Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9c2ca3e
docs(auth-reachability): design spec — signed rendezvous registration…
vxfemboy Jul 23, 2026
0435fdf
docs(auth-reachability): implementation plans — M2 endpoint roaming +…
vxfemboy Jul 23, 2026
ba14e2f
feat(roaming.m2): endpoint follows an authenticated, non-replayed inb…
vxfemboy Jul 24, 2026
bd59df5
feat(roaming.m2): deobf_ingress trials session keys so a roamed peer'…
vxfemboy Jul 24, 2026
0950b88
feat(roaming.m2): egress follows the roam — update DataPlane peer_add…
vxfemboy Jul 24, 2026
93ae1b9
test(roaming.m2): netns — mid-session NAT rebind recovers on the obf …
vxfemboy Jul 24, 2026
be93908
fix(roaming.m2): redirect an in-flight rekey's target on roam (review…
vxfemboy Jul 24, 2026
0e5fd9d
feat(rdv.37): RegisterSigned message + optional PeerInfo.record (codec)
vxfemboy Jul 24, 2026
73c1ddc
feat(rdv.37): rooted server verifies RegisterSigned, drops unsigned; …
vxfemboy Jul 24, 2026
541dd96
feat(rdv.37): server --roots/--network-id enable mesh-mode signed reg…
vxfemboy Jul 24, 2026
ef1bcc8
feat(membership.37): sign_registration + verify_record accessors
vxfemboy Jul 24, 2026
ad047e9
feat(rdv.37): client sends RegisterSigned + verifies PeerInfo.record …
vxfemboy Jul 24, 2026
35ac0a2
fix(rdv.37): bind PeerInfo.record to the resolved node before probing…
vxfemboy Jul 24, 2026
aaa5b2b
test(rdv.37): netns — forged registration refused, victim stays reach…
vxfemboy Jul 24, 2026
841fb18
docs(auth-reachability): correct wire-compat + deobf-trial claims (fi…
vxfemboy Jul 25, 2026
85b8abe
Merge main (#34, #98, #44 flake-fixes) into authenticated-reachabilit…
vxfemboy Jul 25, 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
76 changes: 76 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,44 @@ jobs:
exit 1
fi

- name: Run the roaming.m2 NAT-rebind money test under sudo (poll driver)
# run-netns-roaming.sh: proves M2 authenticated endpoint roaming
# end-to-end. A direct, obfuscation-on peer A changes its on-wire
# source address mid-session (a NAT rebind: A binds 0.0.0.0 and the
# script adds a second address + flips the route's preferred source,
# so A's session/keys/conn_tag are untouched). B must relearn A's
# endpoint from an authenticated, non-replayed inbound packet AND
# redirect its egress there (endpoint alone is insufficient: egress is
# stamped from the epoch's peer_addr), so a steady ping A->B stays
# <=1% loss across the rebind. Uses the release yipd and debug
# yip-rendezvous already built above in this job.
run: |
sudo bash bin/yipd/tests/run-netns-roaming.sh \
"$(pwd)/target/release/yipd" \
"$(pwd)/target/debug/yip-rendezvous" | tee /tmp/roaming-poll.log
if grep -q "^SKIP run-netns-roaming" /tmp/roaming-poll.log; then
echo "::error::roaming.m2 NAT-rebind money test (poll) skipped — expected root + TUN in this job"
exit 1
fi
if grep -q "\[FAIL\]" /tmp/roaming-poll.log; then
echo "::error::roaming.m2 NAT-rebind money test (poll) failed — B did not recover A's roamed, obfuscated source across the mid-session rebind"
exit 1
fi

- name: Run the roaming.m2 NAT-rebind money test under sudo (uring driver)
run: |
sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-roaming.sh \
"$(pwd)/target/release/yipd" \
"$(pwd)/target/debug/yip-rendezvous" | tee /tmp/roaming-uring.log
if grep -q "^SKIP run-netns-roaming" /tmp/roaming-uring.log; then
echo "::error::roaming.m2 NAT-rebind money test (uring) skipped — expected root + TUN in this job"
exit 1
fi
if grep -q "\[FAIL\]" /tmp/roaming-uring.log; then
echo "::error::roaming.m2 NAT-rebind money test (uring) failed — B did not recover A's roamed, obfuscated source across the mid-session rebind"
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
Expand Down Expand Up @@ -371,6 +409,44 @@ jobs:
exit 1
fi

- name: Run the rdv.37 registration-hijack money test under sudo (poll driver)
# run-netns-registration-hijack.sh: proves the #37 fix -- a ROOTED
# (mesh-mode, --roots/--network-id) standalone yip-rendezvous server
# refuses a forged, unsigned `Register` claiming an established
# member's (A's) node_id from a third, uncertified netns (S), and
# B<->A stays reachable (<=1% loss) throughout the attack. Forks
# run-netns-discovery.sh's CA/cert/roots minting and
# run-netns-punch.sh's rendezvous-only `[peer]` config style; new in
# this milestone is combining full mesh-cert config with a rooted
# standalone rendezvous server (no gossip root at all).
#
# POLL DRIVER ONLY: the property under test (the forged Register's
# refusal) lives entirely in the rendezvous SERVER
# (crates/yip-rendezvous/src/server.rs's `handle`, the
# `roots_cfg.is_some()` drop) -- plain tokio, no yipd I/O driver
# involved, so `YIP_USE_URING` cannot change that assertion's
# outcome. The only uring-sensitive part of this script is A/B's
# signed-registration + Lookup establishment timing, which is the
# same discovery-convergence risk run-netns-discovery.sh /
# run-netns-cert-revocation.sh already document as flaky under
# io_uring -- so, like cert-revocation.sh, this runs poll-only rather
# than add a spurious-failure vector for zero additional coverage of
# the actual security property. Uses the release yipd and debug
# yip-ca/yip-rendezvous already built above in this job.
run: |
sudo bash bin/yipd/tests/run-netns-registration-hijack.sh \
"$(pwd)/target/release/yipd" \
"$(pwd)/target/debug/yip-ca" \
"$(pwd)/target/debug/yip-rendezvous" | tee /tmp/registration-hijack-poll.log
if grep -q "^SKIP run-netns-registration-hijack" /tmp/registration-hijack-poll.log; then
echo "::error::rdv.37 registration-hijack money test (poll) skipped — expected root in this job"
exit 1
fi
if grep -q "\[FAIL\]" /tmp/registration-hijack-poll.log; then
echo "::error::rdv.37 registration-hijack money test (poll) failed — the rooted server accepted a forged registration, or it disrupted B<->A connectivity"
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
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions bin/yip-rendezvous/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ path = "src/main.rs"

[dependencies]
yip-rendezvous = { path = "../../crates/yip-rendezvous" }
# rdv.37 Task 3: `--roots`/`--network-id` load a signed `RootSet` (same
# artifact `yipd` loads) to run this server in mesh mode — RootSet is not
# re-exported by the `yip-rendezvous` lib crate, so this binary needs the
# direct dependency.
yip-membership = { path = "../../crates/yip-membership" }
yip-obf = { path = "../../crates/yip-obf" }
yip-utls = { path = "../../crates/yip-utls" }
getrandom = "0.2"
Expand Down
199 changes: 195 additions & 4 deletions bin/yip-rendezvous/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use std::time::{Duration, Instant};
use tokio::sync::Mutex;

use tls_front::RealityCfg;
use yip_membership::RootSet;
use yip_rendezvous::{decode, encode, Message, RendezvousServer};

const SWEEP_INTERVAL: Duration = Duration::from_secs(5);
Expand Down Expand Up @@ -132,6 +133,69 @@ fn hex_nibble(b: u8) -> Result<u8, String> {
}
}

/// Decode a 32-char hex string into 16 bytes (`--network-id <hex32>`).
/// Mirrors `yipd::config::hex_to_16` — kept local to this binary for the same
/// reason as `hex_to_32`/`hex_to_8` above (separate crates).
fn hex_to_16(hex: &str) -> Result<[u8; 16], String> {
if hex.len() != 32 {
return Err(format!("expected 32 hex chars, got {}", hex.len()));
}
let mut out = [0u8; 16];
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
let hi = hex_nibble(chunk[0])?;
let lo = hex_nibble(chunk[1])?;
out[i] = (hi << 4) | lo;
}
Ok(out)
}

