From 5ab6833eaba6939fb5f537c98f40676ffcf33be8 Mon Sep 17 00:00:00 2001 From: ospab Date: Thu, 9 Jul 2026 14:43:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(core):=20time-rotating=20junk=20marker=20?= =?UTF-8?q?=E2=80=94=20kill=20the=20static=20per-user=20fingerprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The junk marker was a per-key CONSTANT sent in plaintext at a fixed offset in junk frames. Junk is meant to look like random noise (zapret-style), but a constant prefix is a recognizable per-user structure: an on-path observer watching one user sees the same 4 bytes on every junk packet, i.e. an OSTP fingerprint. (The earlier fix only removed the GLOBAL constant.) Now the marker rotates every 60s window: junk_marker = HKDF(key, ver, 0x04 || window). To an observer the prefix changes each window (no fixed signature), and a captured marker is only valid for ~1 window — the "bit of protection" against a leaked marker. Only a key holder can compute it, so an outsider still can't forge a silently-dropped junk packet (and silent-drop is cheaper than normal processing anyway, so junk spam was never a DoS lever to begin with). - core: derive_junk_marker(key, window) + current_junk_window() (60s window), same version-gated HKDF scheme; junk_marker dropped from DerivedSecrets. - client: stamps junk with the current window's marker. - server: checks current AND previous window per key (absorbs ~1 window of clock skew) before falling through to unauthorized-probe handling. - Not a wire break: only junk framing changes; real handshake/data untouched. During mixed rollout, unmatched junk merely logs as a probe (cosmetic). --- ostp-client/src/bridge.rs | 11 ++-- ostp-core/src/crypto/mod.rs | 1 + ostp-core/src/crypto/obfuscation.rs | 65 +++++++++++++++++------ ostp-core/src/crypto/obfuscation_tests.rs | 25 +++++++++ ostp-server/src/dispatcher.rs | 18 +++++-- 5 files changed, 97 insertions(+), 23 deletions(-) diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index 644b45a..0bbf873 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -1060,9 +1060,14 @@ impl Bridge { let frag_sleep = self.frag_sleep; let [junk_pc_min, junk_pc_max] = self.junk_pc; let [junk_ps_min, junk_ps_max] = self.junk_ps; - // Per-key junk marker (derived from the access key) — NOT a global - // constant, so junk frames carry no universal DPI signature. - let junk_marker = ostp_core::crypto::derive_all_secrets(&self.access_key).junk_marker; + // Time-rotating per-key junk marker — NOT a global constant and NOT + // even a static per-user value: it changes every window, so junk + // carries no fixed DPI signature on the wire. All frames in this + // burst are sent within milliseconds, so one window applies to all. + let junk_marker = ostp_core::crypto::derive_junk_marker( + &self.access_key, + ostp_core::crypto::current_junk_window(), + ); { use tokio::io::AsyncWriteExt; diff --git a/ostp-core/src/crypto/mod.rs b/ostp-core/src/crypto/mod.rs index deb9b31..4caab84 100644 --- a/ostp-core/src/crypto/mod.rs +++ b/ostp-core/src/crypto/mod.rs @@ -8,4 +8,5 @@ pub use noise::{NoiseRole, NoiseSession}; pub use obfuscation::{ deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace, derive_obfuscation_key, derive_psk, derive_all_secrets, DerivedSecrets, + derive_junk_marker, current_junk_window, JUNK_MARKER_WINDOW_SECS, }; diff --git a/ostp-core/src/crypto/obfuscation.rs b/ostp-core/src/crypto/obfuscation.rs index e52fcbe..63b1427 100644 --- a/ostp-core/src/crypto/obfuscation.rs +++ b/ostp-core/src/crypto/obfuscation.rs @@ -59,11 +59,10 @@ pub struct DerivedSecrets { pub psk: [u8; 32], pub handshake_pad_min: usize, pub handshake_pad_max: usize, - /// Per-key 4-byte prefix stamped on junk frames so the server can drop them - /// without a GLOBAL constant marker (which would be a universal DPI signature - /// for all OSTP users — exactly what the version gate avoids for the handshake). - pub junk_marker: [u8; 4], } +// NOTE: the junk marker is NOT part of DerivedSecrets — it is time-rotating and +// derived separately per window via `derive_junk_marker` (see below), so it +// carries no static per-user signature. /// OSTP wire protocol version. Mixed into key derivation (NOT sent on the /// wire) so peers running incompatible versions derive entirely different @@ -129,25 +128,61 @@ pub(crate) fn derive_all_secrets_versioned(access_key: &[u8], version: u8) -> De let pad_min = 16 + (pad_bytes[0] as usize % 64); // 16-79 let pad_max = pad_min + 48 + (pad_bytes[1] as usize % 128); // +48..+175 - // Derive junk marker (4 bytes) — info = key_hash[16..] || 0x04. - // Per-key: to an outsider it is indistinguishable from the random junk - // payload, so there is no cross-user signature; the server, knowing the key, - // derives the same marker and drops the junk silently. - let mut junk_info = info_base.to_vec(); - junk_info.push(0x04); - let junk_bytes = hkdf_expand(&prk, &junk_info, 4); - let mut junk_marker = [0u8; 4]; - junk_marker.copy_from_slice(&junk_bytes); - DerivedSecrets { obfuscation_key, psk, handshake_pad_min: pad_min, handshake_pad_max: pad_max, - junk_marker, } } +/// Window length (seconds) for the rotating junk marker. The marker changes +/// every window, so junk carries no static per-user fingerprint on the wire; +/// the server checks the current and previous window to absorb clock skew. +pub const JUNK_MARKER_WINDOW_SECS: u64 = 60; + +/// The current junk-marker time window (unix seconds / window length). +pub fn current_junk_window() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() / JUNK_MARKER_WINDOW_SECS) + .unwrap_or(0) +} + +/// Derive the 4-byte junk marker for a given time `window`. +/// +/// Uses the same version-gated HKDF scheme as [`derive_all_secrets`], with the +/// window folded into the `info` (label byte `0x04`). Folding in the window +/// makes the marker rotate: to an on-path observer the junk prefix changes every +/// window (no fixed signature), and a captured marker is only valid for ~1 +/// window. Only a holder of the access key can compute it, so an outsider cannot +/// forge a silently-dropped junk packet. +pub fn derive_junk_marker(access_key: &[u8], window: u64) -> [u8; 4] { + derive_junk_marker_versioned(access_key, window, PROTOCOL_VERSION) +} + +pub(crate) fn derive_junk_marker_versioned(access_key: &[u8], window: u64, version: u8) -> [u8; 4] { + use sha2::Digest; + let key_hash = sha2::Sha256::digest(access_key); + let salt = &key_hash[..16]; + let info_base = &key_hash[16..]; + + let mut ikm = Vec::with_capacity(access_key.len() + 1); + ikm.extend_from_slice(access_key); + ikm.push(version); + let prk = hkdf_extract(salt, &ikm); + + // info = key_hash[16..] || 0x04 || window(LE) — same label byte as before, + // now parameterised by the time window. + let mut info = info_base.to_vec(); + info.push(0x04); + info.extend_from_slice(&window.to_le_bytes()); + let bytes = hkdf_expand(&prk, &info, 4); + let mut marker = [0u8; 4]; + marker.copy_from_slice(&bytes); + marker +} + // ── Legacy API (delegates to derive_all_secrets) ───────────────────────────── pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] { diff --git a/ostp-core/src/crypto/obfuscation_tests.rs b/ostp-core/src/crypto/obfuscation_tests.rs index 80c466b..9411f3d 100644 --- a/ostp-core/src/crypto/obfuscation_tests.rs +++ b/ostp-core/src/crypto/obfuscation_tests.rs @@ -191,4 +191,29 @@ mod tests { assert_eq!(recovered_nonce, nonce); assert_eq!(&packet[12..], &ciphertext); } + + /// The junk marker must: be stable within a window (client and server agree), + /// rotate across windows (no static on-wire fingerprint), and differ per key + /// (one user's marker never silently-drops on another user's flow). + #[test] + fn test_junk_marker_rotation() { + let key_a = b"access-key-alpha"; + let key_b = b"access-key-bravo"; + + // Stable within a window. + assert_eq!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1000)); + + // Rotates across adjacent windows. + assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1001)); + assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 999)); + + // Distinct per key within the same window. + assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_b, 1000)); + + // A different protocol version yields a different marker (version gate). + assert_ne!( + derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION), + derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION.wrapping_add(1)), + ); + } } diff --git a/ostp-server/src/dispatcher.rs b/ostp-server/src/dispatcher.rs index 84e0606..d3bf540 100644 --- a/ostp-server/src/dispatcher.rs +++ b/ostp-server/src/dispatcher.rs @@ -305,14 +305,22 @@ impl Dispatcher { // Not an existing session — try each registered access key's derived obfuscation key let keys_snapshot: Vec = self.access_keys.read().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect(); + // Junk marker rotates per time window; check the current and previous + // window so a client whose clock is up to ~1 window behind/ahead is still + // recognised. Computed once per datagram, not per candidate key. + let junk_window = ostp_core::crypto::current_junk_window(); + for candidate_key in keys_snapshot { let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes()); - // Junk frames carry this key's per-key derived marker (no global - // constant → no universal DPI signature). Drop silently — the secrets - // for this key are already derived here, so the check is free. - if packet.len() >= 4 && packet[0..4] == secrets.junk_marker { - return Ok(DispatchOutcome::Junk); + // Junk frames carry this key's time-rotating marker (no global + // constant, no static per-user signature). Drop silently. + if packet.len() >= 4 { + let m_now = ostp_core::crypto::derive_junk_marker(candidate_key.as_bytes(), junk_window); + let m_prev = ostp_core::crypto::derive_junk_marker(candidate_key.as_bytes(), junk_window.wrapping_sub(1)); + if packet[0..4] == m_now || packet[0..4] == m_prev { + return Ok(DispatchOutcome::Junk); + } } // Decode the session_id using this key's obfuscation