mirror of https://github.com/ospab/ostp.git
§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:
parent
38f2d9e659
commit
89a5eea20d
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue