The core bug: server sent 5 TLS records in server_hello but client only
read the first one (ServerHello), then passed remaining bytes (CCS + fake
records) into RealityStream. RealityStream saw 0x14 (CCS) != 0x17 and
immediately returned an error, killing the connection.
Changes:
- reality.rs: append ChangeCipherSpec after ClientHello (RFC 8446 D.4)
export REALITY_SERVER_HANDSHAKE_RECORDS=5 constant
- xhttp.rs: drain all 5 server handshake records before creating RealityStream
- uot.rs: rebuild server_hello as proper 5-record TLS 1.3 flight:
ServerHello + CCS + fake EE (108B) + fake Cert (812B) + fake Fin (52B)
drain client CCS from raw stream before wrapping in RealityStream
- Flutter: Hide 'DNS Server' field and force '10.1.0.1' if connection link contains owndns=true
- Flutter: Remove 'Use Provider DNS' toggle to eliminate client-side choice
- Server (relay.rs): Intercept DNS queries targeting '10.1.0.1:53' and process them via internal DnsServer if DNS is enabled
- Server (api.rs): Continue appending owndns=true to subscription links to enforce internal DNS logic on clients
- ostp-server/relay.rs: remove DNS port 53 interception — DNS queries
now pass through to the actual DNS server as regular TCP connections
- ostp-client/native_handler.rs (Windows): add explicit gateway/32 route
via real interface BEFORE setting default route via TUN to prevent loop
- ostp-client/native_handler.rs (Linux): properly detect real gateway and
add default route via TUN with metric 10 after server IP exclusion
- Remove redundant extra DNS host routes from Windows setup script
- Boxed HandshakeState in NoiseSession to reduce enum variant sizes
- Used is_ok() instead of let Ok(_) pattern
- Applied automatic clippy fixes for minor warnings
Applied Kerckhoffs's principle: the protocol's security and obfuscation
now depend SOLELY on the access key. An adversary who reverse-engineers
the binary cannot build a DPI filter without knowing the key.
Changes:
- Replaced hardcoded salt string ('-ostp-psk-salt') with HKDF-SHA256.
The salt is now derived from the key hash itself — no protocol-specific
strings remain in the binary.
- Unified all secret derivation into derive_all_secrets() which produces
PSK, obfuscation key, and handshake padding range from a single HKDF
invocation.
- Handshake padding range is now key-derived: different access keys
produce different size distributions (min: 16-79, max: +48..+175).
A universal size-based filter is impossible without the key.
- HKDF-SHA256 (RFC 5869) implemented inline using existing hmac+sha2
dependencies — no new crate required.
What remains identifiable in the binary:
- 'Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s' — standard Noise pattern
string, shared with many other projects, NOT OSTP-specific.
- Generic HMAC/SHA-256/ChaCha20-Poly1305 code — standard crypto
primitives used by millions of applications.
Previously handshake obfuscation used a FIXED mask derived from
HMAC(obf_key, u64::MAX). This meant bytes [4..6] (noise_len XOR
fixed_mask) produced the SAME 2-byte value on every handshake from
the same access key — a correlation fingerprint for DPI.
Now BOTH data and handshake packets use the same payload-sampling
approach:
mask = HMAC-SHA256(obf_key, payload_sample[0..32])
For data packets: payload_sample = AEAD ciphertext (random per packet)
For handshake packets: payload_sample = Noise ephemeral key (random per connection)
Result: every single byte on the wire is cryptographically independent
across packets. No fixed patterns, no correlation between connections.
Wire analysis after this change:
- Packet sizes: random (84-182 for handshake, variable for data)
- All header bytes: unique per packet (XOR with unique HMAC mask)
- Payload bytes: AEAD ciphertext / Noise handshake (indistinguishable from random)
- No protocol signatures, no version fields, no magic bytes visible on wire