From ff0aee3bd1b23f141f21edb167a6a9211583bd0c Mon Sep 17 00:00:00 2001 From: cjharriskc-ai Date: Tue, 4 Aug 2026 13:29:38 -0500 Subject: [PATCH] Make nearby pairing interface resilient --- Cargo.lock | 11 +++ Cargo.toml | 1 + README.md | 5 +- apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/pairing.rs | 122 ++++++++++++++++++++++---- docs/PAIRING.md | 10 ++- docs/RELEASE_EVIDENCE_0.3.1.md | 16 +++- docs/RELEASE_NOTES_0.3.1.md | 9 ++ 8 files changed, 150 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d63fe70..2a5555b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2745,6 +2745,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "image" version = "0.25.10" @@ -4739,6 +4749,7 @@ dependencies = [ "ed25519-dalek 2.2.0", "hex", "hkdf", + "if-addrs", "rand 0.10.2", "reqwest 0.12.28", "serde", diff --git a/Cargo.toml b/Cargo.toml index b778ce3..bbbdeaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ clap = { version = "4.5", features = ["derive"] } ed25519-dalek = { version = "2.2", features = ["rand_core", "serde"] } hex = "0.4" hkdf = "0.12" +if-addrs = "0.15.0" iroh = { version = "=1.0.3", default-features = false, features = ["tls-ring"] } iroh-relay = { version = "=1.0.3", default-features = false, features = ["server", "tls-ring"] } rand = "0.10" diff --git a/README.md b/README.md index f19ed28..10f785c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,9 @@ Rampage 0.3.1 is the current Windows x64 recovery and automatic-role release. It Remote Assist and fabric-proof foundation, then closes the complete device lifecycle: repair in place, return a worker to pairing, revoke a stale enrolled identity from the owner, or factory-reset the local Rampage runtime without uninstalling external model stores. A failed native status bridge -now produces an actionable recovery state instead of an infinite loading surface. +now produces an actionable recovery state instead of an infinite loading surface. Nearby pairing +fans out over every active LAN interface and uses local bounded lifetimes, so a VPN adapter or a +wrong device clock cannot silently strand a laptop on “Looking for your main PC.” | Proof surface | Validated result | | --- | --- | @@ -82,6 +84,7 @@ now produces an actionable recovery state instead of an infinite loading surface | Universal capability contract | Signed offers advertise exact workload domain, adapter, operation, execution pattern, isolation, runtime digest, and qualification status; candidate profiles grant no authority | | Autonomous self-scan | Stable evidence digests cover routes, links, failures, denials, thermal/battery pressure, capability gaps, idle capacity, and protected-artifact replication | | Compute Strategy | Outcome-first Automatic, Biggest AI, Fastest AI, More Work, and Protect This PC placement previews with exact capacity and qualification blockers | +| Nearby pairing | Zero-copy X25519 pairing over multicast, global broadcast, and every active directed LAN broadcast; owner-local expiry removes cross-device clock dependence | | Lifecycle recovery | One-screen Fix Rampage, Pair again, enrolled-device Forget, redacted receipt, and typed local factory reset; restart replay keeps revoked nodes and offers gone | | Remote Assist | Worker opt-in; paired-owner view/control; dedicated authenticated QUIC protocol; request-, node-, controller-, epoch-, size-, frame-rate-, and input-sequence bounds; visible active state; STOP/revoke | | Packaged product | Native Tauri shell, role-aware system tray, close-to-tray, start-at-login, governed sidecars, cold-start tolerance, controller-restart recovery, clean explicit shutdown, installer, and automatic desktop launcher | diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 4c4e426..c036bf1 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] } aes-gcm.workspace = true base64.workspace = true hkdf.workspace = true +if-addrs.workspace = true serde = { version = "1", features = ["derive"] } serde_json = "1" sha2.workspace = true diff --git a/apps/desktop/src-tauri/src/pairing.rs b/apps/desktop/src-tauri/src/pairing.rs index e6a35ab..c68ed78 100644 --- a/apps/desktop/src-tauri/src/pairing.rs +++ b/apps/desktop/src-tauri/src/pairing.rs @@ -4,6 +4,7 @@ use aes_gcm::{ }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use hkdf::Hkdf; +use if_addrs::{IfAddr, Ifv4Addr}; use rand::{TryRng as _, rngs::SysRng}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -22,7 +23,6 @@ const PAIRING_PORT: u16 = 47_839; const PAIRING_MULTICAST: Ipv4Addr = Ipv4Addr::new(239, 255, 73, 82); const PAIRING_WINDOW_MS: u64 = 3 * 60 * 1_000; const WORKER_WAIT_MS: u64 = 5 * 60 * 1_000; -const MAX_CLOCK_SKEW_MS: u64 = 30_000; const MAX_DATAGRAM_BYTES: usize = 8 * 1_024; const MAX_INVITATION_BYTES: usize = 5 * 1_024; const MAX_PENDING_REQUESTS: usize = 16; @@ -410,8 +410,8 @@ async fn owner_receive_loop(manager: PairingManager, socket: Arc) { device_name, device_kind, ephemeral_public_key, - issued_at_ms, - expires_at_ms, + issued_at_ms: _, + expires_at_ms: _, } = message else { continue; @@ -420,9 +420,6 @@ async fn owner_receive_loop(manager: PairingManager, socket: Arc) { || !valid_request_id(&request_id) || bounded_label(&device_name, "device name").is_err() || device_kind != "desktop" - || issued_at_ms > now.saturating_add(MAX_CLOCK_SKEW_MS) - || expires_at_ms <= now - || expires_at_ms > now.saturating_add(WORKER_WAIT_MS + MAX_CLOCK_SKEW_MS) { continue; } @@ -467,7 +464,12 @@ async fn owner_receive_loop(manager: PairingManager, socket: Arc) { else { continue; }; - let effective_expiry = expires_at_ms.min(inner.owner_open_until_ms); + // Remote wall clocks are not a trust boundary. Keep the request bounded by the + // owner's local pairing window so clock drift cannot silently break discovery or + // let a peer extend enrollment availability. + let effective_expiry = now + .saturating_add(WORKER_WAIT_MS) + .min(inner.owner_open_until_ms); let challenge = PairingDatagram::Challenge { schema: PAIRING_SCHEMA.into(), request_id: request_id.clone(), @@ -523,10 +525,7 @@ async fn worker_pairing_loop( expires_at_ms, }) .map_err(|error| error.to_string())?; - let destinations = [ - SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::BROADCAST, PAIRING_PORT)), - SocketAddr::V4(SocketAddrV4::new(PAIRING_MULTICAST, PAIRING_PORT)), - ]; + let destinations = pairing_destinations(); let mut interval = tokio::time::interval(Duration::from_millis(750)); let mut buffer = vec![0_u8; MAX_DATAGRAM_BYTES + 1]; let mut selected_owner: Option<(SocketAddr, [u8; 32], Vec, String)> = None; @@ -542,7 +541,7 @@ async fn worker_pairing_loop( } tokio::select! { _ = interval.tick() => { - for destination in destinations { + for destination in &destinations { let _ = socket.send_to(&hello, destination).await; } } @@ -551,8 +550,8 @@ async fn worker_pairing_loop( if length == 0 || length > MAX_DATAGRAM_BYTES { continue; } let Ok(message) = serde_json::from_slice::(&buffer[..length]) else { continue; }; match message { - PairingDatagram::Challenge { schema, request_id: response_id, owner_name, ephemeral_public_key, expires_at_ms: challenge_expiry } - if schema == PAIRING_SCHEMA && response_id == request_id && challenge_expiry > now_ms() => { + PairingDatagram::Challenge { schema, request_id: response_id, owner_name, ephemeral_public_key, expires_at_ms: _ } + if schema == PAIRING_SCHEMA && response_id == request_id => { let Ok(owner_public) = decode_32(&ephemeral_public_key) else { continue; }; if let Some((selected_addr, _, _, _)) = &selected_owner && (*selected_addr != source) @@ -568,7 +567,9 @@ async fn worker_pairing_loop( request_id: request_id.clone(), owner_name, verification_code, - expires_at_ms: challenge_expiry, + // Present a countdown based on this device's monotonic pairing + // lifetime rather than assuming both Windows clocks agree. + expires_at_ms, })?; } PairingDatagram::Approval { schema, request_id: response_id, nonce, ciphertext } @@ -743,7 +744,18 @@ fn bind_owner_socket() -> Result, String> { socket .set_broadcast(true) .map_err(|error| error.to_string())?; - let _ = socket.join_multicast_v4(&PAIRING_MULTICAST, &Ipv4Addr::UNSPECIFIED); + let mut joined = false; + for interface in active_ipv4_interfaces() { + if socket + .join_multicast_v4(&PAIRING_MULTICAST, &interface.ip) + .is_ok() + { + joined = true; + } + } + if !joined { + let _ = socket.join_multicast_v4(&PAIRING_MULTICAST, &Ipv4Addr::UNSPECIFIED); + } UdpSocket::from_std(socket) .map(Arc::new) .map_err(|error| error.to_string()) @@ -761,6 +773,45 @@ fn bind_worker_socket() -> Result { UdpSocket::from_std(socket).map_err(|error| error.to_string()) } +fn active_ipv4_interfaces() -> Vec { + let mut interfaces = if_addrs::get_if_addrs() + .unwrap_or_default() + .into_iter() + .filter(|interface| { + interface.is_oper_up() + && !interface.is_loopback() + && !interface.is_p2p() + && !interface.is_link_local() + }) + .filter_map(|interface| match interface.addr { + IfAddr::V4(address) if !address.ip.is_unspecified() => Some(address), + _ => None, + }) + .collect::>(); + interfaces.sort_by_key(|interface| interface.ip); + interfaces.dedup_by_key(|interface| interface.ip); + interfaces +} + +fn pairing_destinations() -> Vec { + pairing_destinations_for(active_ipv4_interfaces()) +} + +fn pairing_destinations_for(interfaces: impl IntoIterator) -> Vec { + let mut addresses = vec![Ipv4Addr::BROADCAST, PAIRING_MULTICAST]; + addresses.extend( + interfaces + .into_iter() + .filter_map(|interface| interface.broadcast), + ); + addresses.sort_unstable(); + addresses.dedup(); + addresses + .into_iter() + .map(|address| SocketAddr::V4(SocketAddrV4::new(address, PAIRING_PORT))) + .collect() +} + fn prune_owner_state(inner: &mut PairingInner, now: u64) { inner .pending @@ -995,8 +1046,10 @@ mod tests { device_name: "Studio Laptop".into(), device_kind: "desktop".into(), ephemeral_public_key: BASE64.encode(worker_public), - issued_at_ms: now_ms(), - expires_at_ms: now_ms() + 60_000, + // Pairing must remain available even when the laptop's wall clock is wrong. The + // owner bounds the request with its own three-minute enrollment window. + issued_at_ms: u64::MAX, + expires_at_ms: 0, }) .unwrap(); worker_socket.send_to(&hello, owner_addr).await.unwrap(); @@ -1072,6 +1125,39 @@ mod tests { assert!(bounded_label(&"x".repeat(65), "device").is_err()); } + #[test] + fn discovery_targets_every_active_lan_broadcast_once() { + let destinations = pairing_destinations_for([ + Ifv4Addr { + ip: "192.168.86.32".parse().unwrap(), + netmask: "255.255.255.0".parse().unwrap(), + prefixlen: 24, + broadcast: Some("192.168.86.255".parse().unwrap()), + }, + Ifv4Addr { + ip: "192.168.86.44".parse().unwrap(), + netmask: "255.255.255.0".parse().unwrap(), + prefixlen: 24, + broadcast: Some("192.168.86.255".parse().unwrap()), + }, + Ifv4Addr { + ip: "10.42.0.8".parse().unwrap(), + netmask: "255.255.0.0".parse().unwrap(), + prefixlen: 16, + broadcast: Some("10.42.255.255".parse().unwrap()), + }, + ]); + assert_eq!( + destinations, + vec![ + "10.42.255.255:47839".parse().unwrap(), + "192.168.86.255:47839".parse().unwrap(), + "239.255.73.82:47839".parse().unwrap(), + "255.255.255.255:47839".parse().unwrap(), + ] + ); + } + #[test] fn new_request_rate_limit_is_per_source() { let source: IpAddr = "192.168.1.20".parse().unwrap(); diff --git a/docs/PAIRING.md b/docs/PAIRING.md index 6c60466..f3d2ac0 100644 --- a/docs/PAIRING.md +++ b/docs/PAIRING.md @@ -33,6 +33,12 @@ transport identity anchor after the ten-minute discovery record expires; it gran authority. The long invitation never appears in the normal UI and is never sent in discovery broadcasts. +Rampage sends discovery through multicast, global broadcast, and the directed broadcast address of +every active non-loopback LAN interface. A VPN or virtual adapter therefore cannot win one routing +decision and hide the real Wi-Fi or Ethernet path. Each machine uses its own bounded local pairing +lifetime; remote wall-clock timestamps are advisory and cannot extend the owner's three-minute +window, so clock drift does not silently reject an otherwise valid nearby machine. + The controller also persists its authenticated UDP port. An upgrade migrates the newest proven legacy port from the evidence ledger before selecting the fixed port used by a new installation, so an enrolled laptop does not lose its signed route merely because the main app restarts. @@ -44,8 +50,8 @@ an enrolled laptop does not lose its signed route merely because the main app re ephemeral public key. - Rampage accepts at most five new requests per source address per minute and sixteen pending requests total. -- Datagram size, labels, clock skew, schemas, and unknown fields are validated before state is - created. +- Datagram size, labels, schemas, request identifiers, and public keys are validated before state + is created. Remote timestamps never control the owner-local expiry. - Repeated requests reuse the same challenge; repeated approvals resend the same encrypted payload so ordinary packet loss does not force the user to start over. - The controller and intelligence APIs remain token-protected and bound to loopback. diff --git a/docs/RELEASE_EVIDENCE_0.3.1.md b/docs/RELEASE_EVIDENCE_0.3.1.md index 299ad20..8059fd2 100644 --- a/docs/RELEASE_EVIDENCE_0.3.1.md +++ b/docs/RELEASE_EVIDENCE_0.3.1.md @@ -10,7 +10,7 @@ packages, public artifacts, and physical two-machine behavior are separate claim | Nine release versions | `scripts/Assert-RampageVersion.ps1 -Tag v0.3.1` | PASS — nine surfaces report 0.3.1 | | Rust workspace compile | `cargo check --workspace --all-targets` | PASS | | Controller lifecycle | `cargo test -p rampage-controller --bin rampage-controller` | PASS — 20 tests, including restart-safe node revocation | -| Native desktop recovery | `cargo test --workspace --no-fail-fast` | PASS — 19 desktop tests inside the full workspace campaign | +| Native desktop recovery and pairing | `cargo test --workspace`; `cargo test -p rampage-desktop` | PASS — 22 desktop tests, including clock-independent loopback enrollment and multi-interface directed-broadcast coverage | | Desktop UI and recovery | `pnpm --dir apps/desktop test -- --run` | PASS — 18 tests | | TypeScript SDK | `pnpm --dir packages/sdk-ts test -- --run` | PASS — 12 tests | | Python SDK | `uv run --project packages/sdk-python --with pytest --with httpx python -m pytest packages/sdk-python/tests -q` | PASS — 11 tests | @@ -18,7 +18,7 @@ packages, public artifacts, and physical two-machine behavior are separate claim | Desktop, edge, and TypeScript builds | `pnpm check` | PASS — desktop 18, edge 2, SDK 12 tests plus all production builds | | Proposal-only intelligence | Ruff, mypy, and pytest | PASS — Ruff clean, mypy clean across 9 files, 17 tests | | NSIS installer and desktop shortcut | `scripts/Smoke-RampageInstaller.ps1` | PASS — install 0, uninstall 0, six payloads, controller/intelligence ready, one signed node and offer, shortcut created then removed, no leaked sidecars | -| Public release assets | [`v0.3.1-recovery.2`](https://github.com/ObtuseAI/rampage/releases/tag/v0.3.1-recovery.2) | PASS — 12 uploaded assets, three source-bound manifests, three checksum files, and GitHub build-provenance attestation | +| Public release assets | [`v0.3.1-recovery.2`](https://github.com/ObtuseAI/rampage/releases/tag/v0.3.1-recovery.2) | PASS for candidate 2 — candidate 3 publication is pending the source merge and tag-bound rebuild | | Physical owner upgrade and recovery | Public candidate 2 on Windows | PASS — exact public hash, install exit 0, runtime preserved, desktop shortcut present, lifecycle consistent, non-destructive repair restart, controller ready, one resident agent, and one fresh signed offer | | Physical owner/laptop re-pair | Fresh 0.3.1 installs | PENDING physical laptop action | | Physical owner-to-laptop view | `scripts/Qualify-RampageRemoteAssist.ps1 -ExpectedVersion 0.3.1` | PENDING live opted-in worker | @@ -31,8 +31,8 @@ asset hashes will be recorded separately after publication. | Package | Bytes | SHA-256 | | --- | ---: | --- | -| `Rampage_0.3.1_x64_en-US.msi` | 79,290,368 | `a02b5995e082eb7be371f675ed80d5b0640a16499eb9de1b8e0f093ac7cd06ee` | -| `Rampage_0.3.1_x64-setup.exe` | 69,374,688 | `e2fffa0326e6a6e3322b294433911d94f47ef9bbd40e0647857ce52bc899a5a5` | +| `Rampage_0.3.1_x64_en-US.msi` | 79,314,944 | `9b35e4d9348787d504b2fc5eaa1b8e3dce81111b5ebeb2293a88da184b82e789` | +| `Rampage_0.3.1_x64-setup.exe` | 69,396,188 | `0056224623f9cd4bdb6d350d0859a103f0647d2e38d9b22257b1feae4a2aa7d8` | The source-current Recovery Center capture is `docs/assets/rampage-recovery-center.png`, SHA-256 @@ -62,6 +62,14 @@ an owner legitimately self-enrolls its own local agent. Candidate 2 accepts that enrollment marker matches the pinned endpoint and the pinned Ed25519 governor key matches this owner's local controller. Foreign, incomplete, and mismatched identities remain fail-closed. +Candidate 3 was required after the physical laptop remained on “Looking for your main PC” while the +owner listener and private firewall rule were healthy. The old worker sent only global broadcast and +one default-interface multicast packet, so Windows interface selection could hide the actual LAN. +The corrected worker also sends every active directed LAN broadcast, the owner joins multicast on +every active LAN address, and neither side trusts the other device's wall clock for expiry. The +owner's local three-minute window, laptop's local five-minute window, bounded requests, rate limits, +ephemeral X25519 transcript, matching four-digit code, and encrypted invitation remain intact. + ## Honest boundary The Recovery Center screenshot is a browser-rendered view of the real React component using labeled diff --git a/docs/RELEASE_NOTES_0.3.1.md b/docs/RELEASE_NOTES_0.3.1.md index 3610285..84543ad 100644 --- a/docs/RELEASE_NOTES_0.3.1.md +++ b/docs/RELEASE_NOTES_0.3.1.md @@ -8,6 +8,11 @@ Candidate 2 also recognizes an owner's cryptographically matching self-enrolled confusing it for a worker-role conflict. The fix was added after a physical in-place owner upgrade, and foreign-controller pins remain fail-closed. +Candidate 3 fixes the next physical-laptop discovery failure. Rampage now advertises on every active +LAN interface instead of trusting Windows to choose one path for global broadcast, and pairing +lifetimes are bounded independently on each device instead of requiring synchronized wall clocks. +The normal flow remains one **Find my fabric** action on the laptop and **Add machine** on the owner. + ## What changed - **Fix Rampage** safely restarts a consistent native installation without erasing identity or work. @@ -22,6 +27,10 @@ and foreign-controller pins remain fail-closed. - A redacted recovery receipt makes support diagnosis copyable without exposing controller secrets or private keys. - SDK lifecycle methods are available in Rust, TypeScript, and Python for loopback owner automation. +- Nearby discovery sends to multicast, global broadcast, and each active directed LAN broadcast, + while excluding loopback, link-local, and point-to-point tunnel interfaces. +- The owner derives request expiry from its own open window and the laptop displays its own bounded + countdown. Remote clocks cannot extend the window or silently prevent discovery. ## Simpler compute outcomes