Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
1349b47
docs(hardening): design spec — session-lifecycle hardening (#36 + #41)
vxfemboy Jul 22, 2026
3041629
docs(hardening): implementation plan — session-lifecycle hardening (#…
vxfemboy Jul 22, 2026
4e5bd25
fix(hardening.36): preserve the in-flight ephemeral across a path re-…
vxfemboy Jul 22, 2026
897a84a
docs(hardening.36): add responder-side relay adoption (close the head…
vxfemboy Jul 22, 2026
e245871
fix(hardening.36): responder-side relay adoption on a relayed cold-st…
vxfemboy Jul 22, 2026
209533c
fix(hardening.36): review follow-ups — relay-field doc, last_sent_ms …
vxfemboy Jul 22, 2026
dd37648
fix(hardening.41): re-verify cert on rekey Init, drop session on failure
vxfemboy Jul 22, 2026
32c3b24
fix(hardening.41): re-admission cert gate for tabled mesh peers (root…
vxfemboy Jul 22, 2026
fd0954e
fix(hardening.41): exempt roots from rekey cert re-verify (shared is_…
vxfemboy Jul 22, 2026
a09af66
fix(hardening.41): periodic member cert-liveness sweep (roots exempt,…
vxfemboy Jul 23, 2026
fd9237f
docs(hardening.41): sweep false-positive note — cold-start-admitted p…
vxfemboy Jul 23, 2026
261bd77
test(hardening): netns money tests — #36 path-switch convergence + #4…
vxfemboy Jul 23, 2026
22f75bc
docs(hardening): fix stale responder_cert_ok doc (used for initiator …
vxfemboy Jul 23, 2026
30ad334
fix(hardening.41): make the cert-revocation netns test fast + robust …
vxfemboy Jul 23, 2026
10e684a
Merge remote-tracking branch 'origin/main' into feat/session-lifecycl…
vxfemboy Jul 23, 2026
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
72 changes: 72 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,78 @@ jobs:
exit 1
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
# 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.
run: |
sudo bash bin/yipd/tests/run-netns-pathswitch-rehandshake.sh \
"$(pwd)/target/release/yipd" \
"$(pwd)/target/debug/yip-rendezvous" | tee /tmp/pathswitch-poll.log
if grep -q "^SKIP run-netns-pathswitch-rehandshake" /tmp/pathswitch-poll.log; then
echo "::error::hardening.36 path-switch money test (poll) skipped — expected root + tcpdump + the built witness tool in this job"
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"
exit 1
fi

- name: Run the hardening.36 path-switch convergence money test under sudo (uring driver)
run: |
sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-pathswitch-rehandshake.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"
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"
exit 1
fi

- name: Run the hardening.41 cert-revocation money test under sudo (poll driver)
# run-netns-cert-revocation.sh: proves the #41 fix -- a revoked
# (cert-expired) mesh member loses its session within a bounded
# window of expiry (not just at process restart) and cannot
# re-establish. Forks run-netns-discovery.sh's gossip-based mesh
# topology (required: the periodic cert-liveness sweep reads a
# peer's cert from the gossip-populated directory, not static
# config). The script sets YIP_CERT_SKEW_SECS low so the cert
# expires in seconds rather than waiting out membership.rs's 300s
# production grace, so the whole run is ~90s.
#
# POLL DRIVER ONLY: like its fork source run-netns-discovery.sh
# (also poll-only), gossip discovery convergence is flaky under the
# io_uring driver -- and #41's cert-revocation logic (the sweep /
# re-admission gate) is driver-agnostic, exercised identically by
# the poll run. The #36 money test above still covers both drivers.
# Uses the release yipd and debug yip-ca/yip-rendezvous already
# built above in this job.
run: |
sudo bash bin/yipd/tests/run-netns-cert-revocation.sh \
"$(pwd)/target/release/yipd" \
"$(pwd)/target/debug/yip-ca" \
"$(pwd)/target/debug/yip-rendezvous" | tee /tmp/cert-revocation-poll.log
if grep -q "^SKIP run-netns-cert-revocation" /tmp/cert-revocation-poll.log; then
echo "::error::hardening.41 cert-revocation money test (poll) skipped — expected root in this job"
exit 1
fi
if grep -q "\[FAIL\]" /tmp/cert-revocation-poll.log; then
echo "::error::hardening.41 cert-revocation money test (poll) failed — a revoked peer's session was not dropped, or it was allowed to re-establish"
exit 1
fi

dpi-undetectability:
# The anti-DPI undetectability merge gate (3a Task 7): fails the build if
# a wire/obfuscation change reintroduces a DPI-recognizable fingerprint.
Expand Down
32 changes: 23 additions & 9 deletions bin/yip-ca/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,14 @@ fn usage() -> String {
"usage: yip-ca <genkey|sign-cert|sign-roots> [args]\n\
\n\
yip-ca genkey\n\
yip-ca sign-cert --member <hex32> --member-sign <hex32> --network <hex16> --days <N> [--ca-private <hex>]\n\
yip-ca sign-cert --member <hex32> --member-sign <hex32> --network <hex16> (--days <N> | --secs <N>) [--ca-private <hex>]\n\
yip-ca sign-roots --roots <file> --version <N> [--ca-private <hex>]\n\
\n\
Exactly one of --days/--secs sets the cert's validity window: --days N\n\
is N*86400 seconds, --secs N is exactly N seconds (overrides --days if\n\
both are given). --secs is meant for minting short-lived certs in\n\
revocation tests.\n\
\n\
If --ca-private is omitted, the CA private key hex is read from stdin."
.to_string()
}
Expand All @@ -67,19 +72,28 @@ fn cmd_sign_cert(args: &[String]) -> Result<(), String> {
let member_pubkey = fixed32(&hex_decode(require(&flags, "member")?)?, "member")?;
let member_sign_pubkey = fixed32(&hex_decode(require(&flags, "member-sign")?)?, "member-sign")?;
let network_id = fixed16(&hex_decode(require(&flags, "network")?)?, "network")?;
let days_str = require(&flags, "days")?;
let days: u64 = days_str
.parse()
.map_err(|e| format!("bad --days {days_str:?}: {e}"))?;
let ca_key = load_ca_private(flags.get("ca-private").map(String::as_str))?;

let not_before = now_secs()?;
let validity = days
.checked_mul(SECS_PER_DAY)
.ok_or_else(|| "--days too large: overflow computing validity window".to_string())?;
// `--secs`, when present, overrides `--days`: an exact-seconds validity
// window instead of a whole-days one. Used to mint short-lived certs for
// revocation tests. Exactly one of the two must be given.
let validity = match (flags.get("secs"), flags.get("days")) {
(Some(secs_str), _) => secs_str
.parse::<u64>()
.map_err(|e| format!("bad --secs {secs_str:?}: {e}"))?,
(None, Some(days_str)) => {
let days: u64 = days_str
.parse()
.map_err(|e| format!("bad --days {days_str:?}: {e}"))?;
days.checked_mul(SECS_PER_DAY)
.ok_or_else(|| "--days too large: overflow computing validity window".to_string())?
}
(None, None) => return Err("missing required --days (or --secs)".to_string()),
};
let not_after = not_before
.checked_add(validity)
.ok_or_else(|| "--days too large: overflow computing not_after".to_string())?;
.ok_or_else(|| "validity window too large: overflow computing not_after".to_string())?;

let mut cert = Cert {
version: 1,
Expand Down
44 changes: 44 additions & 0 deletions bin/yip-ca/tests/roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,50 @@ fn cert_issued_by_yip_ca_verifies_in_yip_membership() {
assert!(verify_cert(&cert, &[other_pub], &network_id, &member_pubkey, now, 0).is_err());
}

#[test]
fn sign_cert_secs_flag_overrides_days_with_exact_seconds_validity() {
let key = genkey();

let member_hex = "66".repeat(32);
let member_sign_hex = "77".repeat(32);
let network_hex = "88".repeat(16);

let cert_out = run(&[
"sign-cert",
"--member",
&member_hex,
"--member-sign",
&member_sign_hex,
"--network",
&network_hex,
// --days is still required by the parser but must be ignored once
// --secs is present -- pick an outlandish days value to prove that.
"--days",
"365",
"--secs",
"5",
"--ca-private",
&key.ca_private,
]);
let cert_bytes = hex_decode(cert_out.trim());
let cert = Cert::decode(&cert_bytes).expect("emitted cert decodes");

assert_eq!(
cert.not_after - cert.not_before,
5,
"--secs 5 must produce an exact 5-second validity window, not 365 days"
);

let member_pubkey: [u8; 32] = hex_decode(&member_hex).try_into().unwrap();
let network_id: [u8; 16] = hex_decode(&network_hex).try_into().unwrap();
let ca_pub: [u8; 32] = hex_decode(&key.ca_public).try_into().unwrap();
let now = now_secs();
assert_eq!(
verify_cert(&cert, &[ca_pub], &network_id, &member_pubkey, now, 0),
Ok(())
);
}

#[test]
fn rootset_issued_by_yip_ca_verifies_in_yip_membership() {
let key = genkey();
Expand Down
130 changes: 126 additions & 4 deletions bin/yipd/src/membership.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,26 @@ use yip_membership::{node_addr, node_id, Cert, GossipMsg, NodeId, Record, RootSe
/// Cert validity is widened by this many WALL-CLOCK seconds on both ends to
/// tolerate clock skew between nodes (matches the `yip-ca`/`yip-membership`
/// convention of an explicit, small, documented skew rather than an
/// unbounded one).
/// unbounded one). Production default; see [`clock_skew_secs`] for the
/// test-only env override.
const CLOCK_SKEW_SECS: u64 = 300;

/// The cert-validity clock-skew widening (seconds), read once from
/// `YIP_CERT_SKEW_SECS` (default [`CLOCK_SKEW_SECS`] = 300). Overridable only
/// so netns tests can make a cert expire in seconds instead of waiting out the
/// 5-minute production grace; production leaves the var unset. Cached, so the
/// value is stable for the process's lifetime (a mid-run change cannot shrink
/// an already-honored validity window).
fn clock_skew_secs() -> u64 {
static SKEW: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
*SKEW.get_or_init(|| {
std::env::var("YIP_CERT_SKEW_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(CLOCK_SKEW_SECS)
})
}

/// Minimum spacing, in MONOTONIC milliseconds, between digests emitted by
/// `tick_digest` — pure gossip-chattiness control, unrelated to cert clocks.
const GOSSIP_INTERVAL_MS: u64 = 5_000;
Expand Down Expand Up @@ -167,7 +184,7 @@ impl Membership {
&self.network_id,
static_key,
now,
CLOCK_SKEW_SECS,
clock_skew_secs(),
)
.is_ok()
}
Expand Down Expand Up @@ -200,7 +217,7 @@ impl Membership {
let mut changed = self.sweep_expired(now);

if rec
.verify(&self.ca_pubkeys, &self.network_id, now, CLOCK_SKEW_SECS)
.verify(&self.ca_pubkeys, &self.network_id, now, clock_skew_secs())
.is_ok()
{
changed |= self.insert_if_newer(rec);
Expand Down Expand Up @@ -290,6 +307,22 @@ impl Membership {
&self.roots.roots
}

/// Whether `pubkey` is still an admissible member at wall-clock `now`:
/// `true` if it is an always-admit root, OR the directory holds a valid
/// (unexpired, verifying) cert for it. `false` only when a non-root member's
/// record was evicted (expired) or its cert no longer verifies — i.e.
/// revoked-by-non-renewal. Folding the root check in here keeps roots exempt
/// from the #41 liveness sweep (they have no directory-cert dependency).
pub fn member_cert_valid(&self, pubkey: &[u8; 32], now: u64) -> bool {
if self.roots.roots.iter().any(|(pk, _)| pk == pubkey) {
return true;
}
match self.directory.get(&node_id(pubkey)) {
Some(rec) => self.verify_cert(&rec.cert, pubkey, now),
None => false,
}
}

// ── internal helpers ───────────────────────────────────────────────

/// Unconditionally (re-)insert `rec` into both indices.
Expand Down Expand Up @@ -341,7 +374,7 @@ impl Membership {
&self.network_id,
&rec.cert.member_pubkey,
now,
CLOCK_SKEW_SECS,
clock_skew_secs(),
)
.is_err()
})
Expand Down Expand Up @@ -713,6 +746,95 @@ mod tests {
assert_eq!(m.own_cert_bytes(), expected);
}

// ── #41(b): `member_cert_valid` ─────────────────────────────────────────

/// Build a `Membership` with: a live directory record (`live_pubkey`), a
/// root (`root_pubkey`, in the `RootSet`, no directory dependency), and
/// an expired-cert member (`expired_pubkey`) — inserted while its cert
/// was still valid (window `[100, 200)`, at `ingest` time `now=150`) so
/// `ingest_record` accepts it, but never re-swept, so it is still present
/// in the directory (holding its now-expired cert) at the returned `now`.
/// Returns `(membership, live_pubkey, root_pubkey, expired_pubkey, now)`.
fn membership_with_live_root_and_expired() -> (Membership, [u8; 32], [u8; 32], [u8; 32], u64) {
let ca = ca_key(1);
let net = [7u8; 16];

let root_pubkey = [200u8; 32];
let roots = RootSet {
roots: vec![(root_pubkey, "10.0.0.99:51820".parse().unwrap())],
version: 0,
ca_sig: [0u8; 64],
};

let own_member_pk = [10u8; 32];
let own_sign_key = SigningKey::from_bytes(&[11u8; 32]);
let own_sign_pub = own_sign_key.verifying_key().to_bytes();
let own_cert = make_cert(&ca, own_member_pk, own_sign_pub, net, 0, 1_000_000);
let ca_pub = ca.verifying_key().to_bytes();
let mut m = Membership::new(
vec![ca_pub],
net,
own_cert,
own_sign_key.to_bytes(),
roots,
vec!["10.0.0.1:51820".parse().unwrap()],
);

// A live member: valid essentially forever.
let live_pubkey = [20u8; 32];
let live_sign_key = SigningKey::from_bytes(&[21u8; 32]);
let live_sign_pub = live_sign_key.verifying_key().to_bytes();
let live_cert = make_cert(&ca, live_pubkey, live_sign_pub, net, 0, 1_000_000);
let live_rec = build_signed_record(
live_cert,
vec!["192.0.2.1:1111".parse().unwrap()],
1,
&live_sign_key.to_bytes(),
);
assert!(m.ingest_record(live_rec, 500));

// An expired member: window [100, 200) — insert while valid (now=150).
let expired_pubkey = [30u8; 32];
let expired_sign_key = SigningKey::from_bytes(&[31u8; 32]);
let expired_sign_pub = expired_sign_key.verifying_key().to_bytes();
let expired_cert = make_cert(&ca, expired_pubkey, expired_sign_pub, net, 100, 200);
let expired_rec = build_signed_record(
expired_cert,
vec!["192.0.2.2:2222".parse().unwrap()],
1,
&expired_sign_key.to_bytes(),
);
assert!(m.ingest_record(expired_rec, 150));

// now: well past expired_cert's not_after(200) + CLOCK_SKEW_SECS(300)
// = 500, but well within live_cert's window (not_after 1_000_000).
let now = 900u64;
(m, live_pubkey, root_pubkey, expired_pubkey, now)
}

#[test]
fn member_cert_valid_tracks_directory_and_roots() {
let (m, live_pubkey, root_pubkey, expired_pubkey, now) =
membership_with_live_root_and_expired();
assert!(
m.member_cert_valid(&live_pubkey, now),
"a live directory record is valid"
);
assert!(
m.member_cert_valid(&root_pubkey, now),
"a root is always admissible (exempt)"
);
assert!(
!m.member_cert_valid(&expired_pubkey, now),
"an expired/absent member is invalid"
);
let never_seen = [0xAAu8; 32];
assert!(
!m.member_cert_valid(&never_seen, now),
"an unknown non-root member is invalid"
);
}

// (i) Fix-pass (Task 6): a `PullRequest` naming more `node_id`s than fit
// in one `MAX_GOSSIP_RECORDS_PER_REPLY` batch must not produce one
// unboundedly large `Records` reply — an amplification/CPU concern even
Expand Down
Loading
Loading