Merge track A: VERSION discipline, rekey, migration, rate limiting, O(1) index

This commit is contained in:
DuProcess
2026-06-14 10:47:14 -04:00
7 changed files with 1283 additions and 169 deletions
+311
View File
@@ -1246,3 +1246,314 @@ fn resume_updates_udp_endpoint_for_roaming() {
"expected output on resumed socket, got {text:?}"
);
}
#[test]
fn transport_rekey_round_trip_keeps_session_alive() {
use dosh::native::derive_rekey_session_key;
use dosh::protocol::Rekey;
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
// Rotate after a single packet so a rekey fires almost immediately.
let mut raw = fs::read_to_string(&config).unwrap();
raw.push_str("rekey_after_packets = 1\n");
fs::write(&config, raw).unwrap();
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
socket
.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
// Track the live transport key; it rotates when a Rekey arrives.
let mut current_key = bootstrap.session_key;
let mut current_key_id = protocol::session_key_id(&current_key);
let mut send_seq = 2u64;
// Produce some output so the server starts sending frames (and rekeys).
let input = Input {
bytes: b"printf DOSH_REKEY_ONE\\n\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
send_seq,
0,
&current_key,
&protocol::to_body(&input).unwrap(),
);
send_seq += 1;
// Drive the loop: decrypt frames under the live key, and when a Rekey lands,
// adopt the new epoch key and ack it. Confirm a post-rekey input still works.
let mut rekeyed = false;
let mut saw_post_rekey_output = false;
let mut buf = [0u8; 65535];
let deadline = std::time::Instant::now() + Duration::from_secs(8);
while std::time::Instant::now() < deadline {
let Ok((n, _)) = socket.recv_from(&mut buf) else {
continue;
};
let Ok(packet) = protocol::decode(&buf[..n]) else {
continue;
};
match packet.header.kind {
PacketKind::Rekey => {
let plain =
protocol::decrypt_body(&packet, &current_key, SERVER_TO_CLIENT).unwrap();
let rekey: Rekey = protocol::from_body(&plain).unwrap();
let new_key = derive_rekey_session_key(
&current_key,
&rekey.rekey_material,
&current_key_id,
rekey.epoch,
)
.unwrap();
assert_eq!(
protocol::session_key_id(&new_key),
rekey.new_session_key_id,
"client-derived rekey key id must match the server's"
);
current_key = new_key;
current_key_id = rekey.new_session_key_id;
// Ack under the NEW key.
send_encrypted(
&socket,
port,
PacketKind::RekeyAck,
ok.client_id,
send_seq,
0,
&current_key,
b"",
);
send_seq += 1;
rekeyed = true;
// Now send a fresh input under the new key.
let input = Input {
bytes: b"printf DOSH_REKEY_TWO\\n\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
send_seq,
0,
&current_key,
&protocol::to_body(&input).unwrap(),
);
send_seq += 1;
}
PacketKind::Frame | PacketKind::ResumeOk => {
if let Ok(plain) = protocol::decrypt_body(&packet, &current_key, SERVER_TO_CLIENT) {
if let Ok(frame) = protocol::from_body::<Frame>(&plain) {
let text = String::from_utf8_lossy(&frame.bytes);
if rekeyed && text.contains("DOSH_REKEY_TWO") {
saw_post_rekey_output = true;
break;
}
}
}
}
_ => {}
}
}
let _ = server.kill();
let _ = server.wait();
assert!(rekeyed, "server never initiated a rekey");
assert!(
saw_post_rekey_output,
"post-rekey input/output round trip failed under the new epoch key"
);
}
#[test]
fn input_from_new_source_address_migrates_connection() {
// Spec §11: the server must accept client source-address migration after ANY
// valid authenticated/encrypted packet from a new address, not just resume.
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (_old_socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
// A fresh socket = a new source address (new ephemeral port). The very first
// packet from it is an ordinary encrypted Input, not a ResumeRequest.
let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
new_socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let input = Input {
bytes: b"printf DOSH_MIGRATE\\n\n".to_vec(),
};
let packet = protocol::encode_encrypted(
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&input).unwrap(),
)
.unwrap();
new_socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
// Output frames for this input must now be delivered to the NEW socket,
// proving the server migrated `endpoint` off the original address.
let text = collect_frame_text(&new_socket, &bootstrap.session_key, 2000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("DOSH_MIGRATE"),
"expected server output on the migrated socket, got {text:?}"
);
}
#[test]
fn native_auth_rate_limit_rejects_flood_before_crypto() {
use dosh::native::{NATIVE_PROTOCOL_VERSION, NativeClientHello};
use dosh::protocol::NativeClientHelloBody;
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
// Squeeze the rate limit down to 2/min so a short burst trips it.
let mut raw = fs::read_to_string(&config).unwrap();
raw.push_str("native_auth_rate_limit_per_minute = 2\n");
fs::write(&config, raw).unwrap();
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
let make_hello = || {
let (_, client_public) = dosh::native::generate_native_ephemeral();
let hello = NativeClientHello {
protocol_version: NATIVE_PROTOCOL_VERSION,
client_random: crypto::random_32(),
client_ephemeral_public: client_public,
requested_host: "local".to_string(),
requested_user: user.clone(),
requested_session: "default".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
};
protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody { hello }).unwrap(),
)
.unwrap()
};
let mut reasons = Vec::new();
// Burst of hellos; with a 2/min budget the later ones must be rejected.
for _ in 0..6 {
socket
.send_to(&make_hello(), format!("127.0.0.1:{port}"))
.unwrap();
let mut buf = [0u8; 65535];
if let Ok((n, _)) = socket.recv_from(&mut buf) {
let packet = protocol::decode(&buf[..n]).unwrap();
if packet.header.kind == PacketKind::AttachReject {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
reasons.push(reject.reason);
}
}
}
let _ = server.kill();
let _ = server.wait();
assert!(
reasons.iter().any(|r| r.contains("rate limit")),
"expected a rate-limit reject within the burst, got {reasons:?}"
);
}
#[test]
fn native_hello_with_mismatched_protocol_version_gets_named_reject_not_hang() {
use dosh::native::{NATIVE_PROTOCOL_VERSION, NativeClientHello};
use dosh::protocol::{NativeClientHelloBody, VERSION_MISMATCH_REASON};
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
// Same wire VERSION as the server (so the datagram decodes), but an
// incompatible native handshake protocol_version. This is the spec's
// plaintext negotiation point: the server must answer with a clear, named
// reject rather than letting the client time out.
let hello = NativeClientHello {
protocol_version: NATIVE_PROTOCOL_VERSION.wrapping_add(1),
client_random: crypto::random_32(),
client_ephemeral_public: [3u8; 32],
requested_host: "local".to_string(),
requested_user: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()),
requested_session: "default".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
};
let packet = protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody { hello }).unwrap(),
)
.unwrap();
socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
let mut buf = [0u8; 65535];
// recv with the 2s read timeout above: a hang would surface as a recv error
// here instead of a reject, which is exactly what this test guards against.
let (n, _) = socket
.recv_from(&mut buf)
.expect("server must answer a version-mismatched hello, not hang");
let packet = protocol::decode(&buf[..n]).unwrap();
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
let _ = server.kill();
let _ = server.wait();
assert_eq!(packet.header.kind, PacketKind::AttachReject);
assert!(
reject.reason.contains(VERSION_MISMATCH_REASON),
"expected a named protocol-version-mismatch reject, got {:?}",
reject.reason
);
}
+133
View File
@@ -126,6 +126,139 @@ fn attach_ticket_is_sealed_and_verifies_scope() {
);
}
#[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);