Harden native protocol: version discipline, rekey, migration, rate limit, O(1) client index
Track A protocol hardening for native-v1. 1. Protocol VERSION discipline (protocol.rs, native.rs, dosh-server.rs, dosh-client.rs): bump protocol::VERSION to 2 and NATIVE_PROTOCOL_VERSION to 2. Foreign wire-version datagrams now get a clear named "protocol version mismatch - upgrade dosh" reject from the server instead of a silent timeout (peek_foreign_wire_version + VERSION_MISMATCH_REASON). The native handshake's plaintext protocol_version is also checked server-side before any crypto via check_native_protocol_version, returning a typed ProtocolVersionMismatch. 2. Transport rekey (spec section 11/9): server rotates a client's traffic key after rekey_after_packets OR rekey_after_secs (config knobs). Rotated keys are derived independently of the handshake keys from the current key plus fresh server CSPRNG material shipped confidentially in an AEAD Rekey packet (derive_rekey_session_key). The previous epoch's key is retained briefly so in-flight pre-rekey packets still decrypt (matched by session_key_id), and stale-epoch packets are ignored, not fatal. Client handles Rekey/RekeyAck. 3. Connection migration (spec section 11): every authenticated, replay-accepted packet now migrates client.endpoint to the new source address (input, resize, ping, ack, resume, stream*, rekey-ack), not just resume. Ping now verifies its AEAD tag before acting so migration cannot be spoofed. 4. Native auth rate limiting: per-source-IP token bucket (native_auth_rate_limit_per_minute) enforced in handle_native_client_hello BEFORE any X25519/Ed25519 work; over-limit hellos get a non-crypto reject. 5. Speed: replaced O(sessions x clients) per-packet linear scans with an O(1) HashMap<conn_id, session_name> index in ServerState, kept in sync on every client insert/remove (attach handlers, detach, remove_client, cleanup reap, session-exit drain). New config keys (ServerConfig, with defaults): rekey_after_packets = 100000, rekey_after_secs = 3600. Tests: version-mismatch reject (no hang), rate-limit flood reject, rekey round-trip end-to-end + key-derivation unit tests, source-address migration, client-index sync + cleanup purge. fmt and full test suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,139 @@ fn attach_ticket_is_sealed_and_verifies_scope() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peek_foreign_wire_version_flags_only_version_skew() {
|
||||
let key = crypto::random_32();
|
||||
let mut packet = protocol::encode_encrypted(
|
||||
PacketKind::Input,
|
||||
crypto::random_16(),
|
||||
1,
|
||||
0,
|
||||
&key,
|
||||
CLIENT_TO_SERVER,
|
||||
b"hi",
|
||||
)
|
||||
.unwrap();
|
||||
// A correctly framed packet for this build is not "foreign".
|
||||
assert_eq!(protocol::peek_foreign_wire_version(&packet), None);
|
||||
// Bumping the wire version byte makes it undecodable but recognizable.
|
||||
packet[4] = protocol::VERSION.wrapping_add(7);
|
||||
assert_eq!(
|
||||
protocol::peek_foreign_wire_version(&packet),
|
||||
Some(protocol::VERSION.wrapping_add(7))
|
||||
);
|
||||
assert!(protocol::decode(&packet).is_err());
|
||||
// Non-Dosh datagrams and runts are ignored.
|
||||
assert_eq!(protocol::peek_foreign_wire_version(b"XXXX\x01"), None);
|
||||
assert_eq!(protocol::peek_foreign_wire_version(b"DOS"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_key_derivation_agrees_and_is_independent_per_epoch() {
|
||||
use dosh::native::derive_rekey_session_key;
|
||||
|
||||
// The handshake/current key both peers already share.
|
||||
let current_key = crypto::random_32();
|
||||
let current_id = protocol::session_key_id(¤t_key);
|
||||
// Fresh server-generated material, delivered confidentially in the Rekey.
|
||||
let material = crypto::random_32();
|
||||
|
||||
// Both peers derive identically from shared current key + shipped material.
|
||||
let server_view = derive_rekey_session_key(¤t_key, &material, ¤t_id, 1).unwrap();
|
||||
let client_view = derive_rekey_session_key(¤t_key, &material, ¤t_id, 1).unwrap();
|
||||
assert_eq!(server_view, client_view);
|
||||
|
||||
// The rotated key must not equal the handshake/current key.
|
||||
assert_ne!(server_view, current_key);
|
||||
|
||||
// A different epoch (or different material) yields a different key.
|
||||
let next_epoch = derive_rekey_session_key(¤t_key, &material, ¤t_id, 2).unwrap();
|
||||
assert_ne!(server_view, next_epoch);
|
||||
let other_material = crypto::random_32();
|
||||
let other = derive_rekey_session_key(¤t_key, &other_material, ¤t_id, 1).unwrap();
|
||||
assert_ne!(server_view, other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_round_trip_decrypts_old_and_new_epoch_packets() {
|
||||
use dosh::native::derive_rekey_session_key;
|
||||
|
||||
let key_epoch0 = crypto::random_32();
|
||||
let id0 = protocol::session_key_id(&key_epoch0);
|
||||
let conn_id = crypto::random_16();
|
||||
|
||||
// Pre-rekey packet sealed under epoch-0 key.
|
||||
let pre = protocol::encode_encrypted(
|
||||
PacketKind::Frame,
|
||||
conn_id,
|
||||
5,
|
||||
0,
|
||||
&key_epoch0,
|
||||
protocol::SERVER_TO_CLIENT,
|
||||
b"before",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Rotate to epoch 1.
|
||||
let material = crypto::random_32();
|
||||
let key_epoch1 = derive_rekey_session_key(&key_epoch0, &material, &id0, 1).unwrap();
|
||||
let post = protocol::encode_encrypted(
|
||||
PacketKind::Frame,
|
||||
conn_id,
|
||||
6,
|
||||
0,
|
||||
&key_epoch1,
|
||||
protocol::SERVER_TO_CLIENT,
|
||||
b"after",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pre = protocol::decode(&pre).unwrap();
|
||||
let post = protocol::decode(&post).unwrap();
|
||||
|
||||
// Each epoch's key carries its own session_key_id; the receiver picks the
|
||||
// right one and both decrypt correctly.
|
||||
assert_eq!(pre.header.session_key_id, id0);
|
||||
assert_eq!(
|
||||
pre.header.session_key_id,
|
||||
protocol::session_key_id(&key_epoch0)
|
||||
);
|
||||
assert_eq!(
|
||||
post.header.session_key_id,
|
||||
protocol::session_key_id(&key_epoch1)
|
||||
);
|
||||
assert_eq!(
|
||||
protocol::decrypt_body(&pre, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap(),
|
||||
b"before"
|
||||
);
|
||||
assert_eq!(
|
||||
protocol::decrypt_body(&post, &key_epoch1, protocol::SERVER_TO_CLIENT).unwrap(),
|
||||
b"after"
|
||||
);
|
||||
|
||||
// A stale-epoch packet under the wrong key is rejected via session_key_id
|
||||
// BEFORE any AEAD work — ignorable, not a fatal decrypt error.
|
||||
let err = protocol::decrypt_body(&post, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap_err();
|
||||
assert!(err.to_string().contains("session key id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_native_protocol_version_names_the_mismatch() {
|
||||
use dosh::native::{NATIVE_PROTOCOL_VERSION, check_native_protocol_version};
|
||||
check_native_protocol_version(NATIVE_PROTOCOL_VERSION, "server").unwrap();
|
||||
let err = check_native_protocol_version(NATIVE_PROTOCOL_VERSION.wrapping_add(1), "server")
|
||||
.unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains(protocol::VERSION_MISMATCH_REASON),
|
||||
"expected actionable upgrade message, got {message:?}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("server"),
|
||||
"should name the wrong peer: {message:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() {
|
||||
let mut replay = ReplayWindow::new(8);
|
||||
|
||||
Reference in New Issue
Block a user