From 89a5eea20d3bf036953e433f28637b95ba07527a Mon Sep 17 00:00:00 2001 From: ospab Date: Sat, 27 Jun 2026 16:57:23 +0300 Subject: [PATCH] =?UTF-8?q?=C2=A7C:=20protocol=20version=20gate=20via=20ke?= =?UTF-8?q?y=20derivation=20(reject=20old=20clients)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ostp-core/src/crypto/obfuscation.rs | 32 +++++++++++++++++++++-- ostp-core/src/crypto/obfuscation_tests.rs | 31 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/ostp-core/src/crypto/obfuscation.rs b/ostp-core/src/crypto/obfuscation.rs index a3dd059..ffb519f 100644 --- a/ostp-core/src/crypto/obfuscation.rs +++ b/ostp-core/src/crypto/obfuscation.rs @@ -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(); diff --git a/ostp-core/src/crypto/obfuscation_tests.rs b/ostp-core/src/crypto/obfuscation_tests.rs index 8942af9..80c466b 100644 --- a/ostp-core/src/crypto/obfuscation_tests.rs +++ b/ostp-core/src/crypto/obfuscation_tests.rs @@ -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() {