/// Decode an arbitrary-length hex string into bytes — the `--roots <path>`
/// file contents (a `RootSet::encode` blob is variable-length, unlike the
/// fixed-size 32/16/8-byte keys the other `hex_to_*` helpers handle). Mirrors
/// `yipd::config::hex_decode_vec`.
fn hex_decode_vec(hex: &str) -> Result<Vec<u8>, String> {
if !hex.len().is_multiple_of(2) {
return Err("odd-length hex string".to_string());
}
let mut out = Vec::with_capacity(hex.len() / 2);
for chunk in hex.as_bytes().chunks(2) {
let hi = hex_nibble(chunk[0])?;
let lo = hex_nibble(chunk[1])?;
out.push((hi << 4) | lo);
}
Ok(out)
}

/// Build the mesh-mode roots config (CA pubkeys + network id) from the raw
/// `--roots` file contents (hex-encoded `RootSet::encode`, one line — same
/// artifact `yipd` loads, see `bin/yipd/src/config.rs::load_roots_file`) and
/// the raw `--network-id` hex string.
///
/// Pure (no I/O) so it is unit-testable directly: decodes the root set,
/// derives the CA pubkeys from its own entries
/// (`roots.roots.iter().map(|(pk, _)| *pk)`), verifies the root set is
/// self-consistent (`RootSet::verify_rootset`, signed by one of its own CA
/// keys — mirrors `yipd`'s config-load verification), and parses the network
/// id. Any failure returns a `String` describing what's wrong; callers must
/// fail loudly (exit), never fall back to rootless.
fn roots_cfg_from(
roots_hex: &str,
network_id_hex: &str,
) -> Result<(Vec<[u8; 32]>, [u8; 16]), String> {
let bytes = hex_decode_vec(roots_hex.trim())?;
let roots = RootSet::decode(&bytes).ok_or_else(|| "failed to decode root set".to_string())?;
let ca_pubkeys: Vec<[u8; 32]> = roots.roots.iter().map(|(pk, _)| *pk).collect();
if !roots.verify_rootset(&ca_pubkeys) {
return Err(
"root set signature does not verify against its own CA pubkeys (self-consistency \
check failed)"
.to_string(),
);
}
let network_id = hex_to_16(network_id_hex).map_err(|e| format!("invalid --network-id: {e}"))?;
Ok((ca_pubkeys, network_id))
}

