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
11 changes: 11 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand All @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 104 additions & 18 deletions apps/desktop/src-tauri/src/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;
Expand Down Expand Up @@ -410,8 +410,8 @@ async fn owner_receive_loop(manager: PairingManager, socket: Arc<UdpSocket>) {
device_name,
device_kind,
ephemeral_public_key,
issued_at_ms,
expires_at_ms,
issued_at_ms: _,
expires_at_ms: _,
} = message
else {
continue;
Expand All @@ -420,9 +420,6 @@ async fn owner_receive_loop(manager: PairingManager, socket: Arc<UdpSocket>) {
|| !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;
}
Expand Down Expand Up @@ -467,7 +464,12 @@ async fn owner_receive_loop(manager: PairingManager, socket: Arc<UdpSocket>) {
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(),
Expand Down Expand Up @@ -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<u8>, String)> = None;
Expand All @@ -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;
}
}
Expand All @@ -551,8 +550,8 @@ async fn worker_pairing_loop(
if length == 0 || length > MAX_DATAGRAM_BYTES { continue; }
let Ok(message) = serde_json::from_slice::<PairingDatagram>(&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)
Expand All @@ -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 }
Expand Down Expand Up @@ -743,7 +744,18 @@ fn bind_owner_socket() -> Result<Arc<UdpSocket>, 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())
Expand All @@ -761,6 +773,45 @@ fn bind_worker_socket() -> Result<UdpSocket, String> {
UdpSocket::from_std(socket).map_err(|error| error.to_string())
}

fn active_ipv4_interfaces() -> Vec<Ifv4Addr> {
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::<Vec<_>>();
interfaces.sort_by_key(|interface| interface.ip);
interfaces.dedup_by_key(|interface| interface.ip);
interfaces
}

fn pairing_destinations() -> Vec<SocketAddr> {
pairing_destinations_for(active_ipv4_interfaces())
}

fn pairing_destinations_for(interfaces: impl IntoIterator<Item = Ifv4Addr>) -> Vec<SocketAddr> {
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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 8 additions & 2 deletions docs/PAIRING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
16 changes: 12 additions & 4 deletions docs/RELEASE_EVIDENCE_0.3.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ 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 |
| Full workspace tests and policy | `cargo test --workspace --no-fail-fast`; `cargo clippy --workspace --all-targets -- -D warnings`; `scripts/Assert-RustSecBaseline.ps1` | PASS — all tests; no clippy warnings; 0 RustSec vulnerabilities and 18 target-reviewed warnings through 2026-10-31 |
| 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 |
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading