Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 63 additions & 18 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -255,18 +255,31 @@ jobs:
fi

- name: Run the hardening.36 path-switch convergence money test under sudo (poll driver)
# run-netns-pathswitch-rehandshake.sh: proves the #36 fix -- a
# Punch->Relay path re-target must not draw a fresh Noise ephemeral.
# Forks run-netns-relay.sh's RELAY-FORCED topology (A/B mutually
# run-netns-pathswitch-rehandshake.sh: proves the #36 INVERSION
# (anti-replay.34 Task 4) -- a Punch->Relay path re-target now
# legitimately draws a FRESH Noise ephemeral instead of preserving
# the in-flight one, and the session still converges because the
# responder rebuilds on the fresh, freshness-gated Init. Forks
# run-netns-relay.sh's RELAY-FORCED topology (A/B mutually
# unreachable except via R's blind relay) so every session is forced
# through punch->escalate->relay; asserts (1) A converges over the
# relay, (2) relay-forwarded=<N> N>0, (3) rekey_epoch_witness (with
# the opt-in YIP_WITNESS_UNWRAP_RELAY=1 envelope unwrap, filtered to
# A's own outbound traffic) reports exactly 1 distinct cleartext
# Init ephemeral across >=2 captured Init datagrams -- the rigorous,
# on-wire, non-vacuous proof that the retarget resent the SAME Init
# rather than cold-starting. Uses the release yipd and debug
# yip-rendezvous already built above in this job.
# relay with >=98% steady-state delivery, (2) relay-forwarded=<N>
# N>0, (3) rekey_epoch_witness (with the opt-in
# YIP_WITNESS_UNWRAP_RELAY=1 envelope unwrap, filtered to A's own
# outbound traffic) reports >=2 distinct cleartext Init ephemerals
# across >=2 captured Init datagrams -- the rigorous, on-wire,
# non-vacuous proof that the retarget drew a fresh ephemeral rather
# than preserving the punch attempt's. Uses the release yipd and
# debug yip-rendezvous already built above in this job.
#
# POLL DRIVER ONLY: the punch->relay escalation is driven by discovery
# + path-SM timers, whose convergence within the fixed capture window
# is flaky under the uring driver in CI (the escalation Init can miss
# the window, yielding only 1 captured ephemeral) -- the same
# discovery-timing sensitivity that already makes run-netns-discovery.sh
# and run-netns-cert-revocation.sh poll-only here. The retarget logic
# is driver-independent (verified on both drivers locally), so uring
# adds only flakiness with no extra coverage.
run: |
sudo bash bin/yipd/tests/run-netns-pathswitch-rehandshake.sh \
"$(pwd)/target/release/yipd" \
Expand All @@ -276,21 +289,53 @@ jobs:
exit 1
fi
if grep -q "\[FAIL\]" /tmp/pathswitch-poll.log; then
echo "::error::hardening.36 path-switch money test (poll) failed — A did not converge over the relay, or drew a fresh ephemeral across the retarget"
echo "::error::hardening.36 path-switch money test (poll) failed — A did not converge over the relay, or the retarget did not draw a fresh ephemeral"
exit 1
fi

- name: Install tshark
# run-netns-replay-hijack.sh (below) uses tshark to extract a
# captured [HandshakeInit] datagram's raw UDP payload for replay --
# unlike tcpdump (preinstalled on the runner image), tshark is not
# guaranteed present, so install it explicitly.
# DEBIAN_FRONTEND=noninteractive: tshark's postinst otherwise prompts
# (debconf) to ask whether non-superusers may capture packets; this
# job always invokes it via sudo anyway, so the prompt is moot.
run: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y tshark

- name: Run the anti-replay.34 endpoint-hijack/restart money test under sudo (poll driver)
# run-netns-replay-hijack.sh: proves the #34 freshness gate end-to-end
# -- a captured-and-replayed HandshakeInit from an off-path attacker
# (a fourth netns, S) cannot redirect B's learned endpoint for A
# (steady ping A->B stays <=1% loss across the replay), B's stderr
# shows the replay was actually refused by the freshness gate, and a
# genuine restart of A (fresh ephemeral + newer wall-clock ts) still
# recovers within a bounded ping window. Uses the release yipd and
# debug yip-rendezvous already built above in this job.
run: |
sudo bash bin/yipd/tests/run-netns-replay-hijack.sh \
"$(pwd)/target/release/yipd" \
"$(pwd)/target/debug/yip-rendezvous" | tee /tmp/replay-hijack-poll.log
if grep -q "^SKIP run-netns-replay-hijack" /tmp/replay-hijack-poll.log; then
echo "::error::anti-replay.34 endpoint-hijack/restart money test (poll) skipped — expected root + tcpdump/tshark in this job"
exit 1
fi
if grep -q "\[FAIL\]" /tmp/replay-hijack-poll.log; then
echo "::error::anti-replay.34 endpoint-hijack/restart money test (poll) failed — the replay redirected B's endpoint for A, was not refused by the freshness gate, or A's restart did not recover"
exit 1
fi

