Files
dosh/tests/protocol_auth.rs
T
DuProcessandClaude Opus 4.8 f9c1973c13 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>
2026-06-14 10:45:44 -04:00

274 lines
8.5 KiB
Rust

use dosh::auth::{
build_bootstrap, decode_bootstrap, encode_bootstrap, load_or_create_server_secret,
open_attach_ticket, verify_attach_ticket, verify_bootstrap,
};
use dosh::config::ServerConfig;
use dosh::crypto;
use dosh::protocol::{self, CLIENT_TO_SERVER, PacketKind, ReplayWindow};
#[test]
fn bootstrap_round_trips_and_verifies() {
let dir = tempfile::tempdir().unwrap();
let config = ServerConfig {
secret_path: dir.path().join("secret").display().to_string(),
..ServerConfig::default()
};
let secret = load_or_create_server_secret(&config).unwrap();
let nonce = crypto::random_12();
let resp = build_bootstrap(
&config,
&secret,
"user".to_string(),
"default".to_string(),
"read-write".to_string(),
(80, 24),
nonce,
"127.0.0.1".to_string(),
)
.unwrap();
assert!(verify_bootstrap(&resp, &secret).unwrap());
let encoded = encode_bootstrap(&resp).unwrap();
let decoded = decode_bootstrap(&encoded).unwrap();
assert_eq!(decoded.session, "default");
assert_eq!(decoded.session_key, resp.session_key);
}
#[test]
fn encrypted_packet_round_trips() {
let key = crypto::random_32();
let conn_id = crypto::random_16();
let packet = protocol::encode_encrypted(
PacketKind::Input,
conn_id,
7,
0,
&key,
CLIENT_TO_SERVER,
b"hello",
)
.unwrap();
let decoded = protocol::decode(&packet).unwrap();
assert_eq!(decoded.header.kind, PacketKind::Input);
assert_eq!(decoded.header.conn_id, conn_id);
assert_eq!(
decoded.header.session_key_id,
protocol::session_key_id(&key)
);
let plain = protocol::decrypt_body(&decoded, &key, CLIENT_TO_SERVER).unwrap();
assert_eq!(plain, b"hello");
}
#[test]
fn encrypted_packet_rejects_wrong_session_key_id_before_decrypt() {
let key = crypto::random_32();
let wrong_key = crypto::random_32();
let packet = protocol::encode_encrypted(
PacketKind::Input,
crypto::random_16(),
1,
0,
&key,
CLIENT_TO_SERVER,
b"hello",
)
.unwrap();
let decoded = protocol::decode(&packet).unwrap();
let err = protocol::decrypt_body(&decoded, &wrong_key, CLIENT_TO_SERVER).unwrap_err();
assert!(err.to_string().contains("session key id"));
}
#[test]
fn attach_ticket_is_sealed_and_verifies_scope() {
let dir = tempfile::tempdir().unwrap();
let config = ServerConfig {
secret_path: dir.path().join("secret").display().to_string(),
..ServerConfig::default()
};
let secret = load_or_create_server_secret(&config).unwrap();
let resp = build_bootstrap(
&config,
&secret,
"user".to_string(),
"work".to_string(),
"view-only".to_string(),
(100, 30),
crypto::random_12(),
"127.0.0.1".to_string(),
)
.unwrap();
let opened = open_attach_ticket(&secret, &resp.attach_ticket).unwrap();
assert_eq!(opened.session, "work");
assert_eq!(opened.mode, "view-only");
assert_eq!(opened.psk, resp.attach_ticket_psk);
assert!(
verify_attach_ticket(
&secret,
&resp.attach_ticket,
&resp.attach_ticket_psk,
"work",
"view-only",
)
.unwrap()
.is_some()
);
assert!(
verify_attach_ticket(
&secret,
&resp.attach_ticket,
&resp.attach_ticket_psk,
"default",
"view-only",
)
.unwrap()
.is_none()
);
}
#[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(&current_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(&current_key, &material, &current_id, 1).unwrap();
let client_view = derive_rekey_session_key(&current_key, &material, &current_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(&current_key, &material, &current_id, 2).unwrap();
assert_ne!(server_view, next_epoch);
let other_material = crypto::random_32();
let other = derive_rekey_session_key(&current_key, &other_material, &current_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);
assert!(replay.accept(10));
assert!(!replay.accept(10));
assert!(replay.accept(12));
assert!(replay.accept(11));
assert!(!replay.accept(11));
assert!(replay.accept(18));
assert!(!replay.accept(9));
assert!(!replay.accept(0));
}