Merge track A: VERSION discipline, rekey, migration, rate limiting, O(1) index
This commit is contained in:
+56
-2
@@ -17,7 +17,7 @@ use dosh::native::{
|
||||
use dosh::protocol::{
|
||||
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
||||
NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody,
|
||||
NativeUserAuthBody, PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose,
|
||||
NativeUserAuthBody, PacketKind, Rekey, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose,
|
||||
StreamData, StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody,
|
||||
TicketAttachEnvelope, TicketAttachOkEnvelope,
|
||||
};
|
||||
@@ -2153,6 +2153,9 @@ async fn run_terminal(
|
||||
None
|
||||
};
|
||||
|
||||
// Retain the previous epoch's key briefly after a rekey so any in-flight
|
||||
// pre-rekey frame still decrypts instead of triggering a needless reconnect.
|
||||
let mut previous_session_key: Option<[u8; 32]> = None;
|
||||
let mut recv_buf = vec![0u8; 65535];
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -2193,7 +2196,16 @@ async fn run_terminal(
|
||||
}
|
||||
match packet.header.kind {
|
||||
PacketKind::Frame | PacketKind::ResumeOk => {
|
||||
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
|
||||
let decrypted = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT)
|
||||
.or_else(|err| {
|
||||
// Fall back to the previous epoch key for in-flight
|
||||
// pre-rekey frames before declaring the link stale.
|
||||
match previous_session_key {
|
||||
Some(prev) => protocol::decrypt_body(&packet, &prev, SERVER_TO_CLIENT),
|
||||
None => Err(err),
|
||||
}
|
||||
});
|
||||
let Ok(plain) = decrypted else {
|
||||
if let Some(frame) = reconnect(
|
||||
&socket,
|
||||
&mut cred,
|
||||
@@ -2233,6 +2245,48 @@ async fn run_terminal(
|
||||
PacketKind::Pong => {
|
||||
last_packet_at = Instant::now();
|
||||
}
|
||||
PacketKind::Rekey => {
|
||||
// Server-initiated transport rekey (spec §11). The Rekey is
|
||||
// sealed under the current key; once decrypted we derive the
|
||||
// next key from the shipped fresh material + current key,
|
||||
// switch atomically (keeping the old key for grace), and
|
||||
// confirm with a RekeyAck under the NEW key.
|
||||
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(rekey) = protocol::from_body::<Rekey>(&plain) else {
|
||||
continue;
|
||||
};
|
||||
let previous_key = cred.session_key;
|
||||
let previous_key_id = cred.session_key_id;
|
||||
let Ok(new_key) = dosh::native::derive_rekey_session_key(
|
||||
&previous_key,
|
||||
&rekey.rekey_material,
|
||||
&previous_key_id,
|
||||
rekey.epoch,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
// Only accept if our derivation matches the server's id.
|
||||
if protocol::session_key_id(&new_key) != rekey.new_session_key_id {
|
||||
continue;
|
||||
}
|
||||
previous_session_key = Some(previous_key);
|
||||
cred.session_key = new_key;
|
||||
cred.session_key_id = rekey.new_session_key_id;
|
||||
last_packet_at = Instant::now();
|
||||
send_seq += 1;
|
||||
let ack = protocol::encode_encrypted(
|
||||
PacketKind::RekeyAck,
|
||||
cred.client_id,
|
||||
send_seq,
|
||||
cred.last_rendered_seq,
|
||||
&cred.session_key,
|
||||
CLIENT_TO_SERVER,
|
||||
b"",
|
||||
)?;
|
||||
socket.send_to(&ack, addr).await?;
|
||||
}
|
||||
PacketKind::AttachReject => {
|
||||
let reject: AttachReject = protocol::from_body(&packet.body)?;
|
||||
if reject.reason == "unknown client" {
|
||||
|
||||
+638
-154
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,14 @@ pub struct ServerConfig {
|
||||
pub authorized_keys: Vec<String>,
|
||||
#[serde(default = "default_native_auth_rate_limit_per_minute")]
|
||||
pub native_auth_rate_limit_per_minute: u32,
|
||||
/// Rotate a client's transport traffic key after this many packets in the
|
||||
/// current epoch (spec §11). `0` disables the packet-count trigger.
|
||||
#[serde(default = "default_rekey_after_packets")]
|
||||
pub rekey_after_packets: u64,
|
||||
/// Rotate a client's transport traffic key after this many wall-clock seconds
|
||||
/// in the current epoch (spec §11). `0` disables the time trigger.
|
||||
#[serde(default = "default_rekey_after_secs")]
|
||||
pub rekey_after_secs: u64,
|
||||
#[serde(default = "default_true")]
|
||||
pub allow_tcp_forwarding: bool,
|
||||
#[serde(default)]
|
||||
@@ -61,6 +69,8 @@ impl Default for ServerConfig {
|
||||
host_key: default_host_key(),
|
||||
authorized_keys: default_authorized_keys(),
|
||||
native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(),
|
||||
rekey_after_packets: default_rekey_after_packets(),
|
||||
rekey_after_secs: default_rekey_after_secs(),
|
||||
allow_tcp_forwarding: true,
|
||||
allow_remote_forwarding: false,
|
||||
allow_remote_non_loopback_bind: false,
|
||||
@@ -180,6 +190,18 @@ fn default_native_auth_rate_limit_per_minute() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_rekey_after_packets() -> u64 {
|
||||
// Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a
|
||||
// per-direction monotonic seq is safe far beyond this, but a bounded epoch
|
||||
// keeps forward-secrecy windows small.
|
||||
100_000
|
||||
}
|
||||
|
||||
fn default_rekey_after_secs() -> u64 {
|
||||
// One hour per epoch by default.
|
||||
3600
|
||||
}
|
||||
|
||||
fn default_auth_preference() -> String {
|
||||
"native,ssh".to_string()
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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(¤t_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,
|
||||
¤t_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, ¤t_key, SERVER_TO_CLIENT).unwrap();
|
||||
let rekey: Rekey = protocol::from_body(&plain).unwrap();
|
||||
let new_key = derive_rekey_session_key(
|
||||
¤t_key,
|
||||
&rekey.rekey_material,
|
||||
¤t_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,
|
||||
¤t_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,
|
||||
¤t_key,
|
||||
&protocol::to_body(&input).unwrap(),
|
||||
);
|
||||
send_seq += 1;
|
||||
}
|
||||
PacketKind::Frame | PacketKind::ResumeOk => {
|
||||
if let Ok(plain) = protocol::decrypt_body(&packet, ¤t_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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(¤t_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(¤t_key, &material, ¤t_id, 1).unwrap();
|
||||
let client_view = derive_rekey_session_key(¤t_key, &material, ¤t_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(¤t_key, &material, ¤t_id, 2).unwrap();
|
||||
assert_ne!(server_view, next_epoch);
|
||||
let other_material = crypto::random_32();
|
||||
let other = derive_rekey_session_key(¤t_key, &other_material, ¤t_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);
|
||||
|
||||
Reference in New Issue
Block a user