mirror of https://github.com/ospab/ostp.git
feat(core): time-rotating junk marker — kill the static per-user fingerprint
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).
This commit is contained in:
parent
5dc3a60017
commit
5ab6833eab
|
|
@ -1060,9 +1060,14 @@ impl Bridge {
|
||||||
let frag_sleep = self.frag_sleep;
|
let frag_sleep = self.frag_sleep;
|
||||||
let [junk_pc_min, junk_pc_max] = self.junk_pc;
|
let [junk_pc_min, junk_pc_max] = self.junk_pc;
|
||||||
let [junk_ps_min, junk_ps_max] = self.junk_ps;
|
let [junk_ps_min, junk_ps_max] = self.junk_ps;
|
||||||
// Per-key junk marker (derived from the access key) — NOT a global
|
// Time-rotating per-key junk marker — NOT a global constant and NOT
|
||||||
// constant, so junk frames carry no universal DPI signature.
|
// even a static per-user value: it changes every window, so junk
|
||||||
let junk_marker = ostp_core::crypto::derive_all_secrets(&self.access_key).junk_marker;
|
// 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;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,5 @@ pub use noise::{NoiseRole, NoiseSession};
|
||||||
pub use obfuscation::{
|
pub use obfuscation::{
|
||||||
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
|
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
|
||||||
derive_obfuscation_key, derive_psk, derive_all_secrets, DerivedSecrets,
|
derive_obfuscation_key, derive_psk, derive_all_secrets, DerivedSecrets,
|
||||||
|
derive_junk_marker, current_junk_window, JUNK_MARKER_WINDOW_SECS,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,10 @@ pub struct DerivedSecrets {
|
||||||
pub psk: [u8; 32],
|
pub psk: [u8; 32],
|
||||||
pub handshake_pad_min: usize,
|
pub handshake_pad_min: usize,
|
||||||
pub handshake_pad_max: 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
|
/// OSTP wire protocol version. Mixed into key derivation (NOT sent on the
|
||||||
/// wire) so peers running incompatible versions derive entirely different
|
/// 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_min = 16 + (pad_bytes[0] as usize % 64); // 16-79
|
||||||
let pad_max = pad_min + 48 + (pad_bytes[1] as usize % 128); // +48..+175
|
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 {
|
DerivedSecrets {
|
||||||
obfuscation_key,
|
obfuscation_key,
|
||||||
psk,
|
psk,
|
||||||
handshake_pad_min: pad_min,
|
handshake_pad_min: pad_min,
|
||||||
handshake_pad_max: pad_max,
|
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) ─────────────────────────────
|
// ── Legacy API (delegates to derive_all_secrets) ─────────────────────────────
|
||||||
|
|
||||||
pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] {
|
pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] {
|
||||||
|
|
|
||||||
|
|
@ -191,4 +191,29 @@ mod tests {
|
||||||
assert_eq!(recovered_nonce, nonce);
|
assert_eq!(recovered_nonce, nonce);
|
||||||
assert_eq!(&packet[12..], &ciphertext);
|
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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,14 +305,22 @@ impl Dispatcher {
|
||||||
// Not an existing session — try each registered access key's derived obfuscation key
|
// Not an existing session — try each registered access key's derived obfuscation key
|
||||||
let keys_snapshot: Vec<String> = self.access_keys.read().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect();
|
let keys_snapshot: Vec<String> = 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 {
|
for candidate_key in keys_snapshot {
|
||||||
let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes());
|
let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes());
|
||||||
|
|
||||||
// Junk frames carry this key's per-key derived marker (no global
|
// Junk frames carry this key's time-rotating marker (no global
|
||||||
// constant → no universal DPI signature). Drop silently — the secrets
|
// constant, no static per-user signature). Drop silently.
|
||||||
// for this key are already derived here, so the check is free.
|
if packet.len() >= 4 {
|
||||||
if packet.len() >= 4 && packet[0..4] == secrets.junk_marker {
|
let m_now = ostp_core::crypto::derive_junk_marker(candidate_key.as_bytes(), junk_window);
|
||||||
return Ok(DispatchOutcome::Junk);
|
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
|
// Decode the session_id using this key's obfuscation
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue