diff --git a/Cargo.lock b/Cargo.lock index 9657b52..a177314 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4594,7 +4594,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "peerspeak" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 1967184..4cbfedc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "peerspeak" -version = "0.1.0" +version = "0.2.0" edition = "2024" +# Application crate, not a crates.io library — refuse `cargo publish` and let +# cargo-deny's [licenses.private] skip the missing-license check. +publish = false [lib] name = "peerspeak" diff --git a/SECURITY-REVIEW-security-scan.md b/SECURITY-REVIEW-security-scan.md new file mode 100644 index 0000000..3b62e1d --- /dev/null +++ b/SECURITY-REVIEW-security-scan.md @@ -0,0 +1,51 @@ +# Security Review: `security-scan` branch (PeerSpeak) + +_Date: 2026-06-18_ + +**Scope:** Protocol-versioning migration (`src/protocol.rs`, `versioned_topic`, +ALPN/domain centralization, gossip topic namespacing) and the `deny.toml` +supply-chain policy addition. + +## Result: No high-confidence security vulnerabilities found. + +Each plausible attack surface introduced by this branch was investigated and +confirmed safe: + +### 1. `versioned_topic` XOR transform — topic secrecy preserved +`src/protocol.rs:46`, used at `src/network/gossip.rs:255` + +The room `topic_id` is a uniformly random 32-byte secret (`rand::random()`, +`src/core/mod.rs:1012`) acting as the room capability. XOR-ing it with the public +constant `GOSSIP_PROTO.to_le_bytes()` cyclically is **bijective and +entropy-preserving** — the result is still uniformly random; no byte becomes +predictable and no entropy is lost. The room secret is no more recoverable by an +observer than before the change (previously the raw `topic_id` was the on-wire +topic; now it's a trivial public XOR of it). Bijectivity also preserves room +distinctness, so isolation is not weakened. **Not a vulnerability.** + +### 2. Signature topic-binding — no raw/versioned confusion +`src/network/gossip.rs` + +`active_topic_bytes` stores the **raw** `ticket.topic_id` (line 293), and both +`sign_gossip` and `verify_gossip` bind against that raw value. Only the +*subscribed* swarm topic (line 255) uses the versioned value. There is one swarm +per join and every peer signs/verifies against the same raw topic, so no second +topic exists to enable a raw↔versioned replay/confusion attack. Code matches +VERSIONING.md's claim. **Not a vulnerability.** + +### 3. `GOSSIP_SIG_DOMAIN` — moved verbatim +Value identical (`"peerspeak-gossip-v1"`, `src/protocol.rs:34`); cross-version +cryptographic domain separation preserved. **Not a vulnerability.** + +### 4. ALPN changes — handshake compatibility only +Audio `peerspeak-audio` → `peerspeak/audio/1`, friends `/0` → `/1`. No security +check keys off the old ALPN strings (audio admission is gated by live room +membership per S8, not the ALPN literal); no residual references to old strings +in non-test code. **Not a vulnerability.** + +### 5. `deny.toml` +Ignores only two *unmaintained* advisories (`RUSTSEC-2024-0436`, +`RUSTSEC-2026-0150`) on compile-time/FFI-only crates — documented, and dependency +advisories are out of scope. **Not a vulnerability.** + +The versioning migration is a clean, security-preserving change. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..a28eb56 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,139 @@ +# PeerSpeak Versioning Standard + +PeerSpeak is a full-mesh P2P voice app. Its "API contract" is not a library +surface — it is the **wire protocol** two nodes use to talk. So versioning here +tracks one question above all others: + +> **Can a node on build X talk to a node on build Y?** + +There are two distinct version layers. Keep them straight. + +--- + +## Layer 1 — Release version (`Cargo.toml`) + +The human-facing label you put on a build ("install this one"). + +**Scheme: SemVer, pre-1.0 (`0.MINOR.PATCH`).** + +While we are pre-1.0 (friends-only, no stability promise yet): + +| Change | Bump | Example | +| --- | --- | --- | +| **Breaking wire/protocol change** — peers on the old build can no longer interoperate; *everyone must update* | **MINOR** | `0.4.2 → 0.5.0` | +| Compatible change — bug fix, internal refactor, or a feature that does **not** change the wire (UI, local-only behavior, additive logic that old peers ignore safely) | **PATCH** | `0.4.2 → 0.4.3` | + +- **Reaching `1.0.0`:** when PeerSpeak is first shared beyond the trusted-friends + circle (a "public" release), and we are willing to commit to wire stability. + After 1.0, MAJOR = wire break, MINOR = compatible feature, PATCH = fix (normal + SemVer). +- Bump `version` in `Cargo.toml` as part of the change that warrants it, in the + same commit. The number in `Cargo.toml` is the source of truth; surface it in + the UI (e.g. an About/Settings line) so a user can read their build. + +**Rule of thumb:** if you find yourself writing "all peers must rebuild" or +"breaking gossip wire change" in a commit message (as S2 and W4 did), that is a +**MINOR** bump, and it must also bump the relevant protocol version in Layer 2. + +--- + +## Layer 2 — Protocol compatibility (the one that actually breaks calls) + +Wire incompatibility must **fail fast and legibly** — never as a silent +signature/decode error that looks like a bug or an attack. We achieve this by +embedding a protocol version into each transport plane, so incompatible peers +are rejected at connect/subscribe time instead of mid-conversation. + +PeerSpeak has **three independent planes**, each versioned **separately** — bump +only the plane whose wire format actually changed (audio rarely changes; gossip +changes often; they must not be forced to bump together). + +### ALPN naming convention + +All peerspeak ALPNs use the form **`peerspeak//`** where `` is that +plane's protocol version (an integer, starts at `1`). iroh refuses a connection +whose ALPN does not match exactly, so two peers on different `` for a plane +simply cannot open that connection → we map that to a clean "peer is running an +incompatible version" instead of garbage. + +| Plane | ALPN / mechanism | Bump when… | +| --- | --- | --- | +| **Audio** | ALPN `peerspeak/audio/` | the Opus/datagram framing, sequencing, or audio-handshake changes | +| **Friends/presence** | ALPN `peerspeak/friends/` | the `ControlMsg` / presence ping-pong shape changes | +| **Gossip** | *(see below — cannot use a custom ALPN)* | `GossipPayload` / `GossipMessage` / `PeerState` shape, signing, or freshness rules change | + +### Gossip is special + +The gossip plane runs over **iroh-gossip's own `GOSSIP_ALPN`**, which we do not +control, so we cannot version it via the ALPN. Instead, the gossip protocol +version is bound in **two** places: + +1. **Topic namespacing (primary, fail-fast):** the room's `topic_id` is a random + 32 bytes carried in the ticket, but the topic we actually *subscribe* to is + `protocol::versioned_topic(topic_id)` — a deterministic, dependency-free + transform that folds `GOSSIP_PROTO` into the bytes. Peers on different gossip + versions therefore derive **different subscription topics from the same ticket** + and never share a swarm — the same isolation a versioned ALPN gives the other + planes. The ticket format and the room identity (`topic_id`) are unchanged; only + the subscribed topic is namespaced. (The transform is for *isolation*, not + security — cryptographic separation is the signature domain below.) +2. **Signature domain (cryptographic separation):** the signing domain string + (`peerspeak-gossip-v`, bound into every signed payload) carries the version, + so two versions that somehow met on a topic would fail each other's verification + rather than misread it. + +Bumping the gossip version = bump `protocol::GOSSIP_PROTO` (drives +`versioned_topic`) **and** `protocol::GOSSIP_SIG_DOMAIN` together (a unit test in +`protocol.rs` asserts the domain string matches `GOSSIP_PROTO`, so they can't drift). + +### Single source of truth for protocol versions + +All protocol versions, ALPNs, the gossip signature domain, and `versioned_topic` +live in **`src/protocol.rs`**. Every call site derives from there (e.g. +`crate::protocol::AUDIO_ALPN`); **never hand-write an ALPN literal inline.** A +unit test asserts each ALPN/domain string matches its integer version so a bump +can't half-apply. + +--- + +## "I changed X — what do I bump?" (quick reference) + +| You changed… | Layer 2 (plane version) | Layer 1 (`Cargo.toml`) | +| --- | --- | --- | +| Opus framing / audio datagram layout | `peerspeak/audio/N` → `N+1` | MINOR | +| `ControlMsg` / presence shape | `peerspeak/friends/N` → `N+1` | MINOR | +| `GossipPayload`/`PeerState`/signing | `GOSSIP_PROTO_VERSION` + sig domain → next | MINOR | +| UI, local config, recording, a fix that doesn't touch any wire | nothing | PATCH | +| An *additive* gossip field that old peers safely ignore | judgement call — if old peers misbehave without it, treat as breaking (MINOR + gossip bump); if truly ignorable, PATCH | PATCH or MINOR | + +When in doubt about "is this additive-safe?", assume **breaking** and bump. A +false MINOR bump costs a coordinated rebuild; a false PATCH costs silent broken +calls in the field. + +--- + +## Release checklist (per build handed to anyone) + +1. Decide MINOR vs PATCH from the table above; bump `Cargo.toml`. +2. If MINOR for a wire reason, confirm the matching Layer-2 plane version(s) were + bumped in the same change. +3. Note the version + "breaking?" in the commit / handoff. +4. Tag the commit (`v0.x.y`) so a given binary maps to a known commit. +5. Rebuild **every** peer that must interoperate (e.g. dopedart, staged friend + releases) when the bump was a MINOR/wire break. + +--- + +## Current baseline (standard adopted + migrated, 2026-06-18, `0.2.0`) + +- `Cargo.toml`: **`0.2.0`** — the MINOR bump for the (deliberately breaking) + migration to this standard. **All peers must run ≥ `0.2.0` to interoperate** + (the ALPNs and gossip topics changed); the pre-standard `0.1.0`-era build + (e.g. an un-resynced dopedart) cannot talk to a `0.2.0` peer — by design, and it + now fails cleanly at the handshake instead of silently. +- Protocol versions (all at `1`): `peerspeak/audio/1`, `peerspeak/friends/1`, + gossip `peerspeak-gossip-v1` + `versioned_topic`. All sourced from + `src/protocol.rs`. +- **Remaining nicety (not blocking):** surface `env!("CARGO_PKG_VERSION")` in the + UI (an About/Settings line) and/or log it at startup, so a running build is + self-identifying in the field. Small follow-up. diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..1aaef9f --- /dev/null +++ b/deny.toml @@ -0,0 +1,88 @@ +# cargo-deny policy for peerspeak +# +# Supersedes a bare `cargo audit` run. Enforce with: +# cargo install cargo-deny --locked +# cargo deny check +# +# In CI, run `cargo deny check` on a locked tree so the pinned, vetted +# versions in Cargo.lock are what actually get audited. + +# --------------------------------------------------------------------------- +# Advisories: RustSec database. Vulnerabilities and yanked crates are denied +# by default. The two `ignore` entries below are *unmaintained* warnings only +# (no known exploit); they are deep transitive deps we cannot remove. Pinning +# them via Cargo.lock is our real protection — a future malicious release does +# not reach us until we deliberately `cargo update`, so each update is a review +# checkpoint. Revisit these if either advisory is upgraded to a vulnerability. +# --------------------------------------------------------------------------- +[advisories] +ignore = [ + # paste: unmaintained, compile-time proc-macro only (zero runtime surface), + # transitive via iroh/netdev/netlink and rav1e/image/iced. Maintained fork + # `pastey` is already in the tree; stragglers will follow upstream. + "RUSTSEC-2024-0436", + # audiopus_sys: unmaintained FFI bindings to the stable libopus C library, + # pulled in via our direct `opus 0.3.1` dep. No drop-in replacement. + "RUSTSEC-2026-0150", +] + +# --------------------------------------------------------------------------- +# Bans: shape of the dependency graph. +# --------------------------------------------------------------------------- +[bans] +# Multiple versions of the same crate bloat the build; warn rather than fail +# since transitive graphs (iroh, iced) routinely carry duplicates we can't fix. +multiple-versions = "warn" +# Wildcard ("*") version requirements are a supply-chain footgun: they accept +# any future release, defeating the lockfile-as-review-checkpoint model. +wildcards = "deny" +# ...but our own intra-repo path deps may use "*"; don't penalize those. +allow-wildcard-paths = true + +# Crates that may never appear in the graph. Add a maintained replacement's +# predecessor here once you've migrated off it, to prevent regressions. +deny = [] + +# --------------------------------------------------------------------------- +# Sources: where crates are allowed to come from. This is the core anti-hijack +# control — only the official crates.io registry is trusted; arbitrary git +# sources (a common vector for slipping in unaudited code) are rejected. +# --------------------------------------------------------------------------- +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +# allow-git = [] # add a specific, pinned git repo here only if ever needed + +# --------------------------------------------------------------------------- +# Licenses: permissive set covering the current graph. If `cargo deny check` +# reports an unmatched license, vet it and add the SPDX id here (or add a +# per-crate entry under [licenses.exceptions]) rather than widening blindly. +# --------------------------------------------------------------------------- +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Zlib", + "MPL-2.0", + "Unicode-3.0", + "Unicode-DFS-2016", + "CC0-1.0", + "0BSD", + "Unlicense", + "BSL-1.0", + "NCSA", # University of Illinois/NCSA — BSD-like permissive + "CDLA-Permissive-2.0", # Community Data License Agreement, permissive +] +confidence-threshold = 0.8 +exceptions = [] + +# peerspeak itself has no `license` field and is not published, so skip the +# "unlicensed" check for our own (private) crate. Add a license to Cargo.toml +# if/when this is ever published. +[licenses.private] +ignore = true diff --git a/src/core/mod.rs b/src/core/mod.rs index c2029ba..2a6b58d 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -584,7 +584,7 @@ async fn build_net_stack( // report) is injected via `friends_handler`. let router = Router::builder(endpoint.clone()) .accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone()) - .accept(b"peerspeak-audio", audio_router.clone()) + .accept(crate::protocol::AUDIO_ALPN, audio_router.clone()) .accept( crate::presence_net::FRIENDS_ALPN, crate::presence_net::FriendsProtocol::new(friends_handler), diff --git a/src/lib.rs b/src/lib.rs index 64474ec..e6369ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod audio; pub mod codec; pub mod dsp; pub mod network; +pub mod protocol; pub mod core; pub mod app; pub mod config; diff --git a/src/network/gossip.rs b/src/network/gossip.rs index 6811970..23f0e8a 100644 --- a/src/network/gossip.rs +++ b/src/network/gossip.rs @@ -12,7 +12,7 @@ use serde::{Serialize, Deserialize}; /// Domain-separation tag mixed into every signed gossip payload so a signature /// can never be lifted out of this protocol/version into another context. -const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1"; +use crate::protocol::GOSSIP_SIG_DOMAIN; /// How far a payload's sender-stamped timestamp may differ from local time /// before it's rejected as stale (replayed) or implausibly future. Bounds the @@ -248,7 +248,11 @@ impl RoomState for IrohGossipState { crate::redact_for_log(ticket_str) )); let ticket = ticket_str.parse::()?; - let topic_id = TopicId::from_bytes(ticket.topic_id); + // Version-namespace the subscribed topic (VERSIONING.md): peers on a + // different gossip protocol version derive a different topic from the same + // ticket and never share a swarm. The raw ticket.topic_id stays the room + // identity (and what signatures bind, below). + let topic_id = TopicId::from_bytes(crate::protocol::versioned_topic(ticket.topic_id)); crate::log_msg(&format!( "Parsed ticket. host_id={}, host_addrs={}, topic={}", diff --git a/src/network/iroh_impl.rs b/src/network/iroh_impl.rs index 2267207..80dc871 100644 --- a/src/network/iroh_impl.rs +++ b/src/network/iroh_impl.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet}; use std::time::Duration; use async_trait::async_trait; -const AUDIO_ALPN: &[u8] = b"peerspeak-audio"; +use crate::protocol::AUDIO_ALPN; /// Per-peer datagram send queue depth. Audio is real-time, so a backlog is /// useless latency — keep it shallow and drop the oldest frame when full. diff --git a/src/presence_net.rs b/src/presence_net.rs index c30f8d8..72160de 100644 --- a/src/presence_net.rs +++ b/src/presence_net.rs @@ -31,7 +31,7 @@ use std::time::Duration; /// ALPN for the friends presence/control plane. Separate from the audio/gossip /// ALPNs so a control dial never lands on a bare room endpoint and vice versa. -pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/0"; +pub const FRIENDS_ALPN: &[u8] = crate::protocol::FRIENDS_ALPN; /// Upper bound on a single control message — generous for a Pong carrying a /// member ticket (~300 chars), but rejects a peer trying to make us buffer a diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..c229de1 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,82 @@ +//! Single source of truth for PeerSpeak's on-wire protocol versions and the +//! per-plane ALPNs / gossip constants derived from them. +//! +//! See `VERSIONING.md`. The rule: each transport plane is versioned independently +//! (audio rarely changes, gossip changes often), and incompatible peers must fail +//! fast — never as a silent decode/signature error. iroh refuses a mismatched +//! ALPN at the QUIC handshake, so the audio/friends planes are self-isolating; +//! gossip can't use a custom ALPN (it rides iroh-gossip's `GOSSIP_ALPN`), so its +//! version is bound into the subscribed topic ([`versioned_topic`]) and the +//! signature domain ([`GOSSIP_SIG_DOMAIN`]). +//! +//! **Never hand-write an ALPN literal elsewhere — derive it here.** Bumping a +//! plane's protocol version is a breaking wire change → also bump `Cargo.toml` +//! MINOR (see `VERSIONING.md`). + +/// Audio datagram plane version (Opus framing / sequencing). Bump on any audio +/// wire change. Mirrored in [`AUDIO_ALPN`]. +pub const AUDIO_PROTO: u32 = 1; +/// Friends/presence plane version (`ControlMsg` ping-pong shape). Bump on any +/// change. Mirrored in [`FRIENDS_ALPN`]. +pub const FRIENDS_PROTO: u32 = 1; +/// Gossip plane version (`GossipPayload`/`GossipMessage`/`PeerState`, signing, +/// freshness). Bump on any change. Mirrored in [`GOSSIP_SIG_DOMAIN`] and folded +/// into [`versioned_topic`]. +pub const GOSSIP_PROTO: u32 = 1; + +/// ALPN for the audio datagram plane: `peerspeak/audio/`. +pub const AUDIO_ALPN: &[u8] = b"peerspeak/audio/1"; +/// ALPN for the friends/presence plane: `peerspeak/friends/`. +pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/1"; +/// ed25519 gossip signature domain: `peerspeak-gossip-v`. Carries +/// the gossip protocol version into every signed payload — a version mismatch +/// fails verification (cryptographic separation between gossip versions). +pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1"; + +/// Version-namespace a room topic so peers on different gossip protocol versions +/// derive **different subscription topics from the same ticket** and therefore +/// never share a swarm — the gossip analog of a versioned ALPN. The room's raw +/// `topic_id` (random 32 bytes, carried in the ticket) is the room identity and +/// is unchanged; only the *subscribed* topic is namespaced. +/// +/// Deterministic and dependency-free; bijective for a fixed version, so distinct +/// rooms stay distinct after namespacing. This transform is for *isolation*, not +/// security — cryptographic separation between versions comes from +/// [`GOSSIP_SIG_DOMAIN`]. +pub fn versioned_topic(topic_id: [u8; 32]) -> [u8; 32] { + let v = GOSSIP_PROTO.to_le_bytes(); + let mut out = topic_id; + for (i, b) in out.iter_mut().enumerate() { + *b ^= v[i % v.len()]; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The ALPN/domain strings must stay in lock-step with the integer versions + /// so a version bump can't silently forget to update the wire string. + #[test] + fn alpns_match_their_proto_versions() { + assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes()); + assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes()); + assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}")); + } + + #[test] + fn versioned_topic_is_deterministic_and_room_distinct() { + let a = [9u8; 32]; + let mut b = a; + b[5] = 10; + assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic"); + assert_ne!(versioned_topic(a), versioned_topic(b), "distinct rooms stay distinct"); + } + + #[test] + fn versioned_topic_actually_namespaces_for_current_version() { + // Guards against a no-op transform: GOSSIP_PROTO=1 must change the topic. + assert_ne!(versioned_topic([0u8; 32]), [0u8; 32]); + } +}