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
+79
-11
@@ -15,7 +15,7 @@ use std::str::FromStr;
|
||||
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
||||
|
||||
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
|
||||
pub const NATIVE_PROTOCOL_VERSION: u8 = 1;
|
||||
pub const NATIVE_PROTOCOL_VERSION: u8 = 2;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostPublicKey {
|
||||
@@ -248,17 +248,55 @@ pub fn sign_server_hello(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Error raised when a native handshake peer speaks a different protocol version.
|
||||
///
|
||||
/// Carries the peer's advertised version so callers can render an actionable,
|
||||
/// named message ("upgrade dosh") rather than letting a mismatch surface as an
|
||||
/// opaque decrypt failure or a silent timeout. The `Display` text deliberately
|
||||
/// embeds [`crate::protocol::VERSION_MISMATCH_REASON`] so the server's reject and
|
||||
/// the client's local error read the same way.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProtocolVersionMismatch {
|
||||
pub local: u8,
|
||||
pub remote: u8,
|
||||
pub peer: &'static str,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProtocolVersionMismatch {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} ({} speaks native protocol v{}, this build speaks v{})",
|
||||
crate::protocol::VERSION_MISMATCH_REASON,
|
||||
self.peer,
|
||||
self.remote,
|
||||
self.local,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ProtocolVersionMismatch {}
|
||||
|
||||
/// Verify a peer-advertised native protocol version against this build.
|
||||
///
|
||||
/// `peer` names whose version was wrong ("client" or "server") for the error
|
||||
/// message. Returns a typed [`ProtocolVersionMismatch`] so the caller can both
|
||||
/// match on it and print an actionable, upgrade-oriented message.
|
||||
pub fn check_native_protocol_version(version: u8, peer: &'static str) -> Result<()> {
|
||||
if version != NATIVE_PROTOCOL_VERSION {
|
||||
return Err(ProtocolVersionMismatch {
|
||||
local: NATIVE_PROTOCOL_VERSION,
|
||||
remote: version,
|
||||
peer,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHello) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
client.protocol_version == NATIVE_PROTOCOL_VERSION,
|
||||
"unsupported native client protocol {}",
|
||||
client.protocol_version
|
||||
);
|
||||
anyhow::ensure!(
|
||||
server.protocol_version == NATIVE_PROTOCOL_VERSION,
|
||||
"unsupported native server protocol {}",
|
||||
server.protocol_version
|
||||
);
|
||||
check_native_protocol_version(client.protocol_version, "client")?;
|
||||
check_native_protocol_version(server.protocol_version, "server")?;
|
||||
let transcript = server_hello_transcript(client, server)?;
|
||||
verify_host_signature(&server.host_key, &transcript, &server.host_signature)
|
||||
}
|
||||
@@ -369,6 +407,36 @@ pub fn derive_native_session_key(
|
||||
crypto::hkdf32(shared.as_bytes(), &salt, b"dosh/native/chacha20poly1305/v1")
|
||||
}
|
||||
|
||||
/// Derive a rotated transport key for transport rekey (spec §11 / §9).
|
||||
///
|
||||
/// The rotated key is derived **independently of the handshake traffic keys**:
|
||||
/// the input keying material is the current epoch's key (which both peers already
|
||||
/// hold) mixed with fresh, server-generated `rekey_material` (32 bytes of CSPRNG
|
||||
/// output, delivered confidentially inside an AEAD `Rekey` packet sealed under
|
||||
/// the current key). The new `epoch` and the previous epoch's `session_key_id`
|
||||
/// salt the derivation so each epoch's key is unique. It never re-derives from
|
||||
/// the handshake DH output, satisfying the spec requirement that "rotated session
|
||||
/// keys must be derived independently ... from fresh randomness" and "must not
|
||||
/// reuse handshake traffic keys."
|
||||
///
|
||||
/// Both peers run this identically — the client never needs the server's
|
||||
/// long-term secret, only the fresh `rekey_material` it receives in the `Rekey`
|
||||
/// packet plus the current key it already shares.
|
||||
pub fn derive_rekey_session_key(
|
||||
current_key: &[u8; 32],
|
||||
rekey_material: &[u8; 32],
|
||||
previous_session_key_id: &[u8; 16],
|
||||
epoch: u64,
|
||||
) -> Result<[u8; 32]> {
|
||||
let mut ikm = Vec::with_capacity(64);
|
||||
ikm.extend_from_slice(current_key);
|
||||
ikm.extend_from_slice(rekey_material);
|
||||
let mut salt = b"dosh/native/rekey/v1".to_vec();
|
||||
salt.extend_from_slice(previous_session_key_id);
|
||||
salt.extend_from_slice(&epoch.to_be_bytes());
|
||||
crypto::hkdf32(&ikm, &salt, b"dosh/native/rekey/chacha20poly1305/v1")
|
||||
}
|
||||
|
||||
pub fn load_ed25519_identity(path: &Path) -> Result<SigningKey> {
|
||||
load_ed25519_identity_with_passphrase(path, None)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user