§C: protocol version gate via key derivation (reject old clients)

The base already derives all secrets from the access key, so derived
secrets were never the gap — the gap was that nothing distinguished a
current handshake from an older-format one, so an old client could still
connect to a new server.

Rather than the plan's literal "plaintext version byte before the crypto
layer" (which would add a constant, DPI-visible marker and defeat the
project's stealth north-star), fold the version INTO the HKDF derivation:

- Add PROTOCOL_VERSION (= 4 for 0.4.0), mixed into the IKM of
  derive_all_secrets so a different version yields a completely different
  obfuscation key / psk / padding. No marker ever appears on the wire —
  the output stays indistinguishable from random.
- A pre-0.4.0 peer derives a different obfuscation key, so the 0.4.0
  server cannot recover its handshake header and drops it as an
  unauthorized probe. Bump PROTOCOL_VERSION on any future wire break.

Verified:
- cargo test -p ostp-core: 36/36 incl. new test_protocol_version_gates_
  old_clients (old-version obf key does NOT recover the session_id).
- Loopback E2E: new client <-> new server connects and tunnels HTTPS
  (curl via SOCKS5 returns egress IP).
- Old v0.2.98 client vs new server: handshake times out / aborts, server
  accepts 0 clients — exactly the plan's §C criterion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ospab 2026-06-27 16:57:23 +03:00
parent 38f2d9e659
commit 89a5eea20d
2 changed files with 61 additions and 2 deletions

View File

@ -61,7 +61,27 @@ pub struct DerivedSecrets {
pub handshake_pad_max: usize,
}
/// OSTP wire protocol version. Mixed into key derivation (NOT sent on the
/// wire) so peers running incompatible versions derive entirely different
/// secrets and therefore cannot deobfuscate / decrypt each other's traffic.
///
/// This is a hard, deterministic version gate that needs NO plaintext version
/// byte on the wire — a constant marker would defeat the project's stealth
/// north-star ("no recognizable header"). A pre-0.4.0 client (which derived
/// without a version) produces a different obfuscation key, so a 0.4.0 server
/// cannot recover its handshake header and rejects it as an unauthorized probe.
///
/// Bump this on any wire-breaking protocol change. 0.4.0 = version 4.
pub const PROTOCOL_VERSION: u8 = 4;
pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
derive_all_secrets_versioned(access_key, PROTOCOL_VERSION)
}
/// Version-parameterised derivation. `derive_all_secrets` always pins the
/// current `PROTOCOL_VERSION`; this form exists so tests can prove that a
/// different version yields incompatible secrets (the version gate).
pub(crate) fn derive_all_secrets_versioned(access_key: &[u8], version: u8) -> DerivedSecrets {
// Split the key hash into two halves for salt/info separation.
// This avoids using any hardcoded strings while still providing
// domain separation between the derived values.
@ -70,8 +90,16 @@ pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
let salt = &key_hash[..16];
let info_base = &key_hash[16..];
// Extract PRK from access key using its own hash as salt
let prk = hkdf_extract(salt, access_key);
// Mix the protocol version into the IKM so a different version produces a
// completely different PRK → different obf_key / psk / padding. This is the
// wire-version gate: it is invisible on the wire (only the derived output,
// which is already indistinguishable from random, ever leaves the host).
let mut ikm = Vec::with_capacity(access_key.len() + 1);
ikm.extend_from_slice(access_key);
ikm.push(version);
// Extract PRK from version-tagged access key using its hash as salt
let prk = hkdf_extract(salt, &ikm);
// Derive obfuscation key (8 bytes) — info = key_hash[16..] || 0x01
let mut obf_info = info_base.to_vec();

View File

@ -127,6 +127,37 @@ mod tests {
assert_eq!(correct_sid, session_id, "correct key must recover session_id");
}
/// §C version gate: a peer on a different PROTOCOL_VERSION derives
/// different secrets, so a handshake obfuscated with the OLD version's key
/// does NOT deobfuscate to a valid session_id under the current version.
/// This is exactly what makes an old (pre-0.4.0) client fail to connect to
/// a new server — with no plaintext version marker on the wire.
#[test]
fn test_protocol_version_gates_old_clients() {
let key = b"shared_access_key_across_versions";
let new = derive_all_secrets(key); // == derive_all_secrets_versioned(key, PROTOCOL_VERSION)
let old = derive_all_secrets_versioned(key, PROTOCOL_VERSION.wrapping_sub(1));
// Different protocol version → different derived secrets.
assert_ne!(new.obfuscation_key, old.obfuscation_key, "version must change obf_key");
assert_ne!(new.psk, old.psk, "version must change psk");
// Concretely: a handshake the old client obfuscated with its key does
// not recover a valid session_id when the new server deobfuscates it.
let session_id: u32 = 0x11223344;
let noise = [0x33u8; 48];
let mut pkt = Vec::new();
pkt.extend_from_slice(&session_id.to_be_bytes());
pkt.extend_from_slice(&(noise.len() as u16).to_be_bytes());
pkt.extend_from_slice(&noise);
pkt.extend_from_slice(&[0u8; 32]);
obfuscate_packet_inplace(&mut pkt, &old.obfuscation_key, true); // old client
deobfuscate_packet_inplace(&mut pkt, &new.obfuscation_key, true); // new server
let recovered = u32::from_be_bytes([pkt[0], pkt[1], pkt[2], pkt[3]]);
assert_ne!(recovered, session_id, "old-version client must NOT be accepted by new server");
}
/// Verifies data packet obfuscation round-trip (non-handshake path).
#[test]
fn test_data_packet_obfuscation_roundtrip() {