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:
DuProcess
2026-06-14 10:45:44 -04:00
parent 2a6f28d529
commit f9c1973c13
7 changed files with 1283 additions and 169 deletions
+44 -2
View File
@@ -5,8 +5,14 @@ use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
pub const MAGIC: &[u8; 4] = b"DOSH";
pub const VERSION: u8 = 1;
pub const VERSION: u8 = 2;
pub const HEADER_LEN: usize = 58;
/// Stable, user-facing reason string the server puts in an `AttachReject` when a
/// native handshake arrives carrying a `protocol_version` it cannot speak. The
/// client recognizes this prefix and surfaces a clear "upgrade dosh" message
/// instead of the generic transport rejection or, worse, a silent timeout.
pub const VERSION_MISMATCH_REASON: &str = "protocol version mismatch — upgrade dosh";
const HEADER_AAD_LEN: usize = HEADER_LEN - 2;
pub const CLIENT_TO_SERVER: u32 = 1;
pub const SERVER_TO_CLIENT: u32 = 2;
@@ -39,6 +45,8 @@ pub enum PacketKind {
StreamEof = 23,
StreamClose = 24,
NativeAuthCheckOk = 25,
Rekey = 26,
RekeyAck = 27,
}
impl TryFrom<u8> for PacketKind {
@@ -71,6 +79,8 @@ impl TryFrom<u8> for PacketKind {
23 => Self::StreamEof,
24 => Self::StreamClose,
25 => Self::NativeAuthCheckOk,
26 => Self::Rekey,
27 => Self::RekeyAck,
_ => bail!("unknown packet kind {value}"),
})
}
@@ -110,7 +120,11 @@ impl Header {
bail!("bad magic");
}
if input[4] != VERSION {
bail!("bad protocol version {}", input[4]);
bail!(
"{} (peer wire protocol v{}, this build speaks v{VERSION})",
VERSION_MISMATCH_REASON,
input[4]
);
}
let kind = PacketKind::try_from(input[5])?;
let flags = u16::from_be_bytes(input[6..8].try_into().unwrap());
@@ -133,6 +147,20 @@ impl Header {
}
}
/// Cheaply inspect a datagram that carries our [`MAGIC`] but whose wire
/// [`VERSION`] byte differs from this build's. Returns the peer's advertised
/// wire version when (and only when) the packet is a Dosh packet we cannot
/// otherwise decode because of a version skew, so the receiver can answer with a
/// clear, named version-mismatch reject instead of dropping it (a silent
/// timeout for the peer). Returns `None` for our own version, foreign magic, or
/// runt packets.
pub fn peek_foreign_wire_version(input: &[u8]) -> Option<u8> {
if input.len() < 5 || &input[..4] != MAGIC || input[4] == VERSION {
return None;
}
Some(input[4])
}
#[derive(Debug, Clone)]
pub struct Packet {
pub header: Header,
@@ -369,6 +397,20 @@ pub struct StreamClose {
pub stream_id: u64,
}
/// Server→client transport rekey, sealed under the *current* session key.
///
/// Carries the fresh server-generated `rekey_material` and the new `epoch`; both
/// peers feed these into [`crate::native::derive_rekey_session_key`] to compute
/// the next traffic key. `new_session_key_id` is the id the next epoch's packets
/// will carry, so the client can recognize and switch atomically. The client
/// replies with a [`PacketKind::RekeyAck`] encrypted under the *new* key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rekey {
pub epoch: u64,
pub rekey_material: [u8; 32],
pub new_session_key_id: [u8; 16],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Frame {
pub session: String,