/// If `port` is a canonical VPN/tunnel default port that DPI port-matches,
/// return the protocol it makes the relay look like; `None` for a plausible
/// port. Mirrors `yipd::config::fingerprinted_vpn_port` — kept local to this
Expand Down Expand Up @@ -170,9 +234,12 @@ fn usage_exit() -> ! {
[--reality-dest <host:port> --reality-private-key <hex64> \
[--reality-short-id <hex16>]... [--reality-server-name <name>]... \
[--reality-cert-refresh-secs <secs>] [--reality-cert-max-stale-secs <secs>] \
[--reality-replay-max-bucket <n>] [--reality-max-inflight <n>]]]\n\
[--reality-replay-max-bucket <n>] [--reality-max-inflight <n>]]] \
[--roots <path> --network-id <hex32>]\n\
(--reality-dest supersedes --decoy when both are set; --tls-cert/--tls-key are \
optional when --reality-dest is set — REALITY forges its own per-SNI cert)\n\
optional when --reality-dest is set — REALITY forges its own per-SNI cert; \
--roots/--network-id are both-or-neither and switch the server into mesh mode, \
requiring every registration to carry a verified signed record)\n\
e.g. 0.0.0.0:51821"
);
std::process::exit(2);
Expand Down Expand Up @@ -206,6 +273,13 @@ async fn main() -> std::io::Result<()> {
let mut reality_cert_max_stale_secs: u64 = 21600;
let mut reality_replay_max_bucket: usize = 16384;
let mut reality_max_inflight: usize = 1024;
// Mesh mode (rdv.37 Task 3), both opt-in and both-or-neither: a signed
// `RootSet` file (`--roots`) plus the mesh network id (`--network-id`)
// switch the server from legacy rootless registration to
// `RendezvousServer::new_with_roots`, which requires every registration
// to carry a verified `RegisterSigned` record.
let mut roots_path: Option<String> = None;
let mut network_id_hex: Option<String> = None;

while let Some(arg) = args.next() {
match arg.as_str() {
Expand Down Expand Up @@ -334,6 +408,20 @@ async fn main() -> std::io::Result<()> {
std::process::exit(2);
});
}
"--roots" => {
let Some(v) = args.next() else {
eprintln!("--roots requires a path argument");
std::process::exit(2);
};
roots_path = Some(v);
}
"--network-id" => {
let Some(v) = args.next() else {
eprintln!("--network-id requires a 32-char hex argument");
std::process::exit(2);
};
network_id_hex = Some(v);
}
_ if listen.is_none() => listen = Some(arg),
other => {
eprintln!("unexpected argument: {other}");
Expand Down Expand Up @@ -433,10 +521,47 @@ async fn main() -> std::io::Result<()> {
);
}

// Mesh mode (rdv.37 Task 3): `--roots` and `--network-id` are
// both-or-neither — an operator passing only one clearly wants mesh and
// silently falling back to rootless would be a security downgrade, so
// that mismatch is rejected up front, before any network I/O.
let mesh_cfg: Option<(Vec<[u8; 32]>, [u8; 16])> = match (roots_path, network_id_hex) {
(Some(path), Some(net_hex)) => {
let contents = std::fs::read_to_string(&path).unwrap_or_else(|e| {
eprintln!("failed to read --roots file {path:?}: {e}");
std::process::exit(2);
});
let cfg = roots_cfg_from(&contents, &net_hex).unwrap_or_else(|e| {
eprintln!("fatal: invalid --roots/--network-id: {e}");
std::process::exit(1);
});
eprintln!(
"yip-rendezvous: mesh mode ({} root{})",
cfg.0.len(),
if cfg.0.len() == 1 { "" } else { "s" }
);
Some(cfg)
}
(None, None) => None,
(Some(_), None) => {
eprintln!("--roots requires --network-id (mesh mode needs both)");
std::process::exit(2);
}
(None, Some(_)) => {
eprintln!("--network-id requires --roots (mesh mode needs both)");
std::process::exit(2);
}
};

// Millisecond clock from a monotonic base (Instant), so `now_ms` never goes
// backwards and needs no wall clock.
let base = Instant::now();
let server = Arc::new(Mutex::new(RendezvousServer::new(0)));
let server = Arc::new(Mutex::new(match mesh_cfg {
None => RendezvousServer::new(0),
Some((ca_pubkeys, network_id)) => {
RendezvousServer::new_with_roots(0, ca_pubkeys, network_id)
}
}));

let sock = tokio::net::UdpSocket::bind(&listen).await?;
eprintln!("yip-rendezvous listening on {listen} (udp)");
Expand Down Expand Up @@ -505,6 +630,15 @@ async fn run_udp(
) -> std::io::Result<()> {
let now_ms =
|base: Instant| -> u64 { u64::try_from(base.elapsed().as_millis()).unwrap_or(u64::MAX) };
// WALL-CLOCK seconds, used ONLY by `handle`'s `RegisterSigned` verify
// path to check a cert's `not_before`/`not_after` window — distinct
// from `now_ms`'s monotonic `Instant`-based clock used for TTL/rate.
let now_secs = || -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
};
let mut rx = [0u8; 2048];
let mut sweep =
tokio::time::interval_at(tokio::time::Instant::now() + SWEEP_INTERVAL, SWEEP_INTERVAL);
Expand All @@ -518,7 +652,7 @@ async fn run_udp(
if let Some(msg) = decode_inbound(obf_key.as_ref(), &rx[..n]) {
let replies = {
let mut s = server.lock().await;
s.handle(src, msg, now_ms(base))
s.handle(src, msg, now_ms(base), now_secs())
};
for (dst, reply) in replies {
let wire = wrap_reply(obf_key.as_ref(), &reply);
Expand Down Expand Up @@ -596,6 +730,63 @@ mod cli_tests {
}
}

/// `--roots`/`--network-id` mesh-mode construction: covers `hex_to_16` and
/// the pure `roots_cfg_from` failure paths directly. The positive
/// (successfully-verifying, real CA-signed root set) path is exercised as a
/// real-binary smoke check instead — building a signed `RootSet` here would
/// need ed25519 signing dev-dependencies for a single assertion already
/// covered end-to-end by `crates/yip-rendezvous/src/server.rs`'s
/// `rooted_server_*` tests plus the `yip-ca`-generated smoke fixture (see
/// `task-3-report.md`).
#[cfg(test)]
mod roots_cfg_tests {
use super::{hex_to_16, roots_cfg_from};

/// A valid 32-char hex string decodes to the expected 16 bytes.
#[test]
fn hex_to_16_valid() {
assert_eq!(
hex_to_16("00112233445566778899aabbccddeeff"),
Ok([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff
])
);
}

/// Wrong-length input (too short / too long) is rejected.
#[test]
fn hex_to_16_wrong_length() {
assert!(hex_to_16("00112233445566778899aabbccddeef").is_err()); // 31 chars
assert!(hex_to_16("00112233445566778899aabbccddeeff00").is_err()); // 34 chars
}

/// Non-hex characters are rejected.
#[test]
fn hex_to_16_non_hex() {
assert!(hex_to_16("0011223344556677889zaabbccddeeff").is_err());
}

/// Garbage (non-hex) `--roots` file contents are rejected before any
/// `RootSet::decode` is attempted.
#[test]
fn roots_cfg_from_bad_hex_is_rejected() {
let err = roots_cfg_from("not hex at all", "00112233445566778899aabbccddeeff").unwrap_err();
assert!(!err.is_empty());
}

/// Well-formed hex that isn't a valid `RootSet` encoding is rejected.
/// (`roots_cfg_from` decodes+verifies the root set before ever touching
/// `--network-id`, so a malformed network-id string can't be reached
/// from here with an undecodable/unverified root set — that final step
/// is already covered directly by the `hex_to_16_*` tests above.)
#[test]
fn roots_cfg_from_undecodable_roots_is_rejected() {
let err = roots_cfg_from("deadbeef", "00112233445566778899aabbccddeeff").unwrap_err();
assert!(err.contains("decode"), "error names the failure: {err}");
}
}

#[cfg(test)]
mod obf_tests {
use super::{decode_inbound, wrap_reply};
Expand Down
Loading
Loading