- name: Run the hardening.36 path-switch convergence money test under sudo (uring driver)
- name: Run the anti-replay.34 endpoint-hijack/restart money test under sudo (uring driver)
run: |
sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-pathswitch-rehandshake.sh \
sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-replay-hijack.sh \
"$(pwd)/target/release/yipd" \
"$(pwd)/target/debug/yip-rendezvous" | tee /tmp/pathswitch-uring.log
if grep -q "^SKIP run-netns-pathswitch-rehandshake" /tmp/pathswitch-uring.log; then
echo "::error::hardening.36 path-switch money test (uring) skipped — expected root + tcpdump + the built witness tool in this job"
"$(pwd)/target/debug/yip-rendezvous" | tee /tmp/replay-hijack-uring.log
if grep -q "^SKIP run-netns-replay-hijack" /tmp/replay-hijack-uring.log; then
echo "::error::anti-replay.34 endpoint-hijack/restart money test (uring) skipped — expected root + tcpdump/tshark in this job"
exit 1
fi
if grep -q "\[FAIL\]" /tmp/pathswitch-uring.log; then
echo "::error::hardening.36 path-switch money test (uring) failed — A did not converge over the relay, or drew a fresh ephemeral across the retarget"
if grep -q "\[FAIL\]" /tmp/replay-hijack-uring.log; then
echo "::error::anti-replay.34 endpoint-hijack/restart money test (uring) failed — the replay redirected B's endpoint for A, was not refused by the freshness gate, or A's restart did not recover"
exit 1
fi

Expand Down
17 changes: 0 additions & 17 deletions bin/yipd/src/epoch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,6 @@ impl EpochSet {
age >= interval_ms.saturating_mul(2)
}
}

/// Responder guard: ignore a rekey Init that arrives against a very fresh
/// `current` (< interval/2 old) — bounds attacker-induced speculative
/// handshakes without full timestamp anti-replay (#34).
pub fn accept_rekey_init(&self, now_ms: u64, interval_ms: u64) -> bool {
now_ms.saturating_sub(self.current_created_ms) >= interval_ms / 2
}
}

#[cfg(test)]
Expand Down Expand Up @@ -513,14 +506,4 @@ mod tests {
assert!(!set.needs_rekey(1500, false, interval));
assert!(set.needs_rekey(2000, false, interval));
}

#[test]
fn accept_rekey_init_ignores_too_fresh_current() {
let (_peer, us) = epoch_pair();
let set = EpochSet::new(us, 0);
let interval = 1000u64;

assert!(!set.accept_rekey_init(400, interval));
assert!(set.accept_rekey_init(500, interval));
}
}
71 changes: 71 additions & 0 deletions bin/yipd/src/handshake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,44 @@ pub fn init_ephemeral(init_pkt: &[u8]) -> Option<[u8; 32]> {
init_pkt.get(1..33)?.try_into().ok()
}

/// TAI64N label length: 8-byte seconds + 4-byte nanoseconds.
pub const TAI64N_LEN: usize = 12;

/// The current wall clock as a TAI64N label: big-endian `2^62 + unix_secs`
/// (8 bytes) followed by big-endian nanoseconds (4 bytes). Big-endian so that
/// a lexicographic byte comparison of two labels is chronological. Wall-clock
/// based, so it survives a peer restart (a fresh Init is always newer in real
/// time) with no persisted state. A clock that jumps backwards yields a label
/// that a peer with a higher last-accepted label will reject — the WireGuard
/// behavior, accepted.
pub fn now_tai64n() -> [u8; TAI64N_LEN] {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs().wrapping_add(1u64 << 62);
let nanos = now.subsec_nanos();
let mut out = [0u8; TAI64N_LEN];
out[..8].copy_from_slice(&secs.to_be_bytes());
out[8..].copy_from_slice(&nanos.to_be_bytes());
out
}

/// Build the msg1 Noise payload: the anti-replay TAI64N label followed by the
/// (optional) membership cert. Empty `cert` (2a/2b) yields a 12-byte payload.
pub fn frame_init_payload(cert: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(TAI64N_LEN + cert.len());
out.extend_from_slice(&now_tai64n());
out.extend_from_slice(cert);
out
}

/// Split a received msg1 payload into `(ts_label, cert_remainder)`.
/// `None` (fail-closed) if it is shorter than the 12-byte label.
pub fn parse_init_payload(payload: &[u8]) -> Option<([u8; TAI64N_LEN], &[u8])> {
let ts: [u8; TAI64N_LEN] = payload.get(..TAI64N_LEN)?.try_into().ok()?;
Some((ts, &payload[TAI64N_LEN..]))
}

// ── step-functions (in-band handshakes) ────────────────────────────────────────

/// A handshake in progress, driven step-by-step instead of blocking on a
Expand Down Expand Up @@ -491,4 +529,37 @@ mod tests {
}
let _ = faker.join();
}

#[test]
fn tai64n_is_big_endian_monotonic_and_roundtrips() {
// frame/parse roundtrip: ts prefix split from the cert remainder.
let cert = b"a-cert-blob";
let framed = frame_init_payload(cert);
assert_eq!(framed.len(), TAI64N_LEN + cert.len());
let (ts, rest) = parse_init_payload(&framed).expect("parses");
assert_eq!(rest, cert);
assert_eq!(&framed[..TAI64N_LEN], &ts);

// empty cert (2a/2b): payload is exactly the 12-byte ts.
let framed_empty = frame_init_payload(&[]);
assert_eq!(framed_empty.len(), TAI64N_LEN);
let (_ts, rest) = parse_init_payload(&framed_empty).expect("parses");
assert!(rest.is_empty());

// big-endian so lexicographic byte-compare is chronological: a later
// wall-clock ts compares strictly greater than an earlier one.
let earlier = now_tai64n();
std::thread::sleep(std::time::Duration::from_millis(2));
let later = now_tai64n();
assert!(
later > earlier,
"TAI64N must increase with wall-clock and byte-compare"
);
}

#[test]
fn parse_init_payload_rejects_short_payload() {
assert!(parse_init_payload(&[0u8; TAI64N_LEN - 1]).is_none());
assert!(parse_init_payload(&[]).is_none());
}
}
Loading
Loading