diff --git a/AGENTS.md b/AGENTS.md index 79c967e..115bcbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,14 +11,14 @@ This file gives an AI coding assistant the minimum it needs to make a useful fir ## Commands ```bash -# format / lint / test / doc — match what hooks and CI run +# format / lint / test / doc / supply-chain — match what hooks and CI run just fmt # cargo fmt --all just lint # cargo clippy --workspace --all-targets --all-features -- -D warnings just test # cargo nextest run --workspace --all-features just doc # RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features -# full local CI pipeline -just ci +# format / lint / test / doc / supply-chain — match what hooks and CI run +just ci # fmt-check + lint + test + doc + deny + machete (see Justfile:41) # per-crate README regeneration (do this after editing any Cargo.toml description) cargo run -p xtask -- gen-readmes # write @@ -27,15 +27,17 @@ cargo run -p xtask -- gen-readmes --check # drift gate `just bootstrap` (run once per dev machine) installs `prek`, `cargo-nextest`, `typos-cli`, `cocogitto`, and `taplo-cli`, then arms the git hooks via `prek install`. +`just bootstrap-phase2` (only after the first real feature PR) installs the release stack: `git-cliff`, `release-plz`, `cargo-dist` (see Justfile:52). + ## Workspace layout See [`docs/architecture.md`](./docs/architecture.md) for the crate boundary map, the two-plane control/data split, and the request flow. The ten crates live under `crates/`: ## Strict regime — non-negotiable -- Clippy runs `pedantic` + `nursery` + `cargo` groups at `warn` plus a surgical restriction-deny list (no `panic`, `unwrap_used`, `expect_used`, `todo`, `unimplemented`, `unreachable`, `dbg_macro`, `print_stdout`, `print_stderr`, `mem_forget`, `unwrap_in_result`, `let_underscore_must_use` in non-test code). CI invokes `-D warnings`. +- Clippy runs `pedantic` + `nursery` + `cargo` groups at `warn` plus a surgical restriction-deny list (see full list + rust/rustdoc lints in [`Cargo.toml`](./Cargo.toml):174-201; tests exempt via [`clippy.toml`](./clippy.toml):16-20). CI invokes `-D warnings`. - **Cognitive complexity ≤ 15** per function. State machines that legitimately exceed it may carry `#[allow(clippy::cognitive_complexity)]` with a justification in the commit body. -- **Conventional Commits required.** `cog verify` runs at commit-msg time. See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for accepted types. +- **Conventional Commits required.** `cog verify` runs at commit-msg time. See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for accepted types, branch policy, review axes (Correctness > Hygiene > Footprint), and the 5-point dep rubric. - **Per-crate READMEs are generated.** Never hand-edit `crates/*/README.md`. Edit the `[package].description` and run `cargo run -p xtask -- gen-readmes`. ## Security context @@ -50,22 +52,31 @@ See [`docs/architecture.md`](./docs/architecture.md) for the crate boundary map, - Using `std::sync::Mutex` / `std::sync::RwLock` — `clippy.toml` disallows them in favor of `parking_lot` equivalents. - Using `chrono::DateTime` — `clippy.toml` disallows it in favor of `time::OffsetDateTime`. - Using `println!` / `eprintln!` / `dbg!` in non-test code — disallowed by the restriction lints. Use `tracing` macros. -- Re-implementing what already exists. The anonymity-set ≥30 floor IS enforced today (`crates/bibeam-node/src/coordinator/admission_gate.rs` — R-FLOOR per-region partition + `NoAnonymousPathAvailable` refusal per R-3 formalism). Genuinely Phase 2 / not-yet-implemented: release-plz, cargo-dist, dependabot, coordinator replication protocol (P2A-1 only recorded the decision). +- Re-implementing what already exists. The anonymity-set ≥30 floor (R-3) IS enforced today (`crates/bibeam-node/src/coordinator/admission_gate.rs` — R-FLOOR per-region partition + `NoAnonymousPathAvailable` refusal). Phase-2 release tooling (release-plz, cargo-dist, dependabot, git-cliff) now has committed configs + `just bootstrap-phase2`; publish/installers still gated. Full enablement is future work. +- Bypassing the heavy pre-commit surface (prek-managed, see [`.pre-commit-config.yaml`](./.pre-commit-config.yaml):35-71). The policy is deliberate: fail on fmt/taplo/typos/xtask/clippy/nextest/deny/machete/doc *before* you write a commit message that `cog verify` will then reject (rationale in [`CONTRIBUTING.md`](./CONTRIBUTING.md):29-35). - Hyphenated negative prefixes in comments (the three-letter form starting with `m`, hyphen, then a verb such as `paired` / `keyed` / `bind` / `classified` / `delivered`) trip the `typos` pre-commit hook because that three-letter token is read as a misspelling of `miss` / `mist`. Use `wrongly ` or rewrite the phrase. The hook also enforces `unparsable` over the alternate spelling with an `e`. - Adding a third-party dependency without checking the rubric in [`CONTRIBUTING.md`](./CONTRIBUTING.md) — active in the last 12 months, no RustSec advisory, latest release not yanked. +- Treating `docs/protocol.md` or `docs/plan/tasks.md` as current without knowing their post-D-4 state. `protocol.md` has been rewritten to reflect Phase A WireGuard corrections; `tasks.md` has partial post-D-4 updates. Canonical sources: [`docs/architecture.md`](./docs/architecture.md):54 (data plane) + [`docs/threat-model.md`](./docs/threat-model.md):122 (multi-hop + R-3) + [`docs/plan/init.md`](./docs/plan/init.md):13 (MSRV/prek/lefthook pivots). ## Picked design decisions (D-* + R-*) -Quick reference so you don't grep the plan-doc on every change. Full rationale lives in `docs/plan/init.md` §11; the docs themselves are the source of record for downstream readers. +Quick reference. Full grounding lives in [`docs/architecture.md`](./docs/architecture.md) (Operational decisions + two-plane crate map) + [`docs/threat-model.md`](./docs/threat-model.md):112 (R-1/R-3/D-6 sections) + [`docs/plan/init.md`](./docs/plan/init.md):13 (MSRV/prek/lefthook pivots + 0.2c corrections) + [`docs/plan/init.md`](./docs/plan/init.md):66 (original 17 decisions). The old "§11" pointers are stale post-correction; decisions were moved into architecture + threat-model. + +- **D-1** ECH (Encrypted ClientHello): **best-effort** when rustls supports it for BiBeam's own coordinator TLS (CLI/node → coord); user-app ECH is transparent end-to-end. Policy knob `ech = "best-effort" | "deferred"` (CLI default `Deferred`). (arch:67, threat:11, cli/config.rs:51) +- **D-3** Exit-mode forwarding: **L3 via `tun-rs` + kernel NAT44/66** (primary), **L4 via `fast-socks5`** (fallback when TUN unavailable). (arch:127, plan/tasks:114) +- **D-4** VPN protocol family: **WireGuard wire-compat via `boringtun`** end-to-end (clients see a stock WG peer). (arch:56,69, threat:122) +- **D-5** GeoIP region cross-check: **warn-only** at MVP (`AuditKind::RegionMismatch`); admission proceeds. Region = free-form operator `String` (R-2). (region_verify.rs:1, admission_gate.rs:131) +- **D-6** Multi-hop construction: **option (c)** — stateful UDP forwarder + *end-to-end* client↔exit WG (TURN-style). Intermediates see only addr pair + ciphertext. (arch:56, threat:122-166) +- **R-1** (coord-crate dissolution): single `bibeam-node` binary; `is_coordinator` flag mounts the axum+redb admission module. Coord + data keys in same process (operators should physically separate). (arch:52, threat:114) +- **R-2** Region is operator-tagged free-form `String` on records; coord cross-checks vs GeoIP (ISO-3166) + allowlist CIDRs; per-region partitioning prevents anon-set pollution. +- **R-3** Per-position anonymity floor is **topology-only** (≥30 at *every* hop independently; **no cross-hop union**). Under-floor → refuse (`NoAnonymousPathAvailable`, one fresh audit row per drain poll). Region-isolated. (admission_gate.rs:186-240,300, threat:160) +- **R-MULTIHOP-PROTO** packet-to-lease binding: **option (B)** — explicit `RelayFrame { chain_id, wg_payload }`. Coord **never** holds WG X25519 private keys (client + exit mint locally at registration, publish only pubkeys). (threat:164, multihop.rs:206) -- **D-1** ECH (Encrypted ClientHello): **deferred** at MVP — wired through F-TRANS.2's rustls config + F-CLI.7 `--ech-policy` flag; default `Deferred`. -- **D-3** Exit-mode forwarding: **L3 via `tun-rs` + kernel NAT44/66** (primary), **L4 via `fast-socks5`** (fallback when TUN unavailable). -- **D-4** VPN protocol family: **WireGuard wire-compat via `boringtun`** end-to-end (clients see a stock WG peer). -- **D-5** GeoIP region cross-check: **warn-only** at MVP. Mismatch emits `AuditKind::RegionMismatch`; admission proceeds. -- **D-6** Multi-hop construction: **option (c) — stateful UDP forwarder + end-to-end client↔exit WG** (TURN-style). Intermediates see address pair + ciphertext, never plaintext. Verified by the `forwarder_relays_opaque_payload_byte_preserving` integration test in `crates/bibeam-node/tests/multihop_e2e.rs`. -- **R-2** Region is operator-tagged free-form `String` on `PeerRecord`/`RelayRecord`/`ExitRecord`; coord cross-checks against GeoIP per D-5. (R-1 — coord-crate dissolution — is documented inline above in *Workspace layout*; not repeated here.) -- **R-3** Per-position anonymity floor is **topology-only**: every position p in a multi-hop path must independently satisfy `|position_cohort(C, p)| + 1 ≥ 30`. **No union across hops** (an earlier draft's union claim was rejected by review). Under-floor → refuse, don't auto-route. -- **R-MULTIHOP-PROTO** packet-to-lease binding: **option (B)** — explicit `RelayFrame { chain_id, wg_payload }` encapsulation. Coord NEVER holds WG private keys; client + exit each generate their own keypairs at registration and publish only public keys. +**Cross-cutting invariants (expensive to rediscover):** +- **Data / control plane split** (arch:7): control crates (`discovery`, crypto PASETO, `node/coordinator/`) never see WG private keys or inner traffic; data crates (`tun`, `transport/wg_*`, exit) never perform admission or issue tokens. Any change must respect the boundary. +- **Key custody + forwarder visibility** (D-6/R-4): intermediates see only outer address + WG ciphertext shape; never plaintext, transport keys, or identities. Coord is a pairing service only. +- **Anonymity floor mechanics** (R-3 + R-2): per-position + per-region + per-poll audit (no dedup, no continuous re-gate between rotations). Refuse rather than route. One audit row per poll for operator visibility. +- **Historical pivot record** (plan/init.md:13): MSRV pin removed (0.2c), lefthook → prek, "latest stable only, no dedicated MSRV CI job". The "why no pin" rationale lives only in the correction notes. ## Where to look first @@ -73,4 +84,8 @@ Quick reference so you don't grep the plan-doc on every change. Full rationale l - A new hook failure: [`.pre-commit-config.yaml`](./.pre-commit-config.yaml). - A CI failure that does not reproduce locally: the [GitHub workflow](./.github/workflows/ci.yml) runs three operating systems; macOS and Windows runners catch path and line-ending issues. - A "where does this fit?" question: [`docs/architecture.md`](./docs/architecture.md). -- A "why does the scaffold look like this?" question: [`docs/plan/init.md`](./docs/plan/init.md) — the spec that drove Phase 1. +- A "why does the scaffold look like this?" question: [`docs/plan/init.md`](./docs/plan/init.md) — the spec that drove Phase 1 (plus corrections at :13). +- Supply-chain policy or new third-party dep: [`deny.toml`](./deny.toml) (yanked=deny, "Revisit by YYYY-MM-DD" ignore convention, license allowlist, bans, graph.all-features) + [`CONTRIBUTING.md`](./CONTRIBUTING.md):58-68 (5-point rubric) + :70 (review axes). +- Release / changelog / dist automation: [`release-plz.toml`](./release-plz.toml), [`dist-workspace.toml`](./dist-workspace.toml), [`cliff.toml`](./cliff.toml) (parser order critical for `chore(release)`), [`cog.toml`](./cog.toml), [`.github/dependabot.yml`](./.github/dependabot.yml) (weekly groups). +- Formatting + cross-OS churn prevention: [`.taplo.toml`](./.taplo.toml) (reorder_keys/arrays=false to protect Cargo.lock), [`.editorconfig`](./.editorconfig) (2-space for toml/yml + md no-trim), [`rustfmt.toml`](./rustfmt.toml), [`.cargo/config.toml`](./.cargo/config.toml) (retry=3, git-fetch-with-cli). +- Hook surface + prek policy: [`.pre-commit-config.yaml`](./.pre-commit-config.yaml) (heavy pre-commit vs light pre-push, fail_fast=false, nextest --no-tests=warn in hook, xtask --release gen-readmes --check). diff --git a/Cargo.lock b/Cargo.lock index 05b2ec1..f177fc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aead" version = "0.5.2" @@ -174,18 +168,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-compression" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" -dependencies = [ - "compression-codecs", - "compression-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "async-task" version = "4.7.1" @@ -530,10 +512,8 @@ dependencies = [ name = "bibeam-runtime" version = "0.0.1" dependencies = [ - "anyhow", "axum", "bibeam-core", - "clap", "figment", "metrics", "metrics-exporter-prometheus", @@ -542,7 +522,6 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", - "tower-http", "tracing", "tracing-error", "tracing-subscriber", @@ -909,23 +888,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compression-codecs" -version = "0.4.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" -dependencies = [ - "compression-core", - "flate2", - "memchr", -] - -[[package]] -name = "compression-core" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1012,15 +974,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "critical-section" version = "1.2.0" @@ -1469,16 +1422,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "flume" version = "0.12.0" @@ -2537,16 +2480,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "1.2.0" @@ -3813,12 +3746,6 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "simd_cesu8" version = "1.1.1" @@ -4303,21 +4230,15 @@ version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ - "async-compression", "bitflags", "bytes", - "futures-core", "futures-util", "http", "http-body", - "http-body-util", "pin-project-lite", - "tokio", - "tokio-util", "tower", "tower-layer", "tower-service", - "tracing", "url", ] diff --git a/Cargo.toml b/Cargo.toml index 76c323f..4ced4a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/gosuda/BiBeam" homepage = "https://github.com/gosuda/BiBeam" authors = ["GoSuda BiBeam contributors"] readme = "README.md" -keywords = ["vpn", "p2p", "quic", "noise", "privacy"] +keywords = ["vpn", "p2p", "wireguard", "anonymity", "privacy"] categories = ["network-programming", "cryptography"] [workspace.dependencies] @@ -28,18 +28,15 @@ bibeam-runtime = { version = "0.0.1", path = "crates/bibeam-runtime" } tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "net", "sync", "time", "io-util", "signal", "process", "fs"] } tokio-util = { version = "0.7", features = ["codec", "io", "rt"] } tokio-stream = { version = "0.1", features = ["sync", "time"] } -futures = "0.3" futures-util = "0.3" # --- transport / crypto / proto --- boringtun = "0.7" quinn = { version = "0.11", default-features = false, features = ["rustls-ring", "runtime-tokio"] } -quinn-proto = "0.11" rustls = { version = "0.23", default-features = false, features = ["std", "ring"] } tokio-rustls = "0.26" rustls-pemfile = "2" webpki-roots = "1" -snow = { version = "0.10", features = ["ring-accelerated"] } x25519-dalek = { version = "2", features = ["serde", "static_secrets"] } ed25519-dalek = { version = "2", features = ["serde", "rand_core"] } chacha20poly1305 = "0.10" @@ -96,7 +93,6 @@ caps = "0.5" # --- serde / data --- serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_with = { version = "3", features = ["base64", "hex"] } postcard = { version = "1", features = ["alloc", "use-std"] } bytes = "1" toml = "1.1" @@ -167,6 +163,7 @@ must_use_candidate = "allow" similar_names = "allow" too_many_lines = "allow" # complexity caught by cognitive_complexity instead multiple_crate_versions = "allow" # transitive duplicates from rust-crypto + dalek ecosystems; not load-bearing for safety +redundant_pub_crate = "allow" # rustc `unreachable_pub` already enforces the intent (pub-in-private); clippy's variant is pure noise on top of it # cognitive complexity gate (threshold lives in clippy.toml) cognitive_complexity = "warn" diff --git a/README.md b/README.md index 1b10bf1..97914be 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ Seven libraries + two role-specific daemons + one ops runner. See [docs/architec |---|---| | [`bibeam-core`](./crates/bibeam-core) | Shared types, errors, identity primitives | | [`bibeam-protocol`](./crates/bibeam-protocol) | Wire frames + postcard codec | -| [`bibeam-crypto`](./crates/bibeam-crypto) | Noise IK, AEAD, PASETO, key management | -| [`bibeam-transport`](./crates/bibeam-transport) | QUIC + Noise datagram tunnel + STUN hole-punch | +| [`bibeam-crypto`](./crates/bibeam-crypto) | WG key generation, AEAD, PASETO session tokens | +| [`bibeam-transport`](./crates/bibeam-transport) | WireGuard (boringtun) data plane over UDP + STUN hole-punch | | [`bibeam-tun`](./crates/bibeam-tun) | Cross-platform TUN device + L3 packet pipeline | | [`bibeam-discovery`](./crates/bibeam-discovery) | Coordinator client + rendezvous types | | [`bibeam-runtime`](./crates/bibeam-runtime) | Tracing, metrics, config, signals, health | @@ -47,7 +47,7 @@ Per-crate `README.md` files are **generated** by `cargo run -p xtask -- gen-read ## Reading order 1. [`docs/architecture.md`](./docs/architecture.md) — two-plane diagram, crate boundaries, request flow. -2. [`docs/protocol.md`](./docs/protocol.md) — wire format, handshake, token claims, cohort lifecycle. +2. [`docs/protocol.md`](./docs/protocol.md) — WG wire format, session tokens, control-plane API, cohort lifecycle. 3. [`docs/threat-model.md`](./docs/threat-model.md) — adversaries, scope, mitigations. 4. [`docs/operator-runbook.md`](./docs/operator-runbook.md) — bringing up a coordinator or node. 5. [`CONTRIBUTING.md`](./CONTRIBUTING.md) — strict regime, dep-selection rubric, commit conventions. diff --git a/SECURITY.md b/SECURITY.md index a00ad7b..5342cc6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,7 +13,7 @@ BiBeam is **not** Tor and does not aim to be. Full enumeration lives in [`docs/t - A curious exit operator inspecting the traffic it forwards. - A curious coordinator operator inspecting metadata (registration, matchmaking). - Honest-but-curious peers in the shared exit pool. -- Replay, downgrade, and impersonation against the control plane (PASETO + Noise IK). +- Replay, downgrade, and impersonation against the control plane (PASETO + WireGuard handshake). **Out of scope (explicitly).** diff --git a/clippy.toml b/clippy.toml index 7a46f2f..47e97c5 100644 --- a/clippy.toml +++ b/clippy.toml @@ -3,7 +3,7 @@ msrv = "1.89" # complexity caps (aggressive but workable) cognitive-complexity-threshold = 15 type-complexity-threshold = 200 -too-many-arguments-threshold = 5 +too-many-arguments-threshold = 7 too-many-lines-threshold = 80 excessive-nesting-threshold = 4 max-fn-params-bools = 2 diff --git a/crates/bibeam-cli/src/cli.rs b/crates/bibeam-cli/src/cli.rs index 41e3d56..5e223cd 100644 --- a/crates/bibeam-cli/src/cli.rs +++ b/crates/bibeam-cli/src/cli.rs @@ -1,12 +1,4 @@ #![forbid(unsafe_code)] -#![allow( - clippy::redundant_pub_crate, - reason = "this module is binary-private (`mod cli;` in main.rs has no `pub`); rustc's \ - `unreachable_pub` warns on bare `pub` here, so every public-shaped item \ - below uses `pub(crate)`. The clippy nursery lint disagrees with rustc on \ - the same items — we side with rustc, the load-bearing one for the \ - workspace's `-D warnings` gate." -)] //! Clap subcommand surface for the `bibeam` CLI (F-CLI.1). //! //! The [`Cli`] / [`Cmd`] pair below is the entry-point shape every diff --git a/crates/bibeam-cli/src/config.rs b/crates/bibeam-cli/src/config.rs index 928f2b5..343323b 100644 --- a/crates/bibeam-cli/src/config.rs +++ b/crates/bibeam-cli/src/config.rs @@ -60,12 +60,6 @@ const DEFAULT_CONFIG_TOML: &str = "\ "; /// Errors emitted by the config helpers. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: rustc's `unreachable_pub` rejects bare `pub` on items \ - consumed only by sibling private modules; clippy disagrees. We side with \ - rustc, the load-bearing lint." -)] #[derive(Debug, Error)] pub(crate) enum ConfigError { /// No home directory is available — `directories::ProjectDirs::from` @@ -96,10 +90,6 @@ pub(crate) enum ConfigError { /// env overlay does not fall over on the missing keys; the /// `Default` impl is empty (all `None`) which means "use the /// in-code defaults the consumer carries". -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see ConfigError for the rustc-vs-clippy rationale." -)] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub(crate) struct Config { /// Local SOCKS5 bind address used by the F-CLI.8 fallback. @@ -123,10 +113,6 @@ pub(crate) struct Config { /// Returns [`ConfigError::NoHome`] when /// `directories::ProjectDirs::from` returns [`None`] — a host /// with no home directory available. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see ConfigError for the rustc-vs-clippy rationale." -)] pub(crate) fn config_dir() -> Result { let dirs = directories::ProjectDirs::from("", "BiBeam", "bibeam").ok_or(ConfigError::NoHome)?; Ok(dirs.config_dir().to_owned()) @@ -140,10 +126,6 @@ pub(crate) fn config_dir() -> Result { /// /// Returns [`ConfigError::NoHome`] when no home directory is /// available. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see ConfigError for the rustc-vs-clippy rationale." -)] pub(crate) fn config_file_path() -> Result { Ok(config_dir()?.join(CONFIG_FILENAME)) } @@ -163,10 +145,6 @@ pub(crate) fn config_file_path() -> Result { /// supplied and no home directory is available; /// [`ConfigError::Load`] for figment merge / parse / decode /// failures. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see ConfigError for the rustc-vs-clippy rationale." -)] pub(crate) fn load_config(path_override: Option<&Path>) -> Result { let resolved = match path_override { Some(path) => path.to_owned(), @@ -187,10 +165,6 @@ pub(crate) fn load_config(path_override: Option<&Path>) -> Result) -> Result { let resolved = match path_override { Some(path) => path.to_owned(), @@ -215,10 +189,6 @@ pub(crate) fn write_default_config(path_override: Option<&Path>) -> Result` fields). -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see ConfigError for the rustc-vs-clippy rationale." -)] #[allow( clippy::unused_async, reason = "async-shaped to keep the cli.rs dispatch table uniform; the figment loader \ diff --git a/crates/bibeam-cli/src/ech.rs b/crates/bibeam-cli/src/ech.rs index 7e8dd68..fbe498f 100644 --- a/crates/bibeam-cli/src/ech.rs +++ b/crates/bibeam-cli/src/ech.rs @@ -31,12 +31,6 @@ use thiserror::Error; /// match the operator-runbook convention; TOML keys and string /// literals alike are stable across config-file edits and env /// overlays. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: rustc's `unreachable_pub` rejects bare `pub` on items \ - consumed only by sibling private modules; clippy disagrees. We side with \ - rustc, the load-bearing lint." -)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum EchPolicy { @@ -69,10 +63,6 @@ impl fmt::Display for EchPolicy { /// Error returned by [`EchPolicy::from_str`] when the input is /// not one of the documented kebab-case names. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see EchPolicy for the rustc-vs-clippy rationale." -)] #[derive(Debug, Error, PartialEq, Eq)] #[error("ech-policy: unknown value {value:?} — accepted: \"best-effort\", \"deferred\"")] pub(crate) struct EchPolicyParseError { @@ -96,10 +86,6 @@ impl FromStr for EchPolicy { /// status. Bracketed in `#[allow(clippy::print_stdout)]` because /// it lands on stdout for the `status` subcommand (the operator /// scripting hook) rather than the JSON tracing layer. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see EchPolicy for the rustc-vs-clippy rationale." -)] #[allow( clippy::print_stdout, reason = "user-facing CLI output, not log: `bibeam status` is the documented \ diff --git a/crates/bibeam-cli/src/exit_pick.rs b/crates/bibeam-cli/src/exit_pick.rs index 055d018..1afc365 100644 --- a/crates/bibeam-cli/src/exit_pick.rs +++ b/crates/bibeam-cli/src/exit_pick.rs @@ -57,12 +57,6 @@ use rand::seq::IteratorRandom as _; /// Replaces the previous `Option<&str>` argument so the "any /// region" case reads as a distinct variant at every call site /// (Cleanup A — wire-format forward-compat strip). -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: rustc's `unreachable_pub` rejects bare `pub` on items \ - consumed only by sibling private modules; clippy disagrees. We side with \ - rustc, the load-bearing lint." -)] #[allow( dead_code, reason = "variants are constructed today only by the module's own integration tests; \ @@ -98,12 +92,6 @@ pub(crate) enum ExitFilter<'a> { /// /// The RNG is `&mut impl rand::Rng` so callers can wire in a /// seeded RNG for tests; production callers use `rand::rng()`. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: rustc's `unreachable_pub` rejects bare `pub` on items \ - consumed only by sibling private modules; clippy disagrees. We side with \ - rustc, the load-bearing lint." -)] #[allow( dead_code, reason = "wired into the up flow by F-CLI.5's rotation loop, which calls pick_exit \ diff --git a/crates/bibeam-cli/src/multihop.rs b/crates/bibeam-cli/src/multihop.rs index b02daa8..2bf1154 100644 --- a/crates/bibeam-cli/src/multihop.rs +++ b/crates/bibeam-cli/src/multihop.rs @@ -1,12 +1,4 @@ #![forbid(unsafe_code)] -#![allow( - clippy::redundant_pub_crate, - reason = "this module is binary-private (`mod multihop;` in main.rs has no `pub`); \ - rustc's `unreachable_pub` warns on bare `pub` here, so every public-shaped \ - item below uses `pub(crate)`. The clippy nursery lint disagrees with rustc \ - on the same items — we side with rustc, the load-bearing one for the \ - workspace's `-D warnings` gate." -)] #![allow( dead_code, reason = "R-MULTIHOP-CLI lands the client-side multi-hop encode / decode primitive; \ @@ -219,15 +211,6 @@ impl ClientSession { /// `chain_id` is the [`ChainId`] of the first forwarder's lease /// (`forwarder_chain[0].chain_id`). #[must_use] - #[allow( - clippy::too_many_arguments, - reason = "the multi-hop constructor surfaces the same five inputs `WgTunnel::new` \ - consumes (two keys, optional PSK, peer endpoint, socket) plus the \ - multi-hop-specific `chain_id` — total six, one over the workspace \ - threshold. Folding two of them into a builder struct would hide the \ - cipher-edge dependencies for no real ergonomic win at the one call \ - site this has." - )] pub(crate) fn new_multi_hop( local_secret: &WgSecretKey, exit_public: &WgPublicKey, @@ -413,11 +396,6 @@ fn wrap_in_relay_frame(chain_id: ChainId, wg_payload: Bytes) -> Bytes { } #[cfg(test)] -#[allow( - clippy::expect_used, - reason = "test-only convenience for unwrapping known-good codec / cipher round-trips; \ - the workspace clippy.toml allows `expect` and `unwrap` in tests" -)] mod tests { use core::net::{IpAddr, Ipv4Addr, SocketAddr}; diff --git a/crates/bibeam-cli/src/register.rs b/crates/bibeam-cli/src/register.rs index cd7d14b..a21fd7d 100644 --- a/crates/bibeam-cli/src/register.rs +++ b/crates/bibeam-cli/src/register.rs @@ -73,12 +73,6 @@ const STATE_SUBDIR: &str = "state"; const SESSION_BLOB_FILENAME: &str = "session.bin"; /// Errors emitted by the register / persist helpers in this module. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: rustc's `unreachable_pub` rejects bare `pub` on items \ - consumed only by sibling private modules; clippy disagrees. We side with \ - rustc, the load-bearing lint." -)] #[derive(Debug, Error)] pub(crate) enum RegisterError { /// The invite string did not decode as base64-URL-safe-no-pad. @@ -126,10 +120,6 @@ pub(crate) enum RegisterError { /// /// Returns [`RegisterError::InviteBase64`] for malformed armouring, /// [`RegisterError::InvitePostcard`] for malformed inner payload. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see RegisterError for the rustc-vs-clippy rationale." -)] pub(crate) fn parse_invite(armoured: &str) -> Result { let trimmed = armoured.trim(); let bytes = INVITE_BASE64 @@ -149,10 +139,6 @@ pub(crate) fn parse_invite(armoured: &str) -> Result/state.key` — the 32-byte AEAD key file. @@ -283,10 +257,6 @@ impl StatePaths { /// - [`RegisterError::StateIo`] on filesystem failures. /// - [`RegisterError::StateBlobTooShort`] if the key file exists /// but is the wrong length — likely an externally tampered file. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see RegisterError for the rustc-vs-clippy rationale." -)] #[allow( dead_code, reason = "wired into the up flow after F-CLI.6 lands. Today's integration tests \ @@ -326,10 +296,6 @@ pub(crate) fn load_or_create_state_key( /// - [`RegisterError::StateAead`] on seal failure (vanishingly /// unlikely with `ChaCha20-Poly1305`). /// - [`RegisterError::StateIo`] on filesystem failures. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see RegisterError for the rustc-vs-clippy rationale." -)] #[allow( dead_code, reason = "wired into the up flow after F-CLI.6 lands. Today's integration tests \ @@ -361,10 +327,6 @@ pub(crate) fn persist_session( /// - [`RegisterError::StateAead`] on auth-tag verify failure /// (wrong key, tampered blob, or wrong AAD). /// - [`RegisterError::StateIo`] on filesystem failures. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see RegisterError for the rustc-vs-clippy rationale." -)] #[allow( dead_code, reason = "wired into the up flow after F-CLI.6 lands. Today's integration tests \ diff --git a/crates/bibeam-cli/src/rotation.rs b/crates/bibeam-cli/src/rotation.rs index 05c5ae0..0ff2aa9 100644 --- a/crates/bibeam-cli/src/rotation.rs +++ b/crates/bibeam-cli/src/rotation.rs @@ -75,12 +75,6 @@ const BYTE_CAP_POLL_INTERVAL: Duration = Duration::from_secs(5); /// Why a rotation fired. The callback supplied to /// [`rotation_loop`] receives this so it can decide whether to /// log differently per cause, emit different metrics, etc. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: rustc's `unreachable_pub` rejects bare `pub` on items \ - consumed only by sibling private modules; clippy disagrees. We side with \ - rustc, the load-bearing lint." -)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RotationCause { /// The 15-minute interval elapsed. @@ -109,10 +103,6 @@ pub(crate) enum RotationCause { /// Propagates any error from the callback verbatim (wrapped in a /// "rotation failed" context). Returns `Ok(())` only when /// `cancel` is triggered. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see RotationCause for the rustc-vs-clippy rationale." -)] #[allow( dead_code, reason = "wired into the up flow by F-CLI.6's data-plane bring-up. Reachable today \ diff --git a/crates/bibeam-cli/src/socks5_fallback.rs b/crates/bibeam-cli/src/socks5_fallback.rs index b49c333..8871e16 100644 --- a/crates/bibeam-cli/src/socks5_fallback.rs +++ b/crates/bibeam-cli/src/socks5_fallback.rs @@ -44,12 +44,6 @@ const DEFAULT_BIND: &str = "127.0.0.1:1080"; /// /// Returns an [`anyhow::Error`] when `override_str` is set and /// the value fails to parse as a [`SocketAddr`]. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: rustc's `unreachable_pub` rejects bare `pub` on items \ - consumed only by sibling private modules; clippy disagrees. We side with \ - rustc, the load-bearing lint." -)] pub(crate) fn resolve_bind(override_str: Option<&str>) -> Result { let raw = override_str.unwrap_or(DEFAULT_BIND); raw.parse::().with_context(|| { @@ -68,10 +62,6 @@ pub(crate) fn resolve_bind(override_str: Option<&str>) -> Result { /// /// Forwards any error from /// [`bibeam_transport::run_socks5_listener`] verbatim. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see resolve_bind for the rustc-vs-clippy rationale." -)] pub(crate) async fn run_fallback(bind: SocketAddr, cancel: CancellationToken) -> Result<()> { warn_if_non_loopback(bind); tracing::info!( diff --git a/crates/bibeam-cli/src/tun_setup.rs b/crates/bibeam-cli/src/tun_setup.rs index c1e5c69..9f84c01 100644 --- a/crates/bibeam-cli/src/tun_setup.rs +++ b/crates/bibeam-cli/src/tun_setup.rs @@ -67,13 +67,6 @@ const DEFAULT_TUN_NAME: &str = "bibeam0"; /// sibling `cli` module materialises one and threads it through /// `setup_tun`. The fields stay `pub(crate)` so the caller can /// build a config inline. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: rustc's `unreachable_pub` rejects bare `pub` on items \ - consumed only by sibling private modules. The clippy nursery lint \ - disagrees with rustc on the same items; we side with rustc, the \ - load-bearing lint for the workspace's `-D warnings` gate." -)] #[derive(Debug, Clone)] pub(crate) struct TunSetupConfig { /// Interface name hint passed to [`TunDevice::new`]. @@ -96,10 +89,6 @@ impl Default for TunSetupConfig { /// `pub(crate)` so the sibling `cli` module's `up` handler can /// pattern-match the `NoPrivilege` variant for F-CLI.8's /// SOCKS5-fallback handoff. -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see `TunSetupConfig` for the rustc-vs-clippy rationale." -)] #[derive(Debug, Error)] pub(crate) enum TunSetupError { /// The running process lacks the privilege the platform's TUN @@ -166,10 +155,6 @@ const WINDOWS_NO_PRIV_HELP: &str = "the wintun driver requires an administrator /// capability check itself failed, which is rare enough that /// we surface it as a distinct variant rather than treat it /// as "no privilege". -#[allow( - clippy::redundant_pub_crate, - reason = "binary-only crate: see `TunSetupConfig` for the rustc-vs-clippy rationale." -)] pub(crate) async fn setup_tun(config: &TunSetupConfig) -> Result { #[cfg(target_os = "linux")] { diff --git a/crates/bibeam-core/Cargo.toml b/crates/bibeam-core/Cargo.toml index eb37a6f..0b29db4 100644 --- a/crates/bibeam-core/Cargo.toml +++ b/crates/bibeam-core/Cargo.toml @@ -18,6 +18,9 @@ time = { workspace = true } ulid = { workspace = true } zeroize = { workspace = true } +[dev-dependencies] +serde_json = { workspace = true } + [lints] workspace = true diff --git a/crates/bibeam-core/src/claims.rs b/crates/bibeam-core/src/claims.rs new file mode 100644 index 0000000..8805d37 --- /dev/null +++ b/crates/bibeam-core/src/claims.rs @@ -0,0 +1,97 @@ +#![forbid(unsafe_code)] +//! PASETO v4 session-token claim set. +//! +//! The coordinator issues a PASETO v4 token to every successfully +//! registered peer (see F-CRYPTO.4); that token carries a +//! [`SessionClaims`] payload. The struct lives in the core crate so +//! the crypto issuer/verifier and protocol control-plane types can +//! both reference one canonical authorisation shape without depending +//! on each other. +//! +//! The fields mirror the registration agreement the peer holds with +//! the coordinator: which peer (`sub`), in which cohort (`cohort`), +//! until when (`exp`), routed through which exit nodes (`exit_set`), +//! over which forwarder chain (`path`). +//! +//! `exit_set` and `path` carry distinct information and are NOT +//! redundant: +//! +//! - `exit_set` is the cohort's full exit roster — every exit node +//! the subject could have been assigned. Its membership is fixed +//! by the cohort and does not change across the cohort's lifetime. +//! - `path` is the concrete forwarder chain the subject was assigned +//! *this session*, with the chosen exit as its last entry. +//! +//! The issuer-side invariant is: `path.last()` MUST be an element of +//! `exit_set`. Enforcement of this invariant on the data-plane +//! verifier is deferred to R-MULTIHOP-NODE; tokens emitted in this +//! commit are produced by issuers that already respect it (see +//! `bibeam_node::coordinator::admission`), and the round-trip tests +//! exercise the resulting shape end-to-end. + +use serde::{Deserialize, Serialize}; + +use crate::{CohortId, NodeId, PeerId, Timestamp}; + +/// Claim set sealed inside a PASETO v4 session token. +/// +/// Field names follow PASETO/JWT convention: `sub` for the subject +/// peer, `exp` for the expiry instant. Together with `cohort`, +/// `exit_set`, and `path` they fix every authorisation decision the +/// data plane makes on behalf of `sub`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionClaims { + /// Subject peer the token was issued to. + pub sub: PeerId, + /// Cohort this session belongs to. + pub cohort: CohortId, + /// Wall-clock instant after which the token must be rejected. + pub exp: Timestamp, + /// Exit nodes the subject is authorised to route through. + pub exit_set: Vec, + /// Ordered forwarder chain the subject is authorised to use. + /// + /// The last entry is the exit; preceding entries (if any) are the + /// forwarders, in the order traffic flows. A one-element path + /// (`path == [exit]`) is the direct single-hop case — the + /// pre-multihop shape collapses naturally into this form. + pub path: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(byte: u8) -> NodeId { + NodeId(ulid::Ulid::from_bytes([byte; 16])) + } + + fn fixture_claims() -> SessionClaims { + SessionClaims { + sub: PeerId(ulid::Ulid::from_bytes([1; 16])), + cohort: CohortId(ulid::Ulid::from_bytes([2; 16])), + exp: Timestamp::from_offset_date_time( + time::OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"), + ), + exit_set: vec![node(3), node(4)], + path: vec![node(5), node(4)], + } + } + + #[test] + fn serde_round_trips_claim_shape() { + let claims = fixture_claims(); + let encoded = serde_json::to_value(&claims).expect("claims serialize"); + let decoded: SessionClaims = serde_json::from_value(encoded).expect("claims deserialize"); + + assert_eq!(decoded, claims); + } + + #[test] + fn exit_set_and_path_remain_distinct_fields() { + let encoded = serde_json::to_value(fixture_claims()).expect("claims serialize"); + + assert_ne!(encoded["exit_set"], encoded["path"]); + assert_eq!(encoded["path"][1], encoded["exit_set"][1]); + } +} diff --git a/crates/bibeam-core/src/error.rs b/crates/bibeam-core/src/error.rs index 82bac71..35413c5 100644 --- a/crates/bibeam-core/src/error.rs +++ b/crates/bibeam-core/src/error.rs @@ -22,7 +22,7 @@ pub enum Error { /// A cryptographic primitive or handshake failed. #[error("crypto error: {0}")] Crypto(String), - /// A transport-layer failure (QUIC, TCP, UDP, etc.). + /// A transport-layer failure (WireGuard/UDP, TCP). #[error("transport error: {0}")] Transport(String), /// A protocol-layer violation or unexpected message. diff --git a/crates/bibeam-core/src/lib.rs b/crates/bibeam-core/src/lib.rs index c7f51ba..0d3ccb4 100644 --- a/crates/bibeam-core/src/lib.rs +++ b/crates/bibeam-core/src/lib.rs @@ -1,12 +1,14 @@ #![forbid(unsafe_code)] #![doc = include_str!("../README.md")] +pub mod claims; pub mod error; pub mod identity; pub mod ids; pub mod redaction; pub mod time; +pub use claims::SessionClaims; pub use error::Error; pub use identity::Fingerprint; pub use ids::{ChainId, CohortId, NodeId, PeerId}; diff --git a/crates/bibeam-crypto/Cargo.toml b/crates/bibeam-crypto/Cargo.toml index bba5639..0f5a75b 100644 --- a/crates/bibeam-crypto/Cargo.toml +++ b/crates/bibeam-crypto/Cargo.toml @@ -9,7 +9,7 @@ categories = { workspace = true } description = "Key and keying-material helpers: x25519 peer keys, control-plane chacha20-poly1305 aead, ed25519 identity with pkcs#8 pem, paseto v4 issuer + verifier, hkdf-sha256 key derivation, blake3-keyed invite-code psk derivation." [dependencies] -bibeam-protocol = { workspace = true } +bibeam-core = { workspace = true } x25519-dalek = { workspace = true } ed25519-dalek = { workspace = true, features = ["pkcs8", "pem"] } chacha20poly1305 = { workspace = true } @@ -25,8 +25,6 @@ zeroize = { workspace = true } subtle = { workspace = true } thiserror = { workspace = true } -[dev-dependencies] -bibeam-core = { workspace = true } [lints] workspace = true diff --git a/crates/bibeam-crypto/src/token.rs b/crates/bibeam-crypto/src/token.rs index b6bf186..a8ffe91 100644 --- a/crates/bibeam-crypto/src/token.rs +++ b/crates/bibeam-crypto/src/token.rs @@ -5,7 +5,7 @@ //! and clients (or any verifier that holds the matching public key) //! validate them via [`PasetoVerifier::verify`]. Each token's payload //! carries the typed [`SessionClaims`] from -//! [`bibeam_protocol::claims`] — the canonical authorisation record +//! [`bibeam_core::claims`] — the canonical authorisation record //! the coordinator hands to a peer at registration time. //! //! ## Wire format @@ -19,7 +19,7 @@ //! enforce expiry without the caller having to bring custom validator //! logic. -use bibeam_protocol::claims::SessionClaims; +use bibeam_core::claims::SessionClaims; use core::convert::TryFrom; use pasetors::claims::{Claims, ClaimsValidationRules}; use pasetors::keys::{AsymmetricPublicKey, AsymmetricSecretKey}; diff --git a/crates/bibeam-crypto/src/wg_keys.rs b/crates/bibeam-crypto/src/wg_keys.rs index c6b16b7..e026362 100644 --- a/crates/bibeam-crypto/src/wg_keys.rs +++ b/crates/bibeam-crypto/src/wg_keys.rs @@ -260,26 +260,6 @@ mod tests { assert!(matches!(err, WgKeyError::Base64(_))); } - #[test] - fn session_psk_partial_eq_is_byte_equal() { - let bytes = [7u8; WG_KEY_LEN]; - let lhs = SessionPsk::new(bytes); - let rhs = SessionPsk::new(bytes); - assert_eq!(lhs, rhs); - let mut other = bytes; - other[0] = 8; - let differ = SessionPsk::new(other); - assert_ne!(lhs, differ); - } - - #[test] - fn wg_psk_partial_eq_is_byte_equal() { - let bytes = [3u8; WG_KEY_LEN]; - let lhs = WgPsk::new(bytes); - let rhs = WgPsk::new(bytes); - assert_eq!(lhs, rhs); - } - #[test] fn debug_redacts_secret_material() { let sk = WgSecretKey::generate(); diff --git a/crates/bibeam-crypto/tests/token_adversarial.rs b/crates/bibeam-crypto/tests/token_adversarial.rs index 1b46f5b..13e7b44 100644 --- a/crates/bibeam-crypto/tests/token_adversarial.rs +++ b/crates/bibeam-crypto/tests/token_adversarial.rs @@ -24,9 +24,8 @@ //! verify time, so a token minted with `SessionClaims.exp` in the //! past must surface as [`TokenError::Paseto`]. -use bibeam_core::{CohortId, NodeId, PeerId, Timestamp}; +use bibeam_core::{CohortId, NodeId, PeerId, Timestamp, claims::SessionClaims}; use bibeam_crypto::{PasetoIssuer, PasetoVerifier, TokenError}; -use bibeam_protocol::claims::SessionClaims; use pasetors::claims::Claims; use pasetors::keys::{AsymmetricKeyPair, Generate}; use pasetors::public; diff --git a/crates/bibeam-crypto/tests/zeroize_audit.rs b/crates/bibeam-crypto/tests/zeroize_audit.rs deleted file mode 100644 index 31f851a..0000000 --- a/crates/bibeam-crypto/tests/zeroize_audit.rs +++ /dev/null @@ -1,59 +0,0 @@ -#![forbid(unsafe_code)] -//! F-CRYPTO.7 audit: the secret-bearing newtypes that -//! [F-CRYPTO.1, F-CRYPTO.3, F-CRYPTO.6] introduce all implement -//! [`zeroize::ZeroizeOnDrop`] at the type level today. -//! -//! This is a static, enumerated check — one assertion per current -//! secret type. The list is not generated; future contributors who -//! add a new secret newtype to this crate are expected to add it -//! here too, but nothing in this test forces them to. We accept that -//! limitation rather than reach for a derive macro: in safe Rust, -//! the only stronger guarantee is a build-time registry, and the -//! review cost of one explicit assertion per secret type is lower -//! than the cost of a custom proc-macro. -//! -//! What this test does guarantee: if a workspace upgrade -//! (`x25519-dalek` flipping a feature flag, an `ed25519-dalek` -//! version bump, a refactor that swaps a newtype's inner type for -//! something less zeroizing) breaks the trait projection for any of -//! the enumerated types below, this file stops compiling. -//! -//! `forbid(unsafe_code)` rules out pointer-poking after-drop checks, -//! so the trait-level proof on the current type set is the strongest -//! available compile-time guarantee. - -use bibeam_crypto::{ - IdentitySecretKey, InviteCode, MasterInviteKey, SessionPsk, WgPsk, WgSecretKey, -}; - -const fn assert_zeroize_on_drop() {} - -#[test] -fn wg_secret_key_zeroizes_on_drop() { - assert_zeroize_on_drop::(); -} - -#[test] -fn identity_secret_key_zeroizes_on_drop() { - assert_zeroize_on_drop::(); -} - -#[test] -fn session_psk_zeroizes_on_drop() { - assert_zeroize_on_drop::(); -} - -#[test] -fn wg_psk_zeroizes_on_drop() { - assert_zeroize_on_drop::(); -} - -#[test] -fn master_invite_key_zeroizes_on_drop() { - assert_zeroize_on_drop::(); -} - -#[test] -fn invite_code_zeroizes_on_drop() { - assert_zeroize_on_drop::(); -} diff --git a/crates/bibeam-discovery/tests/bootstrap_happy_path.rs b/crates/bibeam-discovery/tests/bootstrap_happy_path.rs index a82a57f..ed92867 100644 --- a/crates/bibeam-discovery/tests/bootstrap_happy_path.rs +++ b/crates/bibeam-discovery/tests/bootstrap_happy_path.rs @@ -12,10 +12,6 @@ clippy::unwrap_used, reason = "integration test fixtures use unwrap/expect in setup paths" )] -#![allow( - clippy::missing_panics_doc, - reason = "test functions never document panic behaviour" -)] use std::net::SocketAddr; use std::sync::{Arc, Once}; diff --git a/crates/bibeam-node/src/coordinator/admission_gate.rs b/crates/bibeam-node/src/coordinator/admission_gate.rs index bc041d5..8ebed4e 100644 --- a/crates/bibeam-node/src/coordinator/admission_gate.rs +++ b/crates/bibeam-node/src/coordinator/admission_gate.rs @@ -209,16 +209,6 @@ impl AdmissionGate { /// `exit_region_lookup` resolves each cohort exit's [`NodeId`] /// to its operator-tagged region string for the emitted /// [`MatchResponse`]; see [`ExitRegionLookup`] for the contract. - #[allow( - clippy::too_many_arguments, - reason = "R-REGION.3 widened the gate's admit entry point with the \ - exit_region_lookup callback; the other five arguments are \ - the pre-existing F-COORD.5 contract surface (peer_id, \ - cohort_id, region, &mut cohort, &self). Bundling them \ - into a struct only shifts the same six fields onto a \ - literal at every call site and obscures the trailing \ - lookup pointer the rotation scheduler threads in by Arc." - )] pub fn admit_or_bucket( &self, peer_id: PeerId, @@ -318,16 +308,6 @@ impl AdmissionGate { /// floor releases every other peer it brought along for the /// same `(region, cohort_id)` pair. Waiters bucketed under a /// different `cohort_id` or a different region are untouched. - #[allow( - clippy::too_many_arguments, - reason = "R-REGION.3 widened the drain entry point with the \ - exit_region_lookup callback; the other five arguments are \ - the pre-existing F-COORD.5 + F-COORD.8 contract surface \ - (region, cohort_id, &cohort, audit_log, &self). Bundling \ - them into a struct only shifts the same six fields onto \ - a literal at every call site and obscures the trailing \ - lookup pointer the rotation scheduler threads in by Arc." - )] pub fn drain_ready( &self, region: &str, @@ -950,14 +930,6 @@ mod tests { /// bucket, the `floor`th onward admit. Returns only the /// bucketed receivers (the admitted ones already got their /// `MatchResponse` via the admit return). - #[allow( - clippy::too_many_arguments, - reason = "Test helper: the floor-crossing drive needs every \ - parameter the production `admit_or_bucket` needs \ - plus the count of admissions and the floor itself. \ - Test-side helpers are exempt from the strict five- \ - argument bound the production-path helpers honour." - )] fn admit_to_floor_crossing( gate: &AdmissionGate, region: &str, diff --git a/crates/bibeam-node/src/coordinator/cluster.rs b/crates/bibeam-node/src/coordinator/cluster.rs index 40ed5f6..222062e 100644 --- a/crates/bibeam-node/src/coordinator/cluster.rs +++ b/crates/bibeam-node/src/coordinator/cluster.rs @@ -157,7 +157,7 @@ pub trait LeaderLease: Send + Sync { /// deployments. Production multi-coordinator deployments replace /// it with a real backend in a follow-up PR per /// `docs/architecture.md`. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy)] pub struct SoleLeaderLease; impl LeaderLease for SoleLeaderLease { diff --git a/crates/bibeam-node/src/coordinator/multihop.rs b/crates/bibeam-node/src/coordinator/multihop.rs index 57ddeb5..b3d5bbe 100644 --- a/crates/bibeam-node/src/coordinator/multihop.rs +++ b/crates/bibeam-node/src/coordinator/multihop.rs @@ -381,7 +381,7 @@ fn build_single_hop(exit: &ExitCandidate) -> SingleHopMatch { cohort: bibeam_core::CohortId::new(), exit_set: vec![exit.node_id], exit_regions: HashMap::new(), - rotation_deadline: rotation_deadline_now(), + rotation_deadline: lease_deadline_now(), } } @@ -395,7 +395,7 @@ fn build_multi_hop( forwarders: &[ForwarderCandidate], exit: &ExitCandidate, ) -> MultiHopAssignment { - let lease_expires_at = lease_expires_at_now(); + let lease_expires_at = lease_deadline_now(); let forwarder_chain = build_lease_chain(client.addr, forwarders, exit.addr, lease_expires_at); MultiHopAssignment { exit: exit.node_id, @@ -404,16 +404,9 @@ fn build_multi_hop( } } -/// `Timestamp::now() + 15 min` — the rotation cadence §11 R-3 -/// requires every assembled cohort to honour. Extracted so the -/// single-hop and multi-hop branches stamp the same horizon. -fn rotation_deadline_now() -> Timestamp { - Timestamp::from_offset_date_time(Timestamp::now().into_inner() + LEASE_DURATION) -} - -/// `Timestamp::now() + 15 min` — the lease expiry every -/// [`ForwarderLease`] this module emits inherits. -fn lease_expires_at_now() -> Timestamp { +/// `Timestamp::now() + 15 min` — the lease deadline every assembled +/// cohort and [`ForwarderLease`] this module emits inherits. +fn lease_deadline_now() -> Timestamp { Timestamp::from_offset_date_time(Timestamp::now().into_inner() + LEASE_DURATION) } @@ -528,10 +521,6 @@ impl RegionView for InMemoryRegionView { } #[cfg(test)] -#[allow( - clippy::expect_used, - reason = "test-only convenience for unwrapping known-good fixture values" -)] mod tests { use super::*; diff --git a/crates/bibeam-node/src/coordinator/rate_limit.rs b/crates/bibeam-node/src/coordinator/rate_limit.rs index 05a21d0..9b7dd07 100644 --- a/crates/bibeam-node/src/coordinator/rate_limit.rs +++ b/crates/bibeam-node/src/coordinator/rate_limit.rs @@ -47,6 +47,8 @@ use core::net::IpAddr; use std::num::NonZeroU32; use std::sync::Arc; +pub use crate::rate_limit::RateLimitConfigError; + use bibeam_core::PeerId; use governor::clock::DefaultClock; use governor::state::keyed::DefaultKeyedStateStore; @@ -99,18 +101,6 @@ pub struct RateLimitDenied { pub scope: String, } -/// Configuration error returned when a per-minute cap is zero -/// (which would mean "block everyone" and is never a valid input). -#[derive(Debug, Error)] -pub enum RateLimitConfigError { - /// Caller supplied a zero cap for the named field. - #[error("rate-limit configuration error: `{field}` must be non-zero")] - ZeroBudget { - /// Name of the cap field that was zero. - field: &'static str, - }, -} - /// Per-route rate-limit decisions. /// /// Each instance owns the (per-IP, optionally per-PeerId) governor diff --git a/crates/bibeam-node/src/coordinator/region_verify.rs b/crates/bibeam-node/src/coordinator/region_verify.rs index 3e342df..50faf11 100644 --- a/crates/bibeam-node/src/coordinator/region_verify.rs +++ b/crates/bibeam-node/src/coordinator/region_verify.rs @@ -179,16 +179,6 @@ impl AllowlistCidrs { /// run the coord without a configured `mmdb_path`; that branch /// returns `Outcome::Skipped` immediately, no DB touch and no audit /// row. -#[allow( - clippy::too_many_arguments, - reason = "The cross-check contract takes six borrows by design \ - (lookup, declared_region, observed_ip, allowlist, peer_id, \ - audit_log). Bundling into a public parameter struct only \ - shifts the same six fields onto a literal at every call \ - site and inflates the public surface area for one private \ - lint workaround. The internal dispatcher takes the same six \ - borrows." -)] pub fn cross_check_on_register( lookup: Option<&dyn CountryLookup>, declared_region: &str, @@ -223,12 +213,6 @@ pub fn cross_check_on_register( /// Mismatch handling is identical to registration — one audit row /// per heartbeat tick that observes a divergence. The audit cadence /// matches the §11 R-3 R-FLOOR precedent (one row per gate poll). -#[allow( - clippy::too_many_arguments, - reason = "Same six-borrow contract as cross_check_on_register; the \ - heartbeat variant exists to switch the tracing level and \ - expose verified_at, not to change the input shape." -)] #[must_use] pub fn cross_check_on_heartbeat( lookup: Option<&dyn CountryLookup>, diff --git a/crates/bibeam-node/src/coordinator/rotation.rs b/crates/bibeam-node/src/coordinator/rotation.rs index 093325c..1230470 100644 --- a/crates/bibeam-node/src/coordinator/rotation.rs +++ b/crates/bibeam-node/src/coordinator/rotation.rs @@ -131,17 +131,6 @@ impl RotationScheduler { const adds an API promise we cannot honour at call \ sites that already hold runtime-allocated Arcs." )] - #[allow( - clippy::too_many_arguments, - reason = "Test entry point: the cadence variant takes the \ - same 4-Arc dependency bundle the const-cadence \ - variant takes plus two Duration knobs the test \ - drives manually. Bundling these into a Config \ - struct only shifts the argument count off the \ - function signature and onto the struct literal at \ - every call site, and the constructor is exercised \ - by tests only — the production path uses `new`." - )] pub fn with_cadence( registry: Arc, cohorts: Arc, diff --git a/crates/bibeam-node/src/exit_mode.rs b/crates/bibeam-node/src/exit_mode.rs index 19fa581..5bb0fab 100644 --- a/crates/bibeam-node/src/exit_mode.rs +++ b/crates/bibeam-node/src/exit_mode.rs @@ -106,16 +106,6 @@ pub enum ExitModeError { Socks5(#[source] bibeam_transport::Socks5Error), } -impl From for bibeam_core::Error { - fn from(err: ExitModeError) -> Self { - // Both variants of `ExitModeError` are I/O-class failures on the - // exit data path: a TUN write failure or a SOCKS5 listener - // failure. `bibeam_core::Error::Transport` is the closest class - // — the exit is one half of a transport session. - Self::Transport(err.to_string()) - } -} - /// Module-private trait abstracting the "write one IP packet" operation. /// /// Exists so tests can substitute a recording mock (see diff --git a/crates/bibeam-node/src/forwarder.rs b/crates/bibeam-node/src/forwarder.rs index 7bdb9ce..fc6fe00 100644 --- a/crates/bibeam-node/src/forwarder.rs +++ b/crates/bibeam-node/src/forwarder.rs @@ -597,10 +597,8 @@ fn classify_direction(src: SocketAddr, entry: &RoutingEntry) -> Option = Arc::new(NoopAcceptHandler); - // We cannot easily synthesize a `quinn::Connection` without a - // real handshake; the construction-and-storage path above is - // the load-bearing surface this test covers (the - // dyn-compatibility check is at the `Arc` coercion). - // The functional behaviour is exercised via the - // `accept_loop_exits_on_cancel` test, which threads - // `NoopAcceptHandler` through the real loop. - drop(handler); - } - #[test] fn handler_error_wraps_display() { // Contract: `HandlerError::from_display` formats any diff --git a/crates/bibeam-node/src/rotation_handler.rs b/crates/bibeam-node/src/rotation_handler.rs index 4f67f0d..0fec067 100644 --- a/crates/bibeam-node/src/rotation_handler.rs +++ b/crates/bibeam-node/src/rotation_handler.rs @@ -34,23 +34,16 @@ //! "atomically swap its active cohort assignment WITHOUT dropping any //! in-flight packets". //! -//! ## `CohortHandler` trait — provisional, pending F-NODE.5 +//! ## Cohort event integration //! -//! F-NODE.5 (Cohort assignment receiver) is landing in parallel on a -//! disjoint file set and will eventually own the canonical -//! [`CohortHandler`] trait that dispatches `CohortAssigned` and -//! `CohortRotated` events. F-NODE.6's task spec requires -//! `impl CohortHandler for RotationHandler { fn on_rotated(...) }`, -//! which cannot compile without the trait, so the trait is defined -//! here as a forward declaration. When F-NODE.5 lands, whoever -//! merges second consolidates: either F-NODE.5's PR moves the trait -//! to its own module and this file `pub use`s it, or this module -//! stays the canonical home and F-NODE.5 imports from here. The -//! trait surface is intentionally narrow — a single -//! [`CohortHandler::on_rotated`] entry point matching the F-NODE.6 -//! task spec, with the obvious `Send + Sync` bound so the -//! coordinator event-loop task can dispatch into it from any -//! tokio worker. +//! The canonical event-dispatch trait lives in +//! [`crate::cohort_ws::CohortHandler`] and receives a `CohortRotate` +//! envelope (`old`, `new`, `at`). This module intentionally owns only +//! the rotation mechanics: [`RotationHandler::on_rotated`] accepts an +//! already-resolved [`CohortLive`] snapshot and delegates to +//! [`RotationHandler::swap_to`]. F-NODE.5 can translate the async event +//! payload into a full cohort snapshot before handing it to this helper, +//! without keeping a duplicate sync trait in this module. //! //! ## Out of scope (deferred to follow-ups) //! @@ -71,29 +64,6 @@ use bibeam_protocol::cohort::CohortLive; use crate::telemetry::NODE_COHORT_ROTATIONS_TOTAL; -/// Provisional trait for the node-side handler of coordinator-pushed -/// cohort events. -/// -/// F-NODE.6 only exercises [`Self::on_rotated`]; the broader event -/// surface (`CohortAssigned`, `Disconnect`) belongs to F-NODE.5, which -/// is landing on a disjoint file set in parallel. Defining the trait -/// here lets the rotation handler land independently; the eventual -/// canonical home is F-NODE.5's module, at which point one of the -/// two PRs `pub use`s the other's definition (see the module-level -/// doc for the consolidation rule). -/// -/// Implementors MUST be `Send + Sync` — the coordinator event-loop -/// task dispatches into them from a `tokio` worker and the handler -/// runs concurrently with active-cohort reads on every data-plane -/// site. Implementations MUST be non-blocking at the read-side and -/// MUST NOT block the dispatch caller, since the event loop is -/// shared with other event variants. -pub trait CohortHandler: Send + Sync { - /// Handle a `CohortRotated` event by adopting `new` as the - /// active cohort. - fn on_rotated(&self, new: CohortLive); -} - /// Node-side handler for [`bibeam_discovery::CoordinatorEvent::CohortRotated`]. /// /// Owns the active [`CohortLive`] snapshot behind an @@ -197,6 +167,20 @@ impl RotationHandler { ); } + /// Handle a resolved rotated cohort snapshot by adopting `new` as + /// the active cohort. + /// + /// This is intentionally an inherent helper rather than a local + /// trait implementation. The canonical async event surface is + /// [`crate::cohort_ws::CohortHandler`], whose `on_rotated` method + /// receives only a `CohortRotate` envelope; that integration must + /// resolve the replacement [`CohortLive`] before calling this helper. + /// Kept public for embedders that already resolve coordinator + /// rotations into full cohort snapshots. + pub fn on_rotated(&self, new: CohortLive) { + self.swap_to(new); + } + /// Return the total number of rotations this handler has /// processed since construction. /// @@ -208,12 +192,6 @@ impl RotationHandler { } } -impl CohortHandler for RotationHandler { - fn on_rotated(&self, new: CohortLive) { - self.swap_to(new); - } -} - #[cfg(test)] mod tests { use std::collections::HashMap; @@ -224,7 +202,7 @@ mod tests { use bibeam_core::{CohortId, NodeId, PeerId, Timestamp}; use bibeam_protocol::cohort::CohortLive; - use super::{CohortHandler, RotationHandler}; + use super::RotationHandler; /// Build a [`CohortLive`] with a single member + single exit, /// freshly-generated identifiers, and the current wall clock. @@ -311,14 +289,12 @@ mod tests { assert_eq!(handler.rotation_count(), 1); } - /// Contract: `on_rotated` (the `CohortHandler` trait method) - /// dispatches to `swap_to`. + /// Contract: `on_rotated` delegates a resolved [`CohortLive`] to + /// `swap_to`. /// - /// Locks in the F-NODE.6 task-spec requirement - /// `impl CohortHandler for RotationHandler { fn on_rotated(...) }`. - /// A regression that wired `on_rotated` to a no-op (or to a - /// different cohort field) would let coordinator rotation events - /// silently drop on the floor. + /// This test only covers the inherent helper alias. Event routing + /// and `CohortRotate`-to-`CohortLive` resolution belong to the + /// `cohort_ws` integration path. #[test] fn on_rotated_dispatches_to_swap() { let initial = sample_cohort(); @@ -326,7 +302,7 @@ mod tests { let replacement_id = replacement.cohort; let handler = RotationHandler::new(initial); - ::on_rotated(&handler, replacement); + handler.on_rotated(replacement); let snapshot = handler.current_cohort(); assert_eq!(snapshot.cohort, replacement_id); diff --git a/crates/bibeam-node/src/telemetry.rs b/crates/bibeam-node/src/telemetry.rs index 1e28473..c6276f0 100644 --- a/crates/bibeam-node/src/telemetry.rs +++ b/crates/bibeam-node/src/telemetry.rs @@ -244,98 +244,6 @@ pub fn register_node_metrics() { mod tests { use super::*; - /// The complete set of counter names this module defines. - /// - /// Keeping the list in one place means adding a new counter - /// requires touching this slice — which is exactly what we want: - /// the structural tests below will then verify the convention - /// holds for the new name too. - const COUNTERS: &[&str] = &[ - NODE_PACKETS_RELAYED_TOTAL, - NODE_PACKETS_EXITED_TOTAL, - NODE_SOCKS5_CONNECTIONS_TOTAL, - NODE_DNS_RESOLUTIONS_TOTAL, - NODE_RATE_LIMIT_DROPS_TOTAL, - NODE_COHORT_ROTATIONS_TOTAL, - NODE_COORD_WS_RECONNECTS_TOTAL, - ]; - - /// The complete set of gauge names this module defines. - const GAUGES: &[&str] = &[NODE_ACTIVE_COHORTS, NODE_ACTIVE_PEER_SESSIONS, NODE_DNS_CACHE_SIZE]; - - /// Workspace convention: every metric defined by a node-crate - /// const carries the `bibeam_node_` prefix and is composed of - /// snake-case ascii noun tokens. - /// - /// The shape `bibeam___{...}` is enforced - /// structurally: - /// - /// 1. starts with `bibeam_node_`; - /// 2. contains only `[a-z0-9_]` and at least one separator after - /// the prefix (i.e. has a noun and a unit segment). - #[test] - fn metric_names_follow_workspace_convention() { - for name in COUNTERS.iter().chain(GAUGES.iter()) { - assert!( - name.starts_with("bibeam_node_"), - "metric name {name} must share the bibeam_node_ prefix", - ); - assert!( - name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'), - "metric name {name} must be snake-case ascii (lowercase + digits + underscore)", - ); - // After stripping the `bibeam_node_` prefix, the name must - // contain at least one more segment — i.e. it is not just - // the prefix on its own. This guards against a degenerate - // entry like `"bibeam_node_"`. - let tail = name.strip_prefix("bibeam_node_").unwrap_or_default(); - assert!( - !tail.is_empty(), - "metric name {name} must include a noun segment after the prefix", - ); - } - } - - /// Prometheus convention: every counter name ends in `_total`. - /// - /// `metrics-exporter-prometheus` does NOT enforce this for you; - /// it is purely a downstream-tooling convention. Dashboards, - /// alert rules, and recording rules all rely on the suffix to - /// pick counters out of a mixed list. - #[test] - fn each_counter_metric_ends_in_total() { - for name in COUNTERS { - assert!( - name.ends_with("_total"), - "counter name {name} must follow the Prometheus _total convention", - ); - } - } - - /// Structural inverse: every gauge name is listed under the - /// gauges section and does NOT carry the `_total` suffix. - /// - /// A gauge named `*_total` would mislead Prometheus tooling into - /// treating it as a counter (e.g. computing rates over a non- - /// monotonic series), so we lock the absence in. - #[test] - fn each_gauge_metric_is_listed_under_gauges_section() { - for name in GAUGES { - assert!( - !name.ends_with("_total"), - "gauge name {name} MUST NOT carry the _total suffix \ - (Prometheus tooling would wrongly classify it as a counter)", - ); - } - // Cross-check: no name appears in both sections. - for name in COUNTERS { - assert!( - !GAUGES.contains(name), - "metric {name} is listed as both a counter and a gauge", - ); - } - } - /// Sanity: [`register_node_metrics`] is callable without a recorder /// installed (the describe macros route to the global no-op /// recorder in test binaries that do not install one). This diff --git a/crates/bibeam-node/tests/geoip_verify_e2e.rs b/crates/bibeam-node/tests/geoip_verify_e2e.rs index a44ae3e..113e433 100644 --- a/crates/bibeam-node/tests/geoip_verify_e2e.rs +++ b/crates/bibeam-node/tests/geoip_verify_e2e.rs @@ -4,10 +4,6 @@ clippy::unwrap_used, reason = "integration test fixtures use unwrap/expect in setup paths" )] -#![allow( - clippy::missing_panics_doc, - reason = "test functions never document panic behaviour" -)] //! Integration tests for §11 verification gate #5 — the D-5 warn-only //! `GeoIP` cross-check primitive (R-REGION.2 / R-REGION.3). //! diff --git a/crates/bibeam-node/tests/helpers/mod.rs b/crates/bibeam-node/tests/helpers/mod.rs index eb38e6c..71da828 100644 --- a/crates/bibeam-node/tests/helpers/mod.rs +++ b/crates/bibeam-node/tests/helpers/mod.rs @@ -15,7 +15,6 @@ #![allow( clippy::expect_used, clippy::unwrap_used, - clippy::missing_panics_doc, clippy::panic, reason = "Integration-test fixtures unwrap/expect/panic in setup paths." )] diff --git a/crates/bibeam-node/tests/multihop_e2e.rs b/crates/bibeam-node/tests/multihop_e2e.rs index 92855aa..882708b 100644 --- a/crates/bibeam-node/tests/multihop_e2e.rs +++ b/crates/bibeam-node/tests/multihop_e2e.rs @@ -4,10 +4,6 @@ clippy::unwrap_used, reason = "integration test fixtures use unwrap/expect in setup paths" )] -#![allow( - clippy::missing_panics_doc, - reason = "test functions never document panic behaviour" -)] #![allow( clippy::indexing_slicing, reason = "test code indexes into freshly-built fixed-size buffers" diff --git a/crates/bibeam-node/tests/rate_limit_characterization.rs b/crates/bibeam-node/tests/rate_limit_characterization.rs index d487b05..ebf1da2 100644 --- a/crates/bibeam-node/tests/rate_limit_characterization.rs +++ b/crates/bibeam-node/tests/rate_limit_characterization.rs @@ -26,7 +26,21 @@ //! accessor. use bibeam_core::PeerId; -use bibeam_node::rate_limit::NodeRateLimiter; +use bibeam_node::coordinator::rate_limit::{ + RateLimitConfigError as CoordinatorRateLimitConfigError, RouteLimiter, +}; +use bibeam_node::rate_limit::{ + NodeRateLimiter, RateLimitConfigError as SharedRateLimitConfigError, +}; + +/// The coordinator module keeps its historical public import path, +/// but the type is now the shared data-plane config-error enum. +#[test] +fn coordinator_rate_limit_config_error_reexport_is_shared_type() { + let err: SharedRateLimitConfigError = + RouteLimiter::new("register", 0, None).expect_err("zero budget is rejected"); + let _: CoordinatorRateLimitConfigError = err; +} /// Admitting N distinct peers populates the per-peer map with /// exactly N entries. No eviction trims the map; the residency diff --git a/crates/bibeam-node/tests/region_routing_e2e.rs b/crates/bibeam-node/tests/region_routing_e2e.rs index 99ce54a..d13200a 100644 --- a/crates/bibeam-node/tests/region_routing_e2e.rs +++ b/crates/bibeam-node/tests/region_routing_e2e.rs @@ -4,10 +4,6 @@ clippy::unwrap_used, reason = "integration test fixtures use unwrap/expect in setup paths" )] -#![allow( - clippy::missing_panics_doc, - reason = "test functions never document panic behaviour" -)] //! Integration tests for §11 verification gate #3 — per-region floor //! formalism (R-FLOOR + R-MULTIHOP-COORD's path-assembly refusal). //! diff --git a/crates/bibeam-protocol/src/claims.rs b/crates/bibeam-protocol/src/claims.rs index b71303d..67bc144 100644 --- a/crates/bibeam-protocol/src/claims.rs +++ b/crates/bibeam-protocol/src/claims.rs @@ -1,58 +1,9 @@ #![forbid(unsafe_code)] -//! PASETO v4 session-token claim set. +//! Backward-compatible [`SessionClaims`] re-export. //! -//! The coordinator issues a PASETO v4 token to every successfully -//! registered peer (see F-CRYPTO.4); that token carries a -//! [`SessionClaims`] payload. The struct lives in the protocol crate -//! — not in `bibeam-crypto` — so the discovery layer can reference one -//! canonical shape without depending on the cryptographic machinery -//! that issues and verifies it. -//! -//! The fields mirror the registration agreement the peer holds with -//! the coordinator: which peer (`sub`), in which cohort (`cohort`), -//! until when (`exp`), routed through which exit nodes (`exit_set`), -//! over which forwarder chain (`path`). -//! -//! `exit_set` and `path` carry distinct information and are NOT -//! redundant: -//! -//! - `exit_set` is the cohort's full exit roster — every exit node -//! the subject could have been assigned. Its membership is fixed -//! by the cohort and does not change across the cohort's lifetime. -//! - `path` is the concrete forwarder chain the subject was assigned -//! *this session*, with the chosen exit as its last entry. -//! -//! The issuer-side invariant is: `path.last()` MUST be an element of -//! `exit_set`. Enforcement of this invariant on the data-plane -//! verifier is deferred to R-MULTIHOP-NODE; tokens emitted in this -//! commit are produced by issuers that already respect it (see -//! `bibeam_node::coordinator::admission`), and the round-trip tests -//! exercise the resulting shape end-to-end. - -use bibeam_core::{CohortId, NodeId, PeerId, Timestamp}; -use serde::{Deserialize, Serialize}; +//! The canonical claim set lives in `bibeam-core` so the protocol +//! crate and crypto issuer/verifier both depend only on the core +//! domain shape. This module preserves the historical +//! `bibeam_protocol::claims::SessionClaims` import path for callers. -/// Claim set sealed inside a PASETO v4 session token. -/// -/// Field names follow PASETO/JWT convention: `sub` for the subject -/// peer, `exp` for the expiry instant. Together with `cohort`, -/// `exit_set`, and `path` they fix every authorisation decision the -/// data plane makes on behalf of `sub`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SessionClaims { - /// Subject peer the token was issued to. - pub sub: PeerId, - /// Cohort this session belongs to. - pub cohort: CohortId, - /// Wall-clock instant after which the token must be rejected. - pub exp: Timestamp, - /// Exit nodes the subject is authorised to route through. - pub exit_set: Vec, - /// Ordered forwarder chain the subject is authorised to use. - /// - /// The last entry is the exit; preceding entries (if any) are the - /// forwarders, in the order traffic flows. A one-element path - /// (`path == [exit]`) is the direct single-hop case — the - /// pre-multihop shape collapses naturally into this form. - pub path: Vec, -} +pub use bibeam_core::claims::SessionClaims; diff --git a/crates/bibeam-protocol/src/frame.rs b/crates/bibeam-protocol/src/frame.rs index 60217cf..d23d19d 100644 --- a/crates/bibeam-protocol/src/frame.rs +++ b/crates/bibeam-protocol/src/frame.rs @@ -11,7 +11,7 @@ //! //! - [`Frame::Control`] carries the discovery / coordinator control //! messages added in F-PROTO.3, -//! - [`Frame::Tunnel`] carries the Noise-sealed IP datagram added in +//! - [`Frame::Tunnel`] carries the WG-sealed IP datagram added in //! F-PROTO.4, and //! - [`Frame::Cohort`] carries the cohort lifecycle messages added in //! F-PROTO.5. diff --git a/crates/bibeam-protocol/src/multihop.rs b/crates/bibeam-protocol/src/multihop.rs index 518a792..cc0dcb2 100644 --- a/crates/bibeam-protocol/src/multihop.rs +++ b/crates/bibeam-protocol/src/multihop.rs @@ -279,10 +279,6 @@ impl RelayFrame { } #[cfg(test)] -#[allow( - clippy::expect_used, - reason = "test-only convenience for unwrapping known-good codec round-trips" -)] mod tests { //! Unit tests in this module cover the `RelayFrame` encode/decode //! contract — the fixed-prefix data-plane layout that does NOT ride @@ -316,21 +312,6 @@ mod tests { assert_eq!(decoded, frame); } - #[test] - fn relay_frame_round_trip_empty_payload() { - // Contract: an empty wg_payload still round-trips — the - // prefix alone is a syntactically valid frame even if no - // forwarder would route it. - let frame = RelayFrame { - chain_id: ChainId::new(), - wg_payload: Bytes::new(), - }; - let encoded = frame.encode(); - assert_eq!(encoded.len(), RELAY_FRAME_PREFIX_LEN); - let decoded = RelayFrame::decode(&encoded).expect("decode round-trip"); - assert_eq!(decoded, frame); - } - #[test] fn relay_frame_decode_rejects_truncated_prefix() { // Contract: a buffer shorter than the 16-byte chain_id diff --git a/crates/bibeam-protocol/src/tunnel.rs b/crates/bibeam-protocol/src/tunnel.rs index 515d766..e39bb7e 100644 --- a/crates/bibeam-protocol/src/tunnel.rs +++ b/crates/bibeam-protocol/src/tunnel.rs @@ -1,7 +1,7 @@ #![forbid(unsafe_code)] //! Data-plane tunnel datagram. //! -//! Tunnel frames carry a single Noise-sealed IP packet between two +//! Tunnel frames carry a single WG-sealed IP packet between two //! peers. The cryptographic sealing/unsealing lives in `bibeam-crypto` //! and `bibeam-transport`; this layer is wire-shape only — `payload` is //! treated as opaque bytes by the codec and by any router along the @@ -21,6 +21,6 @@ use bibeam_core::PeerId; pub struct Tunnel { /// Identifier of the peer that sealed `payload`. pub peer_id: PeerId, - /// Noise-sealed IP frame; opaque to this layer. + /// WG-sealed IP frame; opaque to this layer. pub payload: Bytes, } diff --git a/crates/bibeam-protocol/tests/codec_adversarial.rs b/crates/bibeam-protocol/tests/codec_adversarial.rs index 4df885e..697b0e1 100644 --- a/crates/bibeam-protocol/tests/codec_adversarial.rs +++ b/crates/bibeam-protocol/tests/codec_adversarial.rs @@ -97,7 +97,7 @@ proptest! { /// [`decode`] is total over `&[u8]`: for any byte sequence up to /// 64 KiB the function returns either `Ok(_)` or `Err(_)`, never /// panics. The 64-KiB bound is a generous superset of any real - /// wire frame (well above the QUIC MTU domain the protocol + /// wire frame (well above the WireGuard MTU domain the protocol /// targets) and keeps individual proptest iterations fast. #[test] fn proptest_decode_never_panics(buf in vec(any::(), 0..65_536)) { diff --git a/crates/bibeam-runtime/Cargo.toml b/crates/bibeam-runtime/Cargo.toml index aaa065b..ae834fb 100644 --- a/crates/bibeam-runtime/Cargo.toml +++ b/crates/bibeam-runtime/Cargo.toml @@ -24,12 +24,9 @@ tracing-error = { workspace = true } metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } figment = { workspace = true } -clap = { workspace = true } axum = { workspace = true } -tower-http = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } -anyhow = { workspace = true } mimalloc = { workspace = true, optional = true } [dev-dependencies] @@ -38,6 +35,5 @@ tokio = { workspace = true, features = ["test-util"] } [lints] workspace = true - [package.metadata.cargo-machete] -ignored = ["bibeam-core", "tokio", "tokio-util", "tracing", "tracing-subscriber", "tracing-error", "metrics", "metrics-exporter-prometheus", "figment", "clap", "axum", "tower-http", "serde", "thiserror", "anyhow", "mimalloc"] +ignored = ["bibeam-core", "tokio", "tokio-util", "tracing", "tracing-subscriber", "tracing-error", "metrics", "metrics-exporter-prometheus", "figment", "axum", "serde", "thiserror", "mimalloc"] diff --git a/crates/bibeam-runtime/src/health.rs b/crates/bibeam-runtime/src/health.rs index cea783b..3a97d54 100644 --- a/crates/bibeam-runtime/src/health.rs +++ b/crates/bibeam-runtime/src/health.rs @@ -44,6 +44,11 @@ pub struct ReadyLatch { impl ReadyLatch { /// Construct a [`ReadyLatch`] in the *not-ready* state. #[must_use] + #[allow( + clippy::new_without_default, + reason = "ReadyLatch is constructed explicitly at daemon bring-up; \ + implicit Default construction is unused ceremony." + )] pub fn new() -> Self { Self { ready: Arc::new(AtomicBool::new(false)), @@ -67,12 +72,6 @@ impl ReadyLatch { } } -impl Default for ReadyLatch { - fn default() -> Self { - Self::new() - } -} - /// Build the health-check [`axum::Router`]. /// /// `latch` is cloned into the router's state; the caller's copy diff --git a/crates/bibeam-transport/src/telemetry.rs b/crates/bibeam-transport/src/telemetry.rs index 3f3bf48..660eb13 100644 --- a/crates/bibeam-transport/src/telemetry.rs +++ b/crates/bibeam-transport/src/telemetry.rs @@ -203,30 +203,6 @@ mod tests { RedactionKey::from_bytes([7_u8; 32]) } - #[test] - fn counter_names_carry_the_prometheus_total_suffix() { - for name in [ - WG_HANDSHAKE_STARTED_TOTAL, - WG_HANDSHAKE_COMPLETED_TOTAL, - HOLEPUNCH_STARTED_TOTAL, - HOLEPUNCH_SUCCEEDED_TOTAL, - HOLEPUNCH_TIMED_OUT_TOTAL, - DECRYPT_FAILURE_TOTAL, - BYTES_IN_TOTAL, - BYTES_OUT_TOTAL, - ] { - assert!( - name.ends_with("_total"), - "counter name {name} must follow Prometheus _total convention", - ); - } - } - - #[test] - fn telemetry_target_is_module_scoped() { - assert_eq!(TELEMETRY_TARGET, "bibeam_transport"); - } - #[test] fn record_helpers_are_callable_without_a_subscriber() { // None of the record_* helpers should panic when no diff --git a/crates/bibeam-transport/src/wg_tunnel.rs b/crates/bibeam-transport/src/wg_tunnel.rs index 625cc49..67a7ac0 100644 --- a/crates/bibeam-transport/src/wg_tunnel.rs +++ b/crates/bibeam-transport/src/wg_tunnel.rs @@ -164,14 +164,6 @@ impl WgTunnel { /// non-overlapping. The prefix is opaque to the wire and only /// affects internal routing. #[must_use] - #[allow( - clippy::too_many_arguments, - reason = "boringtun's Tunn::new takes 6 inputs (two keys, optional PSK, \ - keepalive, session prefix, rate limiter); this fn plus the \ - socket + peer-addr already exceeds the 5-arg threshold. \ - Wrapping in a builder struct hides the dependencies and adds \ - no real ergonomic win at the one call-site this has." - )] pub fn with_session_prefix( local_secret: &WgSecretKey, peer_public: &WgPublicKey, diff --git a/crates/bibeam-tun/src/backpressure.rs b/crates/bibeam-tun/src/backpressure.rs index ddc1212..b4388b4 100644 --- a/crates/bibeam-tun/src/backpressure.rs +++ b/crates/bibeam-tun/src/backpressure.rs @@ -27,7 +27,6 @@ //! This module deliberately has no concept of packet class. Per-class //! scheduling (DSCP-aware, flow-keyed) is a deferred enhancement that //! lands as its own task only after a classifier exists upstream. -//! See `docs/plan/tasks.md` F-TUN.8 for rationale. use bytes::Bytes; use tokio::sync::mpsc; diff --git a/crates/bibeam-tun/src/inbound.rs b/crates/bibeam-tun/src/inbound.rs index 4890a6c..59fee30 100644 --- a/crates/bibeam-tun/src/inbound.rs +++ b/crates/bibeam-tun/src/inbound.rs @@ -9,7 +9,7 @@ //! //! The channel carries **already-decrypted IP bytes**. Whatever //! upstream stage handed bytes to us has already stripped the -//! transport envelope (e.g. QUIC datagram) and the AEAD seal +//! transport envelope (e.g. WireGuard-encapsulated UDP packet) and the AEAD seal //! (see `bibeam-crypto`). This crate stays cleanly L3; the inverse //! split is documented on the outbound side too. //! diff --git a/crates/bibeam-tun/src/outbound.rs b/crates/bibeam-tun/src/outbound.rs index 91df5f0..feb6e48 100644 --- a/crates/bibeam-tun/src/outbound.rs +++ b/crates/bibeam-tun/src/outbound.rs @@ -24,8 +24,7 @@ //! transport frame is the responsibility of a downstream stage //! (`bibeam-crypto` for the AEAD, `bibeam-transport` for the //! datagram envelope). The split keeps `bibeam-tun` cleanly L3 and -//! lets the crypto policy be decided independently — see D-4 in -//! `docs/plan/tasks.md`. +//! lets the crypto policy be decided independently — see D-4. use bytes::Bytes; use tokio::sync::mpsc; diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 81df1bc..afcd111 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -15,7 +15,7 @@ The current state of the two daemons: | `bibeam-node` | Prints `bootstrap version=0.0.1` and waits for SIGINT, then exits cleanly with status 0. No network listener, no storage, no config file. | | `bibeam-cli` | Same as above. No connection to a coordinator. | -There is **no** REST API, **no** `/metrics`, **no** `/healthz`, **no** `/readyz`, **no** `--config` flag, **no** redb storage, **no** Noise tunnel, **no** cohort admission, **no** pkarr fallback. None of this is wired up. +There is **no** REST API, **no** `/metrics`, **no** `/healthz`, **no** `/readyz`, **no** `--config` flag, **no** redb storage, **no** cohort admission, **no** pkarr fallback. None of this is wired up. ### Build @@ -67,7 +67,7 @@ Once the daemons accept network connections, the expected host setup on Oracle A ```bash sudo apt-get install -y curl ca-certificates build-essential pkg-config libssl-dev -sudo ufw allow 4433/udp # QUIC data plane (chosen port) +sudo ufw allow 4433/udp # WireGuard data plane (chosen port) sudo ufw allow 8443/tcp # coordinator REST + WS # /metrics, /healthz, /readyz bind loopback only — no firewall rule needed ``` diff --git a/docs/plan/tasks.md b/docs/plan/tasks.md index e5dd2a3..88d8d8f 100644 --- a/docs/plan/tasks.md +++ b/docs/plan/tasks.md @@ -146,7 +146,7 @@ Each per-crate task is the **first non-stub merge** for that crate. Sub-items ar - **F-PROTO.1** `Frame` enum + magic bytes — 4-byte magic, 1-byte version, postcard-serialized body. - **F-PROTO.2** postcard codec — `Frame::encode(&self) -> bytes::Bytes`, `Frame::decode(&[u8]) -> Result`; round-trip property test using `proptest`. - **F-PROTO.3** Control-plane messages — `Register`, `RegisterAck`, `MatchRequest`, `MatchResponse`, `Heartbeat`, `Disconnect`; all `#[derive(Serialize, Deserialize)]`. -- **F-PROTO.4** Data-plane datagram frame — `Tunnel { peer_id: PeerId, payload: bytes::Bytes }` — for Noise-sealed-IP payloads carried in QUIC datagrams. + - **F-PROTO.4** Data-plane datagram frame — `Tunnel { peer_id: PeerId, payload: bytes::Bytes }` — for WG-sealed-IP payloads carried in WireGuard datagrams. - **F-PROTO.5** Cohort lifecycle messages — `CohortAdmit`, `CohortLive`, `CohortRotate` per plan §2 decision #8 and `docs/protocol.md`. - **F-PROTO.6** PASETO claim struct — `SessionClaims { sub: PeerId, cohort: CohortId, exp: Timestamp, exit_set: Vec }`; matches `bibeam-crypto`'s PASETO issuer. - **F-PROTO.7** Error codes enum — `ProtocolError` with `From` impls for `postcard::Error` and `bibeam_core::Error`. @@ -222,7 +222,7 @@ Per D-4, the data plane is **WireGuard (UDP)** via `boringtun`, not Quinn QUIC + - **F-DISC.7** Session bootstrap protocol — happy path: `redeem invite → register → receive session token (PASETO) → receive cohort assignment`. - **Gate.** `cargo clippy -p bibeam-discovery …` clean + an in-process mock coordinator drives the full bootstrap to a PASETO-issued session. -### F-COORD — `bibeam-coordinator` (depends on all libs; bin) + ### F-COORD — `bibeam-node` coordinator submodule (depends on all libs; via `bibeam-node`) - **F-COORD.1** axum HTTP server — `/v1/register`, `/v1/match`, `/v1/heartbeat`, `/v1/disconnect` plus WS upgrade endpoint. - **F-COORD.2** redb-backed peer registry — peers keyed by `PeerId`, value = `PeerRecord` (last-seen, exit-capability flag, capacity). @@ -236,13 +236,13 @@ Per D-4, the data plane is **WireGuard (UDP)** via `boringtun`, not Quinn QUIC + - **F-COORD.10** BLAKE3-keyed PII hash before logging — wraps every `tracing::info!(peer_id = …)` via the `bibeam-runtime` redaction layer. - **F-COORD.11** Health / readiness — `/healthz` always-200, `/readyz` reflects redb open + axum bound state. - **F-COORD.12** Multi-coordinator failover wiring — independent coordinators per plan §2 decision #7; no inter-coord state (until P2A-1's replication-protocol PR lands). -- **Gate.** `cargo clippy -p bibeam-coordinator …` clean + an end-to-end test on a single coordinator process: register two peers, satisfy admission floor with a 30-member fixture, issue a token, verify token against `bibeam-crypto` verifier. + - **Gate.** `cargo clippy -p bibeam-node --lib --bin bibeam-node …` clean + an end-to-end test on a single coordinator process: register two peers, satisfy admission floor with a 30-member fixture, issue a token, verify token against `bibeam-crypto` verifier. ### F-NODE — `bibeam-node` (depends on all libs; bin) - **F-NODE.1** Coordinator registration flow — at startup, register self with all configured coordinators in parallel; succeed on first quorum. - **F-NODE.2** Quinn server accept loop — inbound tunnel acceptance from peers in the same cohort. -- **F-NODE.3** Relay traffic between peers — when matched as relay, forward Noise-sealed datagrams between two cohort members. + - **F-NODE.3** Relay traffic between peers — when matched as relay, forward WG-sealed datagrams between two cohort members. - **F-NODE.4** Exit traffic to internet — two paths corresponding to the CLI's TUN-or-SOCKS5 choice (F-CLI.2 / F-CLI.8). **L3 path (TUN-side ingress):** decrypt inbound `Tunnel` datagrams, hand raw IP packets to an OS-level NAT/routing layer — Linux `nftables` / `iptables` `MASQUERADE`, macOS `pf` NAT, or Windows ICS — operator-documented in `docs/operator-runbook.md`. Userspace TCP/UDP termination via `smoltcp` is a deferred enhancement — see D-3. **L4 path (SOCKS5-side ingress):** decrypt inbound `Tunnel` datagrams as L4 stream payloads, terminate SOCKS5 server semantics locally (via `fast-socks5`), forward to the destination via the OS socket layer (no NAT mutation required). SNI-obfuscation behavior follows D-1, not assumed here. - **F-NODE.5** Cohort assignment receiver — WS message from coordinator; updates local `CohortState`. - **F-NODE.6** Rotation event handler — on `CohortRotate`, drain current exit, accept the new cohort, atomically swap. @@ -260,7 +260,7 @@ Per D-4, the data plane is **WireGuard (UDP)** via `boringtun`, not Quinn QUIC + - **F-CLI.5** Per-session rotation — 15-min wall-clock OR 500-MB cumulative bytes, whichever fires first; rotation drains current exit and prompts coordinator for re-pool. - **F-CLI.6** Config persistence — `figment` TOML at `~/.config/bibeam/config.toml` (Linux/macOS) or `%APPDATA%\bibeam\config.toml` (Windows). - **F-CLI.7** ECH policy exposure — surface whichever policy D-1 selects as a config-visible flag (e.g. `ech = "best-effort" | "deferred" | "skipped"`) and a `status` line for operator visibility; the actual ECH hop (DNS HTTPS record lookup, rustls ECH-extension wiring) lives in F-TRANS.2 (and is consumed at the exit via F-NODE.4 when D-1 places the hop server-side). The CLI consumes the policy; it does **not** load DNS HTTPS records itself. -- **F-CLI.8** SOCKS5 fallback — when TUN setup fails (no capabilities, restricted environment), expose `127.0.0.1:1080` SOCKS5 over QUIC datagram tunnel. + - **F-CLI.8** SOCKS5 fallback — when TUN setup fails (no capabilities, restricted environment), expose `127.0.0.1:1080` SOCKS5 over WireGuard tunnel. - **Gate.** `cargo clippy -p bibeam-cli …` clean + `cargo run --bin bibeam -- --help` and `cargo run --bin bibeam -- version` exit 0 with non-empty stdout. --- diff --git a/docs/protocol.md b/docs/protocol.md index 5f2f656..c1f1ddf 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -10,53 +10,14 @@ Endianness: all multi-byte integers postcard emits are little-endian varints; fr ## Transport -- **Data plane.** [QUIC](https://datatracker.ietf.org/doc/html/rfc9000) using [Quinn](https://docs.rs/quinn) 0.11.x with the rustls-ring crypto provider. Bulk packet payloads ride [QUIC unreliable datagrams (RFC 9221)](https://datatracker.ietf.org/doc/html/rfc9221). Reliable side-channels (control messages within a session) ride QUIC streams. -- **Control plane.** REST over HTTPS plus WebSocket subscriptions, both served by [axum](https://docs.rs/axum) on the coordinator. Bodies are JSON for human debuggability; the WS subscription stream carries postcard frames inside binary WS messages. - -### Layering: Noise runs over QUIC, not as QUIC - -QUIC's own TLS 1.3 layer provides the network-level secure channel: server authentication via the exit's QUIC cert chain, packet protection on the wire, congestion control. Noise IK runs **on top of that QUIC connection as an application-layer end-to-end channel** between client and exit. Noise does not replace QUIC's packet protection; it adds a second envelope. The two AEAD layers are nested, not alternatives — Noise owns the payload contents inside QUIC frames; QUIC owns the transport. - -### QUIC mapping contract - -Each established client-to-exit QUIC connection carries two reliable stream channels plus one datagram channel. The data channel rides QUIC unreliable datagrams (RFC 9221) and is recognized by being a datagram, not by stream order; the two stream channels are recognized by client-initiated open order. - -**Stream channels.** Stream IDs are not fixed by this spec — Quinn allocates them in standard order — but role-to-open-order is normative. The client opens exactly two client-initiated bidirectional streams per connection, in this order: - -1. **Handshake stream** (first client-initiated bidi stream). Carries two postcard-encoded Noise IK messages: client → exit, then exit → client. The stream closes cleanly after message 2 is delivered. -2. **Control stream** (second client-initiated bidi stream, opened immediately after handshake completion). Carries Noise-sealed postcard frames for rekey salts, rotation announcements, byte-budget accounting, and `ProtocolError` reports. The stream remains open for the lifetime of the session. - -The exit MUST NOT open additional streams to the client in either direction. A peer that receives a client-initiated stream beyond the second, or any server-initiated stream, MUST close that stream with the QUIC application error code corresponding to `proto.stream_unexpected`. - -**Datagram channel.** After handshake completion, both sides MAY send QUIC unreliable datagrams. Each datagram payload is exactly one Noise-AEAD-sealed postcard data frame carrying an L3 IP packet; no fragmentation of a sealed frame across datagrams and no batching of multiple sealed frames into one datagram. Datagrams received before handshake completion MUST be dropped without error. +Data plane speaks WireGuard wire protocol via boringtun. Control plane is REST over HTTPS plus WebSocket on axum. ## Identity and keys - **Long-term peer identity.** Ed25519 keypair. The 32-byte public key is the canonical peer ID, and a ULID-derived 16-byte tag is the routing alias used in postcard frames (the full key is exchanged at registration; the alias is what flies on the wire). -- **Static key for Noise IK.** X25519 keypair, distinct from the Ed25519 identity. Exits advertise their static public key through the coordinator's exit catalog. Clients learn an exit's static key as part of the match response, signed by the coordinator inside the PASETO token. +- **Static key for WireGuard.** X25519 keypair used for the WireGuard handshake (boringtun). Exits advertise their public key through the coordinator's exit catalog. Clients learn an exit's static key as part of the match response, signed by the coordinator inside the PASETO token. - **Invite material.** Each invite encodes a coordinator-signed bundle `{invite_id, max_uses, expires_at, signature}`. Invite admission proves possession of a fresh invite to the coordinator; the coordinator records the use and decrements `remaining_uses`. -## Noise IK handshake - -Pattern: `Noise_IK_25519_ChaChaPoly_BLAKE3`, run by [snow](https://docs.rs/snow) 0.10 with the `ring-accelerated` backend. - -- **IK** — the client knows the responder's static key (acquired from the coordinator) before the handshake begins. Standard one-RTT exchange, suitable for client-initiated connections to known exits. -- **25519** — X25519 for ephemeral and static key agreement. -- **ChaChaPoly** — ChaCha20-Poly1305 AEAD for transport keys. -- **BLAKE3** — hash function for the Noise mixing chain. - -Handshake payloads: - -- **Message 1 (`e, es, s, ss`).** Client → Exit. Payload carries the PASETO session token issued by the coordinator and a postcard-encoded `ClientHello { proto_version, capabilities }`. -- **Message 2 (`e, ee, se`).** Exit → Client. Payload carries `ExitAck { transport_params, session_alias }`. - -After message 2, both sides derive symmetric transport keys (`k1`, `k2`) from the Noise mixing chain and switch to AEAD-per-packet sealing of QUIC datagrams. - -### Key schedule - -- Noise establishes the initial transport keys after handshake completion (Noise spec `Split()` output). -- Rekey happens when either side has sealed `2^20` packets under the current key, or at exit-driven rotation (every 15 min / 500 MB), whichever comes first. Rekey is a HKDF-BLAKE3 derivation from the current chaining key + a fresh 16-byte salt exchanged on a reliable stream. - ## PASETO session tokens Coordinator-issued, PASETO v4 (public) tokens are the data-plane admission credential. Library: [`pasetors`](https://docs.rs/pasetors) 0.7 with the `v4` feature. @@ -113,11 +74,10 @@ Errors carry a stable string code plus a human-readable message. Codes form a cl | `token.expired` | 401 | `exp` in the past | | `token.replay` | 409 | `jti` previously seen | | `proto.version_unsupported` | 426 | `proto_version` not accepted | -| `proto.stream_unexpected` | n/a (QUIC stream close only) | Peer opened a stream beyond the two-stream topology defined in [QUIC mapping contract](#quic-mapping-contract); raised only on the data plane. | | `rate.limited` | 429 | Per-peer rate limit hit | | `internal` | 500 | Anything not explicitly mapped above | -Data-plane equivalents (carried inside Noise-sealed protocol frames) reuse the same string codes inside a `ProtocolError { code, message }` frame. +Data-plane equivalents (carried inside WireGuard-sealed protocol frames) reuse the same string codes inside a `ProtocolError { code, message }` frame. ## Cohort admission lifecycle diff --git a/docs/threat-model.md b/docs/threat-model.md index c641e07..b62f52b 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -31,28 +31,28 @@ The protocol it describes lives in [`docs/protocol.md`](./protocol.md); the arch ### The user's ISP (and any on-path observer between user and exit) -**Capability.** Observes every packet between the user's modem and the exit's IP. Can see the exit's IP, packet timing, byte volumes, and that the link is QUIC. +**Capability.** Observes every packet between the user's modem and the exit's IP. Can see the exit's IP, packet timing, byte volumes, and that the link is WireGuard/UDP. **Visibility.** -- Destination SNI: **not visible** on this hop. The user-to-exit link carries Noise-sealed traffic inside QUIC datagrams. The ISP sees only the exit's IP and a QUIC handshake, with no information about what the user is fetching. +- Destination SNI: **not visible** on this hop. The user-to-exit link carries WireGuard-encrypted traffic. The ISP sees only the exit's IP and a WireGuard handshake, with no information about what the user is fetching. - Exit IP: visible. The ISP knows the user talks to this exit; it does not know what the user does through it. - Traffic volume and timing: visible. Long-term flow analysis can correlate that the user is online and using BiBeam; it does not reveal the content. **Mitigations.** -- **Noise IK over QUIC.** All data-plane traffic is sealed by Noise (ChaCha20-Poly1305) inside QUIC's own packet protection. The ISP sees encrypted bytes. +- **WireGuard.** All data-plane traffic is encrypted by WireGuard (ChaCha20-Poly1305). The ISP sees encrypted bytes. - **Datagram-based transport.** No reliable-stream side-channel for content; the ISP cannot reconstruct application-layer flows even with deep packet inspection. - **Exit diversity.** The set of exits used by a single user changes every rotation, blurring any "this user always talks to exit X" pattern. ### A curious exit operator -**Capability.** Runs the exit binary. Terminates the user-to-exit QUIC connection. Sees each connecting client's source IP, the PASETO session alias bound to that connection, decrypted L3 IP frames as they egress, and the destination IP/port of every onward connection. Sees inner SNI when the destination does not advertise ECH. +**Capability.** Runs the exit binary. Terminates the user-to-exit WireGuard tunnel. Sees each connecting client's source IP, the PASETO session alias bound to that connection, decrypted L3 IP frames as they egress, and the destination IP/port of every onward connection. Sees inner SNI when the destination does not advertise ECH. **Visibility — be precise about what is and is not unlinkable.** The exit-operator threat splits into two distinct questions, and they have different answers: 1. **Can the destination identify the user?** *No, not from network metadata.* The destination sees only the exit's IP, with traffic that may belong to any user in the current cohort. This is the property pool mixing buys. -2. **Can the exit itself link a specific egress packet back to a specific client?** *Yes.* Because the exit terminates the user-to-exit QUIC connection, every Noise-sealed packet arrives on a specific connection bound to a specific client source IP and session alias. The exit can correlate "client at source IP A, session alias S sent these L3 frames, which I forwarded to destination D." Pool mixing does **not** break that link — mixing produces unlinkability against observers downstream of the exit, not against the exit itself. +2. **Can the exit itself link a specific egress packet back to a specific client?** *Yes.* Because the exit terminates the user-to-exit WireGuard tunnel, every WireGuard-encrypted packet arrives on a specific session bound to a specific client source IP and session alias. The exit can correlate "client at source IP A, session alias S sent these L3 frames, which I forwarded to destination D." Pool mixing does **not** break that link — mixing produces unlinkability against observers downstream of the exit, not against the exit itself. So the exit knows: @@ -105,7 +105,7 @@ And does **not** know: **Mitigations.** -- **Per-session Noise keys.** Each client-to-exit session has independent transport keys. +- **Per-session WireGuard keys.** Each client-to-exit session has independent WireGuard keys. - **No peer-to-peer signalling through the exit.** The exit forwards L3 packets toward the public Internet, not laterally to other cohort members. - **Coordinator gating.** A peer attempting to flood admission with many invites to dominate a cohort runs into per-invite rate limits and the invite-distribution chokepoint. @@ -169,7 +169,7 @@ Client and exit each generate their own X25519 WG keypairs at registration time - **Replay.** PASETO `jti` is a UUID v7 and is checked against a per-exit seen-set retained until each token's `exp` passes (TTL-keyed, no capacity-driven eviction). Tokens are bound to a single exit via `aud`; replay against a different exit fails the `aud` check. - **Downgrade.** `proto_version` in `ClientHello` and the PASETO claim must match. There is no negotiation step a MITM can use to push both sides to a weaker version. -- **Impersonation.** Exit static keys are coordinator-signed inside the match response. A client checks the signature before initiating the Noise handshake; a network attacker cannot substitute a static key without forging the coordinator's signature. +- **Impersonation.** Exit static keys are coordinator-signed inside the match response. A client checks the signature before initiating the WireGuard handshake; a network attacker cannot substitute a static key without forging the coordinator's signature. ## What we do not promise