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:
co-authored by
Claude Opus 4.8
parent
2a6f28d529
commit
f9c1973c13
+56
-2
@@ -17,7 +17,7 @@ use dosh::native::{
|
||||
use dosh::protocol::{
|
||||
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
||||
NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody,
|
||||
NativeUserAuthBody, PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose,
|
||||
NativeUserAuthBody, PacketKind, Rekey, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose,
|
||||
StreamData, StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody,
|
||||
TicketAttachEnvelope, TicketAttachOkEnvelope,
|
||||
};
|
||||
@@ -2145,6 +2145,9 @@ async fn run_terminal(
|
||||
None
|
||||
};
|
||||
|
||||
// Retain the previous epoch's key briefly after a rekey so any in-flight
|
||||
// pre-rekey frame still decrypts instead of triggering a needless reconnect.
|
||||
let mut previous_session_key: Option<[u8; 32]> = None;
|
||||
let mut recv_buf = vec![0u8; 65535];
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -2185,7 +2188,16 @@ async fn run_terminal(
|
||||
}
|
||||
match packet.header.kind {
|
||||
PacketKind::Frame | PacketKind::ResumeOk => {
|
||||
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
|
||||
let decrypted = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT)
|
||||
.or_else(|err| {
|
||||
// Fall back to the previous epoch key for in-flight
|
||||
// pre-rekey frames before declaring the link stale.
|
||||
match previous_session_key {
|
||||
Some(prev) => protocol::decrypt_body(&packet, &prev, SERVER_TO_CLIENT),
|
||||
None => Err(err),
|
||||
}
|
||||
});
|
||||
let Ok(plain) = decrypted else {
|
||||
if let Some(frame) = reconnect(
|
||||
&socket,
|
||||
&mut cred,
|
||||
@@ -2225,6 +2237,48 @@ async fn run_terminal(
|
||||
PacketKind::Pong => {
|
||||
last_packet_at = Instant::now();
|
||||
}
|
||||
PacketKind::Rekey => {
|
||||
// Server-initiated transport rekey (spec §11). The Rekey is
|
||||
// sealed under the current key; once decrypted we derive the
|
||||
// next key from the shipped fresh material + current key,
|
||||
// switch atomically (keeping the old key for grace), and
|
||||
// confirm with a RekeyAck under the NEW key.
|
||||
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(rekey) = protocol::from_body::<Rekey>(&plain) else {
|
||||
continue;
|
||||
};
|
||||
let previous_key = cred.session_key;
|
||||
let previous_key_id = cred.session_key_id;
|
||||
let Ok(new_key) = dosh::native::derive_rekey_session_key(
|
||||
&previous_key,
|
||||
&rekey.rekey_material,
|
||||
&previous_key_id,
|
||||
rekey.epoch,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
// Only accept if our derivation matches the server's id.
|
||||
if protocol::session_key_id(&new_key) != rekey.new_session_key_id {
|
||||
continue;
|
||||
}
|
||||
previous_session_key = Some(previous_key);
|
||||
cred.session_key = new_key;
|
||||
cred.session_key_id = rekey.new_session_key_id;
|
||||
last_packet_at = Instant::now();
|
||||
send_seq += 1;
|
||||
let ack = protocol::encode_encrypted(
|
||||
PacketKind::RekeyAck,
|
||||
cred.client_id,
|
||||
send_seq,
|
||||
cred.last_rendered_seq,
|
||||
&cred.session_key,
|
||||
CLIENT_TO_SERVER,
|
||||
b"",
|
||||
)?;
|
||||
socket.send_to(&ack, addr).await?;
|
||||
}
|
||||
PacketKind::AttachReject => {
|
||||
let reject: AttachReject = protocol::from_body(&packet.body)?;
|
||||
if reject.reason == "unknown client" {
|
||||
|
||||
+638
-154
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user