feat(transport): anti-surveillance - UDP port-knocking + cover traffic
Two opt-in (default off) features directly targeting the kind of operator
dragnet described in the news context — make the server harder to identify
on a scan, and the traffic harder to fingerprint by volume/timing analysis.
1) Port-knocking (probe resistance, UDP)
- Wire: every HS datagram (0x01) is prefixed with a 16-byte HMAC token
when UdpOpts.knock_required is on:
knock = HMAC-SHA256(knock_key, u64_be(unix_minute))[..16]
- Server-side: validates against {now-1, now, now+1} minutes (3-minute
window for clock skew, constant-time compare). Invalid -> silent drop;
the port looks closed to scanners.
- knock_key comes from the CLI (derived from CA fingerprint at the
deployment layer); transport just consumes it.
- DATA datagrams unchanged (AEAD already proves legitimacy past hs).
2) Cover traffic (chaff, UDP)
- Optional background task per UdpConnection: every random delay
(mean_interval_ms +/- jitter, default 500ms +/- 50%) sends a
Frame::Ping{seq=random} when no Data was sent in the recent window
(idle-skip => zero overhead under load). RAII-aborted on Drop.
- Receiver answers Ping with Pong (existing logic); both are consumed
internally by recv_packet, invisible to the app.
API: UdpOpts gains knock_required/knock_key/cover_traffic_enabled/
cover_mean_interval_ms/cover_jitter (all defaults preserve v2 behavior).
Helpers exported: knock_for_minute, KNOCK_LEN.
Local deps: hmac 0.12 + sha2 0.10 (already in workspace lockfile, no new
resolution). Workspace: 185 tests passed (+11), clippy -D warnings clean,
fmt clean. 174 baseline tests unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,7 @@ pub use padding::{
|
||||
};
|
||||
pub use quic::{client_endpoint, server_endpoint, AcceptAnyServerCert};
|
||||
pub use tcp::{TcpClient, TcpConnection, TcpOpts, TcpServer, DEFAULT_TCP_ALPN};
|
||||
pub use udp::{UdpClient, UdpConnection, UdpOpts, UdpServer};
|
||||
pub use udp::{knock_for_minute, UdpClient, UdpConnection, UdpOpts, UdpServer, KNOCK_LEN};
|
||||
|
||||
// Re-export the inner proto trait so downstream crates (the CLI) can name the connection as
|
||||
// `Arc<dyn aura_transport::PacketConnection>` without a separate `aura_proto` import.
|
||||
|
||||
@@ -50,6 +50,7 @@ use std::collections::{BTreeMap, HashMap};
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
@@ -96,15 +97,145 @@ const ACK_NONE: u16 = u16::MAX;
|
||||
/// ~1253 bytes; data records are MTU-sized; this leaves slack for headers + obfuscation padding).
|
||||
const RECV_BUF: usize = 2048;
|
||||
|
||||
/// Length of the port-knock token prefixed on each HS datagram when
|
||||
/// [`UdpOpts::knock_required`] is enabled (truncated HMAC-SHA256 output).
|
||||
pub const KNOCK_LEN: usize = 16;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Time helpers + knock derivation
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Current wall-clock minute since the Unix epoch (`floor(now_secs / 60)`).
|
||||
///
|
||||
/// Returns 0 if the system clock is reported as before the epoch (extremely unusual; the knock
|
||||
/// validator's ±1-minute window absorbs the resulting bucket on healthy peers).
|
||||
fn current_unix_minute() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() / 60)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Current wall-clock milliseconds since the Unix epoch, for the cover-traffic last-send timestamp.
|
||||
fn unix_ms() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Derive the 16-byte port-knock token for `minute` under the shared `key`.
|
||||
///
|
||||
/// Wire formula: `HMAC-SHA256(key, u64_be(minute))[..16]`. The server validates against
|
||||
/// [`current_unix_minute`] and ±1 to tolerate honest clock skew (≈3-minute acceptance window).
|
||||
///
|
||||
/// Exposed primarily as a test seam (drive the validator with a fake minute) and so the CLI / a
|
||||
/// future wire-probe tool can compute the same token; production code does not need to call it
|
||||
/// directly because the adapter prefixes it on every HS datagram when
|
||||
/// [`UdpOpts::knock_required`] is set.
|
||||
pub fn knock_for_minute(key: &[u8; 32], minute: u64) -> [u8; KNOCK_LEN] {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key)
|
||||
.expect("HMAC accepts any key length, so a 32-byte slice cannot fail");
|
||||
mac.update(&minute.to_be_bytes());
|
||||
let tag = mac.finalize().into_bytes();
|
||||
let mut out = [0u8; KNOCK_LEN];
|
||||
out.copy_from_slice(&tag[..KNOCK_LEN]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Constant-time compare of two 16-byte knock tokens. Avoids leaking the index of the first
|
||||
/// differing byte through timing — a defensive choice; the knock is a coarse probe-resistance
|
||||
/// filter, not a per-byte secret, but a tight loop is just as cheap as a non-CT compare here.
|
||||
fn ct_eq_knock(a: &[u8; KNOCK_LEN], b: &[u8; KNOCK_LEN]) -> bool {
|
||||
let mut acc = 0u8;
|
||||
for i in 0..KNOCK_LEN {
|
||||
acc |= a[i] ^ b[i];
|
||||
}
|
||||
acc == 0
|
||||
}
|
||||
|
||||
/// Strip the knock prefix from a datagram from a **known** peer when knocking is on. Returns
|
||||
/// `Some(stripped)` for valid wire layouts, `None` for malformed ones (which the master loop will
|
||||
/// silently drop):
|
||||
///
|
||||
/// * Empty datagram → `None`.
|
||||
/// * `0x02 ...` (DATA) → passed through unchanged (DATA datagrams are never knock-prefixed).
|
||||
/// * `knock(16) || 0x01 || ...` (HS, len ≥ 17) → returns the tail starting at the `0x01`.
|
||||
/// * Anything else → `None`.
|
||||
///
|
||||
/// We do **not** re-validate the knock on the already-known-peer path (per the spec: "На датаграмму
|
||||
/// от известного пира — без проверки knock"). Once an address has registered via a valid first
|
||||
/// knock, subsequent prefixes are trusted as a wire-format artefact, not a continuing auth check.
|
||||
fn strip_knock_for_known_peer(dg: &[u8]) -> Option<Vec<u8>> {
|
||||
if dg.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if dg[0] == TYPE_DATA {
|
||||
return Some(dg.to_vec());
|
||||
}
|
||||
if dg.len() > KNOCK_LEN && dg[KNOCK_LEN] == TYPE_HS {
|
||||
return Some(dg[KNOCK_LEN..].to_vec());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate the leading 16-byte knock prefix against `HMAC(key, minute_be)[..16]` for the current
|
||||
/// Unix-minute and ±1 (a ≈3-minute acceptance window), then return the stripped datagram (with the
|
||||
/// type byte `TYPE_HS` at index 0). Returns `None` on any wire-format or HMAC failure — the caller
|
||||
/// silently drops, so a passive probe sees no response.
|
||||
fn validate_and_strip_knock(dg: &[u8], key: &[u8; 32]) -> Option<Vec<u8>> {
|
||||
if dg.len() <= KNOCK_LEN || dg[KNOCK_LEN] != TYPE_HS {
|
||||
return None;
|
||||
}
|
||||
let mut prefix = [0u8; KNOCK_LEN];
|
||||
prefix.copy_from_slice(&dg[..KNOCK_LEN]);
|
||||
let now = current_unix_minute();
|
||||
// ±1 minute tolerance. Use saturating_sub to avoid wrapping at the epoch boundary.
|
||||
let candidates = [now, now.saturating_sub(1), now.saturating_add(1)];
|
||||
for m in candidates {
|
||||
let expected = knock_for_minute(key, m);
|
||||
if ct_eq_knock(&prefix, &expected) {
|
||||
return Some(dg[KNOCK_LEN..].to_vec());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Options
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Tunables for the UDP transport (handshake reliability timers and obfuscation).
|
||||
/// Tunables for the UDP transport (handshake reliability timers, obfuscation, and the two
|
||||
/// anti-surveillance features).
|
||||
///
|
||||
/// [`UdpOpts::default`] is a sensible production default: obfuscation off, a 250 ms retransmit
|
||||
/// timeout, a 10 s overall handshake deadline, and padding profile `0` (the historical
|
||||
/// [`HTTPS_SIZE_BUCKETS`](padding::HTTPS_SIZE_BUCKETS) palette).
|
||||
/// timeout, a 10 s overall handshake deadline, padding profile `0` (the historical
|
||||
/// [`HTTPS_SIZE_BUCKETS`](padding::HTTPS_SIZE_BUCKETS) palette), **knock disabled** and
|
||||
/// **cover traffic disabled**. The two anti-surveillance toggles are opt-in so existing callers
|
||||
/// keep the pre-feature wire behaviour without any changes.
|
||||
///
|
||||
/// ## Probe resistance — UDP port-knocking
|
||||
///
|
||||
/// When [`Self::knock_required`] is `true`, the client prefixes a 16-byte HMAC token on **every**
|
||||
/// HS datagram it sends; the server silently drops any first datagram from an unknown source whose
|
||||
/// prefix does not validate against the shared [`Self::knock_key`] for the current Unix-minute
|
||||
/// (with ±1 minute tolerance for clock skew). To a passive scanner the listening UDP port looks
|
||||
/// closed. The shared key is the SHA-256 of the Aura CA cert DER (the CLI computes it and supplies
|
||||
/// it here; the transport just consumes the 32 bytes).
|
||||
///
|
||||
/// ## Cover traffic — idle-time chaff
|
||||
///
|
||||
/// When [`Self::cover_traffic_enabled`] is `true`, an established [`UdpConnection`] runs a
|
||||
/// background task that injects encrypted [`Frame::Ping`]s during idle periods so the on-wire byte
|
||||
/// rate stays roughly constant. The interval between attempts is
|
||||
/// `cover_mean_interval_ms ± cover_jitter` (uniform), and an attempt is **skipped** if any DATA
|
||||
/// datagram was sent within the previous interval (so user traffic suppresses chaff). The receiver
|
||||
/// handles each cover Ping exactly like any other Ping (it answers with a Pong and keeps reading)
|
||||
/// — no application-layer awareness needed.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct UdpOpts {
|
||||
/// When `true`, pad every outgoing DATA datagram up to the next bucket of the configured
|
||||
@@ -123,6 +254,30 @@ pub struct UdpOpts {
|
||||
/// How long the post-handshake linger task keeps resending the final flight (so the peer's last
|
||||
/// flight is not lost) before giving up if no DATA datagram arrives.
|
||||
pub hs_linger: Duration,
|
||||
|
||||
// -- anti-surveillance: probe resistance ----------------------------------------------------
|
||||
/// When `true`, port-knocking is required on the server side and the client must prefix the
|
||||
/// 16-byte knock token on every HS datagram (see the type-level "Probe resistance" docs).
|
||||
/// `[Self::knock_key]` MUST be `Some(...)` when this is `true`; if it is not, both ends behave
|
||||
/// as if knocking is off and no knock prefix is added or validated. Default `false` for
|
||||
/// back-compat.
|
||||
pub knock_required: bool,
|
||||
/// Shared 32-byte key for the knock HMAC (typically `SHA-256(CA-cert-DER)`). Used only when
|
||||
/// [`Self::knock_required`] is `true`. Default `None`.
|
||||
pub knock_key: Option<[u8; 32]>,
|
||||
|
||||
// -- anti-surveillance: cover traffic --------------------------------------------------------
|
||||
/// When `true`, after the handshake the [`UdpConnection`] spawns a background task that injects
|
||||
/// encrypted [`Frame::Ping`]s during idle periods (see the type-level "Cover traffic" docs).
|
||||
/// Default `false` for back-compat.
|
||||
pub cover_traffic_enabled: bool,
|
||||
/// Mean interval, in milliseconds, between cover-traffic attempts. Default `500`. Effective
|
||||
/// only when [`Self::cover_traffic_enabled`] is `true`.
|
||||
pub cover_mean_interval_ms: u64,
|
||||
/// Uniform jitter fraction applied to [`Self::cover_mean_interval_ms`] (e.g. `0.5` gives
|
||||
/// ±50%, so the effective interval is `mean * (1 ± 0.5)`). Clamped into `[0.0, 1.0)`. Default
|
||||
/// `0.5`.
|
||||
pub cover_jitter: f32,
|
||||
}
|
||||
|
||||
impl Default for UdpOpts {
|
||||
@@ -133,6 +288,11 @@ impl Default for UdpOpts {
|
||||
hs_rto: Duration::from_millis(250),
|
||||
hs_timeout: Duration::from_secs(10),
|
||||
hs_linger: Duration::from_secs(2),
|
||||
knock_required: false,
|
||||
knock_key: None,
|
||||
cover_traffic_enabled: false,
|
||||
cover_mean_interval_ms: 500,
|
||||
cover_jitter: 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,6 +421,10 @@ struct ReliableHsAdapter {
|
||||
/// Signalled by `poll_write` when new bytes are buffered, so the driver flushes promptly without
|
||||
/// busy-polling.
|
||||
write_notify: Arc<tokio::sync::Notify>,
|
||||
/// Optional port-knock key. When `Some`, **the client** prefixes every outgoing HS datagram with
|
||||
/// the 16-byte `knock_for_minute(key, current_unix_minute())` token (probe resistance). Set
|
||||
/// only on the client side (the server never knocks back); always `None` on the server.
|
||||
knock_key: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// All mutable state of the reliable handshake adapter.
|
||||
@@ -353,17 +517,34 @@ impl ReliableHsAdapter {
|
||||
socket: Arc<PeerSocket>,
|
||||
state: Arc<Mutex<HsState>>,
|
||||
write_notify: Arc<tokio::sync::Notify>,
|
||||
knock_key: Option<[u8; 32]>,
|
||||
) -> Self {
|
||||
Self {
|
||||
socket,
|
||||
state,
|
||||
write_notify,
|
||||
knock_key,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and send one HS datagram carrying `msg_bytes` at sequence `seq` with the current ack.
|
||||
async fn send_hs(socket: &PeerSocket, seq: u16, ack_upto: u16, msg_bytes: &[u8]) {
|
||||
let mut dg = Vec::with_capacity(HS_PREFIX_LEN + msg_bytes.len());
|
||||
///
|
||||
/// When `knock_key` is `Some`, the 16-byte port-knock token for the current Unix-minute is
|
||||
/// prefixed to the datagram (probe-resistance; see [`UdpOpts::knock_required`]). When `None`,
|
||||
/// the datagram is emitted unchanged — matches the historical wire layout.
|
||||
async fn send_hs(
|
||||
socket: &PeerSocket,
|
||||
seq: u16,
|
||||
ack_upto: u16,
|
||||
msg_bytes: &[u8],
|
||||
knock_key: Option<&[u8; 32]>,
|
||||
) {
|
||||
let knock_pad = if knock_key.is_some() { KNOCK_LEN } else { 0 };
|
||||
let mut dg = Vec::with_capacity(knock_pad + HS_PREFIX_LEN + msg_bytes.len());
|
||||
if let Some(key) = knock_key {
|
||||
let token = knock_for_minute(key, current_unix_minute());
|
||||
dg.extend_from_slice(&token);
|
||||
}
|
||||
dg.push(TYPE_HS);
|
||||
dg.extend_from_slice(&seq.to_be_bytes());
|
||||
dg.extend_from_slice(&ack_upto.to_be_bytes());
|
||||
@@ -399,7 +580,14 @@ impl ReliableHsAdapter {
|
||||
st.unacked.insert(seq, msg.clone());
|
||||
(seq, ack, msg)
|
||||
};
|
||||
Self::send_hs(&self.socket, to_send.0, to_send.1, &to_send.2).await;
|
||||
Self::send_hs(
|
||||
&self.socket,
|
||||
to_send.0,
|
||||
to_send.1,
|
||||
&to_send.2,
|
||||
self.knock_key.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +623,7 @@ impl ReliableHsAdapter {
|
||||
let st = self.state.lock().await;
|
||||
(st.next_send_seq, st.ack_upto())
|
||||
};
|
||||
Self::send_hs(&self.socket, seq, ack, &[]).await;
|
||||
Self::send_hs(&self.socket, seq, ack, &[], self.knock_key.as_ref()).await;
|
||||
}
|
||||
|
||||
/// Retransmit all currently-unacked HS datagrams (called on the RTO timer), each carrying the
|
||||
@@ -448,7 +636,7 @@ impl ReliableHsAdapter {
|
||||
(st.ack_upto(), batch)
|
||||
};
|
||||
for (seq, msg) in batch {
|
||||
Self::send_hs(&self.socket, seq, ack, &msg).await;
|
||||
Self::send_hs(&self.socket, seq, ack, &msg, self.knock_key.as_ref()).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,6 +761,7 @@ async fn run_reliable_handshake<F, Fut>(
|
||||
socket: Arc<PeerSocket>,
|
||||
state: Arc<Mutex<HsState>>,
|
||||
opts: UdpOpts,
|
||||
knock_key: Option<[u8; 32]>,
|
||||
run_hs: F,
|
||||
) -> anyhow::Result<Established>
|
||||
where
|
||||
@@ -586,13 +775,20 @@ where
|
||||
socket.clone(),
|
||||
state.clone(),
|
||||
write_notify.clone(),
|
||||
knock_key,
|
||||
));
|
||||
let writer = AdapterWrite(ReliableHsAdapter::new(
|
||||
socket.clone(),
|
||||
state.clone(),
|
||||
write_notify.clone(),
|
||||
knock_key,
|
||||
));
|
||||
let driver = ReliableHsAdapter::new(socket.clone(), state.clone(), write_notify.clone());
|
||||
let driver = ReliableHsAdapter::new(
|
||||
socket.clone(),
|
||||
state.clone(),
|
||||
write_notify.clone(),
|
||||
knock_key,
|
||||
);
|
||||
|
||||
let hs_fut = run_hs(reader, writer);
|
||||
tokio::pin!(hs_fut);
|
||||
@@ -710,16 +906,38 @@ impl AsyncWrite for AdapterWrite {
|
||||
/// surfaces as an error. Late handshake retransmits (`0x01` HS datagrams) seen on the data path are
|
||||
/// dropped. Send and receive use **separate** [`tokio::sync::Mutex`]es, so the two directions run
|
||||
/// concurrently.
|
||||
///
|
||||
/// When [`UdpOpts::cover_traffic_enabled`] is set, the constructor spawns a background task that
|
||||
/// injects encrypted [`Frame::Ping`]s during idle periods (see the type-level "Cover traffic" docs
|
||||
/// on [`UdpOpts`]); the task is `abort`ed on `Drop`.
|
||||
pub struct UdpConnection {
|
||||
socket: Arc<PeerSocket>,
|
||||
sender: Mutex<DatagramSender>,
|
||||
sender: Arc<Mutex<DatagramSender>>,
|
||||
receiver: Mutex<DatagramReceiver>,
|
||||
peer_id: Option<String>,
|
||||
opts: UdpOpts,
|
||||
/// Wall-clock ms of the last datagram **we** emitted on the data path (DATA `0x02`). Updated by
|
||||
/// [`PacketConnection::send_packet`] and by [`PacketConnection::recv_packet`] every time the
|
||||
/// receive path emits a `Pong` reply, and read by the cover task to skip an attempt when the
|
||||
/// link has not been idle. `Arc<AtomicU64>` so the cover task observes the same counter without
|
||||
/// contending on the send mutex.
|
||||
last_send_ms: Arc<AtomicU64>,
|
||||
/// `Some` for server-side connections (keeps the [`UdpServer`]'s master loop alive past the
|
||||
/// server handle being dropped); `None` for client-side connections (the ephemeral
|
||||
/// `connect()`ed socket lives inside the [`PeerSocket`] and needs no external task).
|
||||
_master_task: Option<Arc<MasterTask>>,
|
||||
/// `Some` when [`UdpOpts::cover_traffic_enabled`] was set at construction; `Drop` aborts the
|
||||
/// task so dropping the connection does not leak it. `None` keeps the old wire-silent behaviour.
|
||||
_cover_task: Option<CoverTaskGuard>,
|
||||
}
|
||||
|
||||
/// RAII guard that aborts the cover-traffic task on drop. Wrapping the `JoinHandle` keeps the
|
||||
/// `Drop` impl trivial and avoids the temptation to leak it.
|
||||
struct CoverTaskGuard(tokio::task::JoinHandle<()>);
|
||||
impl Drop for CoverTaskGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl UdpConnection {
|
||||
@@ -728,13 +946,29 @@ impl UdpConnection {
|
||||
opts: UdpOpts,
|
||||
master_task: Option<Arc<MasterTask>>,
|
||||
) -> Self {
|
||||
let sender = Arc::new(Mutex::new(est.sender));
|
||||
// Seed the idle clock to *now* so the cover task's first attempt waits a full interval —
|
||||
// we don't want a cover Ping firing on the same millisecond the connection establishes.
|
||||
let last_send_ms = Arc::new(AtomicU64::new(unix_ms()));
|
||||
let cover_task = if opts.cover_traffic_enabled {
|
||||
Some(CoverTaskGuard(tokio::spawn(cover_traffic_loop(
|
||||
est.socket.clone(),
|
||||
sender.clone(),
|
||||
last_send_ms.clone(),
|
||||
opts,
|
||||
))))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self {
|
||||
socket: est.socket,
|
||||
sender: Mutex::new(est.sender),
|
||||
sender,
|
||||
receiver: Mutex::new(est.receiver),
|
||||
peer_id: est.peer_id,
|
||||
opts,
|
||||
last_send_ms,
|
||||
_master_task: master_task,
|
||||
_cover_task: cover_task,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,6 +986,35 @@ impl UdpConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack an already-sealed AEAD record into one DATA datagram (`0x02 || rec_len(u16) || rec`),
|
||||
/// applying obfuscation padding to the next bucket of `padding_profile` if `obfuscate` is set.
|
||||
///
|
||||
/// Shared by [`PacketConnection::send_packet`], the Ping/Pong reply branch in
|
||||
/// [`PacketConnection::recv_packet`], and the cover-traffic loop — they all produce identical
|
||||
/// on-wire framing.
|
||||
fn pack_data_datagram(rec: &[u8], obfuscate: bool, padding_profile: u8) -> Vec<u8> {
|
||||
let rec_len = rec.len();
|
||||
debug_assert!(
|
||||
rec_len <= u16::MAX as usize,
|
||||
"sealed record exceeds u16 len"
|
||||
);
|
||||
let mut dg = Vec::with_capacity(DATA_PREFIX_LEN + rec_len);
|
||||
dg.push(TYPE_DATA);
|
||||
dg.extend_from_slice(&(rec_len as u16).to_be_bytes());
|
||||
dg.extend_from_slice(rec);
|
||||
if obfuscate {
|
||||
let target = padding::next_bucket_for_profile(dg.len(), padding_profile);
|
||||
if target > dg.len() {
|
||||
let pad = target - dg.len();
|
||||
let mut pad_bytes = vec![0u8; pad];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut pad_bytes);
|
||||
dg.extend_from_slice(&pad_bytes);
|
||||
}
|
||||
}
|
||||
dg
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PacketConnection for UdpConnection {
|
||||
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
|
||||
@@ -762,32 +1025,10 @@ impl PacketConnection for UdpConnection {
|
||||
payload: Bytes::copy_from_slice(packet),
|
||||
})
|
||||
};
|
||||
let rec_len = rec.len();
|
||||
debug_assert!(
|
||||
rec_len <= u16::MAX as usize,
|
||||
"sealed record exceeds u16 len"
|
||||
);
|
||||
|
||||
let mut dg = Vec::with_capacity(DATA_PREFIX_LEN + rec_len);
|
||||
dg.push(TYPE_DATA);
|
||||
dg.extend_from_slice(&(rec_len as u16).to_be_bytes());
|
||||
dg.extend_from_slice(&rec);
|
||||
|
||||
if self.opts.obfuscate {
|
||||
// Pad the *whole datagram* up to the next size bucket of the configured padding
|
||||
// profile (the daily mask picks the profile id). The receiver reads exactly `rec_len`
|
||||
// of the sealed record and ignores the trailing pad bytes.
|
||||
let target = padding::next_bucket_for_profile(dg.len(), self.opts.padding_profile);
|
||||
if target > dg.len() {
|
||||
let pad = target - dg.len();
|
||||
let mut pad_bytes = vec![0u8; pad];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut pad_bytes);
|
||||
dg.extend_from_slice(&pad_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
let dg = pack_data_datagram(&rec, self.opts.obfuscate, self.opts.padding_profile);
|
||||
self.socket.send_dgram(&dg).await?;
|
||||
// Mark the link as non-idle so the cover-traffic loop skips its next attempt.
|
||||
self.last_send_ms.store(unix_ms(), Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -824,11 +1065,14 @@ impl PacketConnection for UdpConnection {
|
||||
let mut tx = self.sender.lock().await;
|
||||
tx.seal(&Frame::Pong { seq })
|
||||
};
|
||||
let mut out = Vec::with_capacity(DATA_PREFIX_LEN + rec.len());
|
||||
out.push(TYPE_DATA);
|
||||
out.extend_from_slice(&(rec.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(&rec);
|
||||
let out = pack_data_datagram(
|
||||
&rec,
|
||||
self.opts.obfuscate,
|
||||
self.opts.padding_profile,
|
||||
);
|
||||
self.socket.send_dgram(&out).await?;
|
||||
// A Pong is just as good as a Data send for cover-traffic suppression.
|
||||
self.last_send_ms.store(unix_ms(), Ordering::Relaxed);
|
||||
}
|
||||
Frame::Pong { .. } => continue,
|
||||
Frame::Close { code, reason } => {
|
||||
@@ -844,6 +1088,61 @@ impl PacketConnection for UdpConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task body: emit encrypted [`Frame::Ping`] chaff during idle periods so the on-wire
|
||||
/// byte rate stays roughly constant, masking user activity (typing, voice, idle).
|
||||
///
|
||||
/// One iteration:
|
||||
/// 1. Sample a uniform delay in `mean * (1 ± jitter)` (clamped to ≥ 1 ms) and sleep that long.
|
||||
/// 2. If we sent anything in the last `delay_ms` (the link was not idle), skip — user traffic
|
||||
/// suppresses chaff one-for-one.
|
||||
/// 3. Otherwise seal one `Frame::Ping { seq = random }` and ship it as a DATA datagram. The peer's
|
||||
/// `recv_packet` answers with a Pong, which our `recv_packet` then drops on the floor — fully
|
||||
/// invisible to the application layer.
|
||||
///
|
||||
/// The receiver-side cover work for the Pong reply happens on the **peer's** existing `recv_packet`
|
||||
/// loop, not here — so this task spawns only an outbound writer; no extra reader is needed.
|
||||
async fn cover_traffic_loop(
|
||||
socket: Arc<PeerSocket>,
|
||||
sender: Arc<Mutex<DatagramSender>>,
|
||||
last_send_ms: Arc<AtomicU64>,
|
||||
opts: UdpOpts,
|
||||
) {
|
||||
use rand::Rng;
|
||||
// Defensive clamp: a misconfigured caller setting `mean = 0` would spin tight.
|
||||
let mean = opts.cover_mean_interval_ms.max(1) as f64;
|
||||
let j = opts.cover_jitter.clamp(0.0, 0.999) as f64;
|
||||
loop {
|
||||
// Uniform delay in [mean*(1-j), mean*(1+j)], floored at 1 ms.
|
||||
let r: f64 = rand::thread_rng().gen_range(-1.0..=1.0);
|
||||
let delay_ms = ((mean * (1.0 + r * j)).max(1.0)) as u64;
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
|
||||
// Idle check: if any DATA datagram was emitted within the last `delay_ms`, the link is busy
|
||||
// and chaff would just add overhead. Skip this round.
|
||||
let now_ms = unix_ms();
|
||||
let last = last_send_ms.load(Ordering::Relaxed);
|
||||
if now_ms.saturating_sub(last) < delay_ms {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Seal one Ping with a random seq and pack it as a DATA datagram.
|
||||
let rec = {
|
||||
let mut tx = sender.lock().await;
|
||||
let seq: u32 = rand::thread_rng().gen();
|
||||
tx.seal(&Frame::Ping { seq })
|
||||
};
|
||||
let dg = pack_data_datagram(&rec, opts.obfuscate, opts.padding_profile);
|
||||
if let Err(e) = socket.send_dgram(&dg).await {
|
||||
// A transient send failure (e.g. UnreachableHost during reconfig) is best-effort;
|
||||
// log and keep trying. A permanent failure will be surfaced by the real send path.
|
||||
tracing::debug!("cover-traffic send failed: {e}");
|
||||
continue;
|
||||
}
|
||||
// Treat the cover send as "we sent something" so back-to-back ticks do not bunch up.
|
||||
last_send_ms.store(now_ms, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-peer inbox capacity in the server's master loop demuxer.
|
||||
///
|
||||
/// 128 datagrams is comfortably more than a single handshake flight (a handful of messages)
|
||||
@@ -1007,9 +1306,27 @@ async fn server_master_loop(
|
||||
};
|
||||
let dg = buf[..n].to_vec();
|
||||
|
||||
// Existing peer (handshake-in-progress OR established): hand it to that peer's inbox.
|
||||
// Cheap RwLock read per datagram so a runtime rotation of the knock key/flag takes effect
|
||||
// for new traffic immediately.
|
||||
let opts_now = *opts.read().await;
|
||||
let knock_active = opts_now.knock_required && opts_now.knock_key.is_some();
|
||||
|
||||
// Existing peer (handshake-in-progress OR established): hand it to that peer's inbox,
|
||||
// stripping the knock prefix on HS datagrams when knocking is on (the peer's adapter expects
|
||||
// the plain `0x01 || ...` wire layout). DATA datagrams (`0x02`) and stray bytes are passed
|
||||
// through unchanged so already-established connections keep working without the prefix.
|
||||
if let Some(tx) = peers.get(&from) {
|
||||
match tx.try_send(dg) {
|
||||
let routed = if knock_active {
|
||||
strip_knock_for_known_peer(&dg)
|
||||
} else {
|
||||
Some(dg)
|
||||
};
|
||||
let Some(routed) = routed else {
|
||||
// Malformed-when-knock-required (no `0x01` after stripping the 16-byte prefix and
|
||||
// not a DATA datagram): silently drop, same as for unknown peers.
|
||||
continue;
|
||||
};
|
||||
match tx.try_send(routed) {
|
||||
Ok(()) => {}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
tracing::warn!("udp inbox full for {from}, dropping datagram");
|
||||
@@ -1023,22 +1340,38 @@ async fn server_master_loop(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown source: only a leading HS byte is allowed to spawn a fresh peer. Late stray
|
||||
// data datagrams from sources we forgot are silently dropped.
|
||||
if dg.is_empty() || dg[0] != TYPE_HS {
|
||||
// Unknown source: only a leading HS byte (after optional knock stripping) may spawn a fresh
|
||||
// peer. Late stray data datagrams from sources we forgot are silently dropped.
|
||||
let first_hs_dg = if knock_active {
|
||||
// `unwrap()` is safe under `knock_active` (it's set only when the key is `Some`).
|
||||
let key = opts_now.knock_key.expect("knock_active implies a key");
|
||||
match validate_and_strip_knock(&dg, &key) {
|
||||
Some(stripped) => stripped,
|
||||
None => {
|
||||
// Silently drop — a probe never gets a reply or even a log at info level. UDP
|
||||
// looks closed to scanners. Keep one debug line for legitimate operators.
|
||||
tracing::debug!("udp port-knock failed from {from}; dropping (probe?)");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else if dg.is_empty() || dg[0] != TYPE_HS {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
dg
|
||||
};
|
||||
|
||||
// Register the peer and pre-load the inbox with its first datagram so the spawned
|
||||
// handshake task picks it up on its first `recv_dgram`.
|
||||
// Register the peer and pre-load the inbox with its first (post-knock-strip) datagram so
|
||||
// the spawned handshake task picks it up on its first `recv_dgram`.
|
||||
let (inbox_tx, inbox_rx) = mpsc::channel::<Vec<u8>>(PEER_INBOX_CAPACITY);
|
||||
// Capacity > 0, so this `try_send` cannot fail; ignore the result defensively.
|
||||
let _ = inbox_tx.try_send(dg);
|
||||
let _ = inbox_tx.try_send(first_hs_dg);
|
||||
peers.insert(from, inbox_tx);
|
||||
|
||||
// Snapshot opts for this peer's lifetime so a concurrent rotation does not change wire
|
||||
// behaviour mid-handshake (matches the single-peer impl's contract).
|
||||
let opts_snap = *opts.read().await;
|
||||
// behaviour mid-handshake (matches the single-peer impl's contract). We already snapshotted
|
||||
// at the top of the loop iteration for the knock check; reuse that exact value so the
|
||||
// routing decision and the spawned task agree.
|
||||
let opts_snap = opts_now;
|
||||
let cfg = proto_cfg.clone();
|
||||
let master_for_peer = master.clone();
|
||||
let acc = accept_tx.clone();
|
||||
@@ -1052,12 +1385,19 @@ async fn server_master_loop(
|
||||
},
|
||||
});
|
||||
let state = Arc::new(Mutex::new(HsState::new()));
|
||||
let result =
|
||||
run_reliable_handshake(peer_socket, state, opts_snap, move |r, w| async move {
|
||||
// Server never knock-prefixes its outgoing HS datagrams (only the client does — see the
|
||||
// `Probe resistance` docs on `UdpOpts`). Pass `None` regardless of `opts_snap.knock_*`.
|
||||
let result = run_reliable_handshake(
|
||||
peer_socket,
|
||||
state,
|
||||
opts_snap,
|
||||
None,
|
||||
move |r, w| async move {
|
||||
let session = server_handshake(r, w, &cfg).await?;
|
||||
Ok(session.into_datagram_parts())
|
||||
})
|
||||
.await;
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(est) => {
|
||||
// Pin the master task alive while this connection lives: upgrading `Weak`
|
||||
@@ -1123,10 +1463,23 @@ impl UdpClient {
|
||||
|
||||
// Fresh (unseeded) state: the client speaks first (ClientHello).
|
||||
let state = Arc::new(Mutex::new(HsState::new()));
|
||||
let est = run_reliable_handshake(peer_socket, state, opts, move |r, w| async move {
|
||||
let session = client_handshake(r, w, &proto_cfg).await?;
|
||||
Ok(session.into_datagram_parts())
|
||||
})
|
||||
// Client knocks if (and only if) BOTH `knock_required` is set AND a key was supplied; this
|
||||
// matches the server's accept policy: missing key on either side ⇒ knocking effectively off.
|
||||
let knock_key = if opts.knock_required {
|
||||
opts.knock_key
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let est = run_reliable_handshake(
|
||||
peer_socket,
|
||||
state,
|
||||
opts,
|
||||
knock_key,
|
||||
move |r, w| async move {
|
||||
let session = client_handshake(r, w, &proto_cfg).await?;
|
||||
Ok(session.into_datagram_parts())
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Client side has no master loop to keep alive — the ephemeral connected socket lives in
|
||||
@@ -1269,4 +1622,144 @@ mod tests {
|
||||
let msg_b: Vec<u8> = st.out_partial.drain(..total).collect();
|
||||
assert_eq!(msg_b, b);
|
||||
}
|
||||
|
||||
// -- Port-knocking helpers -----------------------------------------------------------------
|
||||
|
||||
/// A constant 32-byte key shared by the unit tests below.
|
||||
fn test_key() -> [u8; 32] {
|
||||
let mut k = [0u8; 32];
|
||||
for (i, b) in k.iter_mut().enumerate() {
|
||||
*b = i as u8;
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// Build a knocked HS datagram for an arbitrary minute, with a trivial trailing payload. The
|
||||
/// test cares only about the prefix-validation logic, not the wrapped HS message.
|
||||
fn make_knocked_hs(key: &[u8; 32], minute: u64) -> Vec<u8> {
|
||||
let token = knock_for_minute(key, minute);
|
||||
let mut dg = Vec::with_capacity(KNOCK_LEN + HS_PREFIX_LEN + 8);
|
||||
dg.extend_from_slice(&token);
|
||||
dg.push(TYPE_HS);
|
||||
dg.extend_from_slice(&0u16.to_be_bytes()); // hs_seq = 0
|
||||
dg.extend_from_slice(&ACK_NONE.to_be_bytes()); // ack = none
|
||||
dg.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
dg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knock_for_minute_is_deterministic_and_minute_sensitive() {
|
||||
let k = test_key();
|
||||
// Same input → same output.
|
||||
assert_eq!(
|
||||
knock_for_minute(&k, 1_000_000),
|
||||
knock_for_minute(&k, 1_000_000)
|
||||
);
|
||||
// Different minute → different output.
|
||||
assert_ne!(
|
||||
knock_for_minute(&k, 1_000_000),
|
||||
knock_for_minute(&k, 1_000_001)
|
||||
);
|
||||
// Different key → different output.
|
||||
let mut k2 = k;
|
||||
k2[0] ^= 1;
|
||||
assert_ne!(
|
||||
knock_for_minute(&k, 1_000_000),
|
||||
knock_for_minute(&k2, 1_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_knock_tolerates_clock_skew() {
|
||||
// Cover the spec test name: a datagram knocked for `now-1` / `now+1` must still validate at
|
||||
// the server, but `now-2` / `now+2` must NOT (window is ±1 minute).
|
||||
let key = test_key();
|
||||
let now = current_unix_minute();
|
||||
|
||||
for minute in [now, now.saturating_sub(1), now.saturating_add(1)] {
|
||||
let dg = make_knocked_hs(&key, minute);
|
||||
let stripped = validate_and_strip_knock(&dg, &key).unwrap_or_else(|| {
|
||||
panic!("expected validation pass for minute {minute} (now={now})")
|
||||
});
|
||||
assert_eq!(
|
||||
stripped[0], TYPE_HS,
|
||||
"first byte after strip must be the HS type",
|
||||
);
|
||||
// The stripped tail is exactly the original datagram minus the 16-byte prefix.
|
||||
assert_eq!(stripped, &dg[KNOCK_LEN..]);
|
||||
}
|
||||
|
||||
// Two minutes away (in either direction) must fail.
|
||||
for minute in [now.saturating_sub(2), now.saturating_add(2)] {
|
||||
let dg = make_knocked_hs(&key, minute);
|
||||
assert!(
|
||||
validate_and_strip_knock(&dg, &key).is_none(),
|
||||
"minute {minute} (now={now}) should fall outside the ±1 acceptance window",
|
||||
);
|
||||
}
|
||||
|
||||
// Garbage prefix never validates.
|
||||
let mut bad = make_knocked_hs(&key, now);
|
||||
bad[0] ^= 0xFF;
|
||||
assert!(
|
||||
validate_and_strip_knock(&bad, &key).is_none(),
|
||||
"tampered knock must fail"
|
||||
);
|
||||
|
||||
// Wrong layout: missing `0x01` after the 16 bytes — must fail (and not panic).
|
||||
let mut short = vec![0u8; KNOCK_LEN]; // 16 zero bytes
|
||||
short.push(0xAA); // not TYPE_HS
|
||||
assert!(validate_and_strip_knock(&short, &key).is_none());
|
||||
// Too short overall: must fail without panic.
|
||||
let tiny = vec![0u8; KNOCK_LEN - 1];
|
||||
assert!(validate_and_strip_knock(&tiny, &key).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_peer_strip_handles_data_and_hs_paths() {
|
||||
// DATA datagrams are passed through unchanged (no knock prefix on the data path).
|
||||
let data = vec![TYPE_DATA, 0x00, 0x05, 1, 2, 3, 4, 5];
|
||||
assert_eq!(strip_knock_for_known_peer(&data), Some(data.clone()));
|
||||
|
||||
// HS with a 16-byte (any-bytes) prefix is stripped without validation.
|
||||
let mut hs = vec![0xCDu8; KNOCK_LEN];
|
||||
hs.push(TYPE_HS);
|
||||
hs.extend_from_slice(&[0, 0, 0xFF, 0xFF, 9, 9, 9]);
|
||||
let stripped = strip_knock_for_known_peer(&hs).expect("known-peer strip succeeds");
|
||||
assert_eq!(stripped[0], TYPE_HS);
|
||||
assert_eq!(stripped, hs[KNOCK_LEN..]);
|
||||
|
||||
// Empty: dropped.
|
||||
assert!(strip_knock_for_known_peer(&[]).is_none());
|
||||
|
||||
// Junk: dropped.
|
||||
let junk = vec![0xFFu8; 32];
|
||||
assert!(strip_knock_for_known_peer(&junk).is_none());
|
||||
}
|
||||
|
||||
// -- Cover-traffic packing ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pack_data_datagram_layout_no_obfuscate() {
|
||||
let rec = [1u8, 2, 3, 4, 5];
|
||||
let dg = pack_data_datagram(&rec, false, 0);
|
||||
assert_eq!(dg[0], TYPE_DATA);
|
||||
assert_eq!(u16::from_be_bytes([dg[1], dg[2]]) as usize, rec.len());
|
||||
assert_eq!(&dg[DATA_PREFIX_LEN..], &rec);
|
||||
// No padding when obfuscate is off.
|
||||
assert_eq!(dg.len(), DATA_PREFIX_LEN + rec.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_data_datagram_pads_when_obfuscate_set() {
|
||||
let rec = [0u8; 10];
|
||||
let dg = pack_data_datagram(&rec, true, 0);
|
||||
// Padded up to at least the next bucket; the canonical buckets start above 10 + 3 = 13.
|
||||
assert!(
|
||||
dg.len() >= DATA_PREFIX_LEN + rec.len(),
|
||||
"padded datagram is at least the minimum encoded length",
|
||||
);
|
||||
// Header is still correct (rec_len is unchanged, padding is appended).
|
||||
assert_eq!(u16::from_be_bytes([dg[1], dg[2]]) as usize, rec.len());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user