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:
+44
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user