Compare commits

...
Author SHA1 Message Date
molluskandClaude Opus 4.8 85b12a26c9 Windows port Phase 0: platform-select the audio backend
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Make the tree compile for Windows without touching core logic, by
confining all Linux/PipeWire assumptions behind cfg gates and a single
platform-selected backend alias. No new dependencies — the cpal/WASAPI
backend lands in Phase 1; this ships a no-op stub.

- Cargo.toml: move pipewire + rfd(xdg-portal) under cfg(unix); add a
  cfg(windows) rfd using the Win32 dialog backend.
- audio: gate pipewire_impl to unix, add a cpal_impl stub for windows,
  and select between them via the new PlatformAudioBackend alias.
- core: use PlatformAudioBackend instead of the concrete PipeWireBackend.
- lib: gate the unix-only 0o600 log-file mode code (+ its test); Windows
  logs inherit the directory ACL.
- audio_probe: gate this PipeWire diagnostic to unix with a stub main.
- app: open URLs via rundll32 on windows, xdg-open on unix (shell-free).
- ci: add .gitea/workflows/windows-build.yml (M1) — build + lib tests for
  x86_64-pc-windows-msvc, with CMAKE_POLICY_VERSION_MINIMUM=3.5 for the
  vendored libopus build. Needs a windows act_runner to actually run.

Linux build/clippy/tests green (316/316). The Windows path is verified by
inspection only (no local Windows toolchain); CI is the real gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:47:13 -04:00
molluskandClaude Opus 4.8 e4767be210 ci: enforce cargo-deny supply-chain policy on push and PRs
Adds a Gitea Actions workflow that runs `cargo deny --locked check` on
every push to main and every PR, so the deny.toml policy (advisories,
bans, licenses, sources) is enforced automatically rather than by hand.

Runs on a locked tree so the pinned versions in Cargo.lock are what get
audited; a poisoned dependency release can't reach CI until Cargo.lock is
deliberately updated. cargo-deny is pinned to 0.19.9 via a prebuilt binary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:00:36 -04:00
mollusk 10ee765ffd Merge pull request 'Security scan' (#1) from security-scan into main
Reviewed-on: #1
2026-06-18 19:57:56 +00:00
molluskandClaude Opus 4.8 3034c42f71 Add security review report for security-scan branch
Documents the focused security review of the protocol-versioning migration
and the cargo-deny policy addition. Result: no high-confidence vulnerabilities
— the versioned_topic XOR transform is entropy-preserving, signature binding
uses the raw topic_id consistently, and the ALPN/domain changes are
handshake-level compatibility only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 15:54:35 -04:00
molluskandClaude Opus 4.8 465c7ba2b0 Add cargo-deny supply-chain policy (deny.toml)
Supersede bare cargo-audit with an enforceable four-part policy, validated
against the current tree with cargo-deny 0.19.9 (advisories/bans/licenses/
sources all pass):

- advisories: deny vulnerabilities + yanked; ignore the two *unmaintained*
  warnings (paste RUSTSEC-2024-0436, audiopus_sys RUSTSEC-2026-0150) with
  rationale. Both are transitive and pinned via Cargo.lock, so a future
  malicious release can't reach us until a deliberate cargo update.
- sources: trust only crates.io; deny unknown registries and git sources
  (core anti-hijack control).
- bans: deny wildcard version reqs; warn on duplicate versions.
- licenses: permissive allow-list covering the current graph.

Mark peerspeak publish = false (it's an application, not a published
library): blocks accidental cargo publish and lets [licenses.private]
skip the missing-license check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 15:46:52 -04:00
molluskandClaude Opus 4.8 3ec09de87e Adopt versioning standard + migrate to versioned protocol planes (0.2.0)
Establishes VERSIONING.md: SemVer 0.x (MINOR = breaking wire change) for the
release version, and per-plane protocol versions enforced on the wire so
incompatible peers fail fast and legibly instead of via silent decode/signature
errors.

⚠️ BREAKING WIRE CHANGE — all peers must run >= 0.2.0 to interoperate (ALPNs and
gossip subscription topics changed). A pre-0.2.0 peer (e.g. an un-resynced
dopedart) can no longer connect, by design, and now fails at the handshake.

- New src/protocol.rs: single source of truth for AUDIO/FRIENDS/GOSSIP_PROTO,
  the derived ALPNs (peerspeak/audio/1, peerspeak/friends/1), GOSSIP_SIG_DOMAIN,
  and versioned_topic(). Unit tests assert ALPN/domain strings match their
  integer versions (no silent drift) + that topic namespacing is deterministic.
- Unified ALPNs: audio was b"peerspeak-audio" (unversioned, and duplicated in
  iroh_impl.rs + core/mod.rs) -> peerspeak/audio/1 from protocol.rs; friends
  re-exports protocol::FRIENDS_ALPN (was peerspeak/friends/0 -> /1).
- Gossip: subscribe to versioned_topic(ticket.topic_id) so different gossip
  versions never share a swarm; the raw topic_id stays the room identity and
  what signatures bind. GOSSIP_SIG_DOMAIN centralized into protocol.rs.
- Cargo.toml 0.1.0 -> 0.2.0.

316 lib tests / clippy --all-targets clean. VERSIONING.md documents the bump
rules, the "I changed X -> what do I bump" table, and a release checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 05:20:27 -04:00
mollusk 4b8fb92dc5 Merge codex-security-s8-audio-membership: gate inbound audio by live room membership (S8) 2026-06-18 04:41:41 -04:00
17 changed files with 763 additions and 127 deletions
+34
View File
@@ -0,0 +1,34 @@
name: cargo-deny
# Enforce the supply-chain policy in deny.toml (advisories / bans / licenses /
# sources) on every push to main and every PR. Runs on a *locked* tree so the
# pinned, vetted versions in Cargo.lock are exactly what get audited — see the
# deny.toml header and VERSIONING.md. A new poisoned release of a dependency
# cannot reach CI until Cargo.lock is deliberately updated.
on:
push:
branches: [main]
pull_request:
jobs:
cargo-deny:
runs-on: ubuntu-latest
# rust:1 provides the cargo toolchain that cargo-deny shells out to for
# `cargo metadata`. Adjust the runner label if your act_runner uses a
# different one.
container: rust:1
steps:
- uses: actions/checkout@v4
- name: Install cargo-deny (pinned prebuilt)
run: |
set -euo pipefail
version=0.19.9
curl -sSfL \
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${version}/cargo-deny-${version}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz -C /usr/local/bin --strip-components=1 --wildcards '*/cargo-deny'
cargo-deny --version
- name: cargo deny check
run: cargo deny --locked check
+85
View File
@@ -0,0 +1,85 @@
name: windows-build
# Milestone M1 of the Windows port (see docs/handoff windows-migration-plan):
# prove the tree compiles for `x86_64-pc-windows-msvc` and the unit tests pass.
# The audio backend is the Phase 0 `CpalBackend` stub for now — this job guards
# the *compile* boundary (cfg gating, platform deps, the PlatformAudioBackend
# alias) so a Unix-only assumption can't sneak back in and break Windows.
#
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
# `windows-latest` label (the Linux `cargo-deny` job's container approach does
# NOT apply here — Windows jobs run on the host, not a Linux container). If your
# runner advertises a different label, change `runs-on` below. Until a Windows
# runner exists this workflow is simply skipped/queued, not a failure of the
# Linux CI.
#
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
# peerspeak-windows-opus-spike.md):
# - MSVC C toolchain (Visual Studio Build Tools) — to compile vendored libopus.
# - CMake on PATH — `audiopus_sys` builds libopus from source via cmake.
# - CMAKE_POLICY_VERSION_MINIMUM=3.5 (set below) — the vendored libopus declares
# an ancient `cmake_minimum_required` that CMake >= 4.0 refuses without it.
# GitHub-hosted `windows-latest` images ship MSVC + CMake; a self-hosted runner
# must provide both.
on:
push:
# `main` plus the in-progress port branches, so the Windows path is exercised
# before merge rather than only after.
branches: [main, "windows-port-**"]
pull_request:
# Allow manual runs from the Gitea Actions UI.
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# The vendored libopus (audiopus_sys -> cmake) uses cmake_minimum_required < 3.5,
# which CMake 4.x rejects unless this is set. See the opus spike report.
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
jobs:
windows-build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust (MSVC, pinned to repo toolchain if present)
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
components: clippy
- name: Show toolchain + build prerequisites
shell: bash
run: |
set -euo pipefail
rustc --version
cargo --version
# libopus is built from source via cmake; fail early with a clear
# message if the runner lacks it rather than deep in the opus build.
if ! command -v cmake >/dev/null 2>&1; then
echo "::error::cmake not found on PATH. The opus crate builds libopus from source via cmake; install CMake on this runner."
exit 1
fi
cmake --version
# Build on a *locked* tree so the pinned, vetted Cargo.lock versions are what
# get compiled — same supply-chain stance as the cargo-deny job.
- name: Build (all targets, msvc)
run: cargo build --all-targets --locked --target x86_64-pc-windows-msvc
# Unit (lib) tests only: the `transport_loopback` integration tests stand up
# real iroh/QUIC endpoints and need working loopback networking, which isn't
# guaranteed on a CI runner. Add `--tests` here once a networked Windows
# runner is confirmed.
- name: Unit tests (lib, msvc)
run: cargo test --lib --locked --target x86_64-pc-windows-msvc
# Informational for now (not `-D warnings`): the Windows tree may surface
# platform-specific lints we haven't triaged. Tighten to deny-warnings once
# it's clean.
- name: Clippy (msvc)
run: cargo clippy --all-targets --locked --target x86_64-pc-windows-msvc
Generated
+1 -1
View File
@@ -4594,7 +4594,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "peerspeak" name = "peerspeak"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+27 -8
View File
@@ -1,7 +1,10 @@
[package] [package]
name = "peerspeak" name = "peerspeak"
version = "0.1.0" version = "0.2.0"
edition = "2024" 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] [lib]
name = "peerspeak" name = "peerspeak"
@@ -27,17 +30,12 @@ bytes = "1.11.1"
dirs = "6.0.0" dirs = "6.0.0"
iced = { version = "0.14.0", features = ["canvas", "image"] } iced = { version = "0.14.0", features = ["canvas", "image"] }
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep # W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
# the codec surface small) and a native file picker (xdg-portal backend, no GTK). # the codec surface small). The matching native file picker (`rfd`) is platform-
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
iroh = "1.0.0-rc.0" iroh = "1.0.0-rc.0"
iroh-gossip = "0.99.0" iroh-gossip = "0.99.0"
opus = "0.3.1" opus = "0.3.1"
# v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle quantum), used by
# the playback RT callback to fill exactly what the device asks for instead of
# pinning the buffer to a hard-coded 1024-frame quantum (crackle on non-1024
# hardware). The field has existed in libpipewire since 0.3.49 (2022).
pipewire = { version = "0.9", features = ["v0_3_49"] }
rand = "0.10.1" rand = "0.10.1"
ringbuf = "0.5.0" ringbuf = "0.5.0"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
@@ -45,3 +43,24 @@ serde_json = "1.0.150"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] } tokio = { version = "1.52.3", features = ["full"] }
tokio-stream = "0.1.18" tokio-stream = "0.1.18"
# --- Platform-specific dependencies -----------------------------------------
# Audio and the native file-picker backends differ per OS. Everything else in the
# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see
# `src/audio/mod.rs`), so platform selection is confined to these few lines.
[target.'cfg(unix)'.dependencies]
# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle
# quantum), used by the playback RT callback to fill exactly what the device asks
# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware).
# The field has existed in libpipewire since 0.3.49 (2022).
pipewire = { version = "0.9", features = ["v0_3_49"] }
# Native file picker via the XDG desktop portal (no GTK) on Linux.
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
[target.'cfg(windows)'.dependencies]
# Native file picker using the built-in Win32 dialog backend on Windows.
rfd = { version = "0.17", default-features = false }
# NOTE: the Windows audio backend (cpal/WASAPI) lands in Phase 1. Until then the
# Windows build uses the no-op `CpalBackend` stub in `src/audio/cpal_impl.rs`,
# which needs no extra dependency.
+51
View File
@@ -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.
+139
View File
@@ -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/<plane>/<N>`** where `<N>` 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 `<N>` 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/<N>` | the Opus/datagram framing, sequencing, or audio-handshake changes |
| **Friends/presence** | ALPN `peerspeak/friends/<N>` | 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<N>`, 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.
+88
View File
@@ -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
+22 -6
View File
@@ -1350,12 +1350,28 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Defence in depth: only ever hand http(s) URLs to the opener. The // Defence in depth: only ever hand http(s) URLs to the opener. The
// link span's href came from `linkify`, which only emits http/https, // link span's href came from `linkify`, which only emits http/https,
// but re-check here so this can't be widened into launching arbitrary // but re-check here so this can't be widened into launching arbitrary
// schemes/args. `xdg-open` receives the URL as a single argv entry // schemes/args. Each opener receives the URL as a single argv entry
// (no shell), so there's no injection surface. // (no shell), so there's no injection surface:
if (url.starts_with("http://") || url.starts_with("https://")) // - Unix: `xdg-open <url>`.
&& let Err(e) = std::process::Command::new("xdg-open").arg(&url).spawn() // - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the
{ // default browser without going through `cmd`/`start`, which would
crate::log_msg(&format!("Failed to open URL {url:?}: {e}")); // otherwise re-parse `&` in query strings.
if url.starts_with("http://") || url.starts_with("https://") {
let spawned = {
#[cfg(unix)]
{
std::process::Command::new("xdg-open").arg(&url).spawn()
}
#[cfg(windows)]
{
std::process::Command::new("rundll32")
.args(["url.dll,FileProtocolHandler", &url])
.spawn()
}
};
if let Err(e) = spawned {
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
}
} }
} }
AppMessage::ToggleMicTest(enabled) => { AppMessage::ToggleMicTest(enabled) => {
+67
View File
@@ -0,0 +1,67 @@
//! Windows audio backend (cpal/WASAPI) — **Phase 0 stub**.
//!
//! This is a compile-and-run placeholder so the Windows build links and the app
//! starts up (networking, UI, and text chat all functional) while the real
//! capture/playback implementation lands in Phase 1. Every method satisfies the
//! [`AudioBackend`] contract as a no-op: no microphone is captured and nothing is
//! played. It deliberately pulls in no extra dependency — `cpal` is added only
//! when the real implementation arrives.
//!
//! Phase 1 will replace this with cpal streams on the WASAPI host, mapping:
//! - `start_capture` → input stream, f32→i16, mono 48 kHz, into `tx`;
//! - `start_playback` → output stream draining a `ringbuf`, keeping `ring_fill`
//! updated so the existing hardware-clock pacing in the mixer keeps working;
//! - `stop` → drop the streams.
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc::{Receiver, Sender};
use super::{AudioBackend, AudioError};
/// No-op Windows audio backend (Phase 0). See module docs.
pub struct CpalBackend;
impl CpalBackend {
pub fn new() -> Self {
crate::log_msg("CpalBackend: Phase 0 stub active (no audio I/O yet)");
CpalBackend
}
}
impl Default for CpalBackend {
fn default() -> Self {
Self::new()
}
}
impl AudioBackend for CpalBackend {
fn start_capture(
&self,
_tx: Sender<Vec<i16>>,
_target_node: Option<String>,
) -> Result<(), AudioError> {
// No capture stream yet: dropping `_tx` simply means no samples are ever
// produced (silent mic), which is the intended Phase 0 behaviour.
crate::log_msg("CpalBackend::start_capture: not yet implemented (Phase 1) — capturing silence");
Ok(())
}
fn start_playback(
&self,
rx: Receiver<Vec<i16>>,
_target_node: Option<String>,
_ring_fill: Arc<AtomicUsize>,
) -> Result<(), AudioError> {
// Drain and discard incoming audio on a detached thread so the mixer's
// producer never blocks or sees a closed channel. This keeps the rest of
// the pipeline running normally while output is silent.
std::thread::spawn(move || while rx.recv().is_ok() {});
crate::log_msg("CpalBackend::start_playback: not yet implemented (Phase 1) — discarding output");
Ok(())
}
fn stop(&self) -> Result<(), AudioError> {
Ok(())
}
}
+18
View File
@@ -62,6 +62,24 @@ pub mod gate;
pub mod limiter; pub mod limiter;
pub mod multitrack; pub mod multitrack;
pub mod pan; pub mod pan;
#[cfg(unix)]
pub mod pipewire_impl; pub mod pipewire_impl;
#[cfg(windows)]
pub mod cpal_impl;
pub mod pw_cli; pub mod pw_cli;
pub mod recorder; pub mod recorder;
/// The audio backend implementation for the current platform.
///
/// The whole app constructs and threads this alias (via
/// `PlatformAudioBackend::new()`) rather than any concrete backend type, so
/// platform selection lives entirely here. Both implementations satisfy the
/// [`AudioBackend`] trait, which is the only interface the core talks to.
///
/// - Linux/Unix → PipeWire ([`pipewire_impl::PipeWireBackend`]).
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]); a no-op stub until the
/// Phase 1 capture/playback implementation lands.
#[cfg(unix)]
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
#[cfg(windows)]
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
+111 -95
View File
@@ -17,105 +17,121 @@
//! //!
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node] //! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
//! e.g. cargo run --release --bin audio_probe -- 440 30 //! e.g. cargo run --release --bin audio_probe -- 440 30
//!
//! This probe exercises the PipeWire backend directly, so it is a Unix-only tool.
//! On non-Unix targets `main` is a stub that explains the limitation.
use std::io::{BufRead, BufReader, Seek, SeekFrom}; #[cfg(unix)]
use std::sync::Arc; fn main() {
use std::sync::atomic::AtomicUsize; unix_probe::run();
use std::sync::mpsc;
use std::time::Duration;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::pipewire_impl::PipeWireBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
let backend = PipeWireBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the PipeWire hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
}
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample);
frame.push(sample);
n += 1;
}
if tx.send(frame).is_err() {
eprintln!("playback channel closed early");
break;
}
}
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
} }
/// Open the app log, seek to the end, and echo new lines (the `playout-health:` #[cfg(not(unix))]
/// reports) to stdout once they appear. fn main() {
fn spawn_log_tailer() { eprintln!("audio_probe is only supported on Unix builds (it drives the PipeWire backend directly).");
let path = peerspeak::log_file_path(); }
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it). #[cfg(unix)]
let file = loop { mod unix_probe {
if let Ok(f) = std::fs::File::open(&path) { use std::io::{BufRead, BufReader, Seek, SeekFrom};
break f; use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use std::time::Duration;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::pipewire_impl::PipeWireBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
#[tokio::main]
pub async fn run() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
let backend = PipeWireBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the PipeWire hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
} }
std::thread::sleep(Duration::from_millis(100)); let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
}; for _ in 0..FRAME_SAMPLES {
let mut reader = BufReader::new(file); let t = n as f32 / SAMPLE_RATE;
let _ = reader.seek(SeekFrom::End(0)); // 0.25 amplitude: clearly audible but not harsh.
loop { let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
let mut line = String::new(); // Stereo playback bus: duplicate the probe tone to L/R.
match reader.read_line(&mut line) { frame.push(sample);
Ok(0) => std::thread::sleep(Duration::from_millis(150)), frame.push(sample);
Ok(_) => { n += 1;
if line.contains("playout-health:") { }
print!("{line}"); if tx.send(frame).is_err() {
} eprintln!("playback channel closed early");
break;
}
}
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
}
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
/// reports) to stdout once they appear.
fn spawn_log_tailer() {
let path = peerspeak::log_file_path();
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it).
let file = loop {
if let Ok(f) = std::fs::File::open(&path) {
break f;
}
std::thread::sleep(Duration::from_millis(100));
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::End(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
Ok(_) => {
if line.contains("playout-health:") {
print!("{line}");
}
}
Err(_) => std::thread::sleep(Duration::from_millis(150)),
} }
Err(_) => std::thread::sleep(Duration::from_millis(150)),
} }
} });
}); }
} }
+5 -5
View File
@@ -1,7 +1,7 @@
pub mod messages; pub mod messages;
pub mod jitter; pub mod jitter;
use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend}; use crate::audio::{AudioBackend, PlatformAudioBackend};
use crate::audio::eq::{Eq, EqSettings}; use crate::audio::eq::{Eq, EqSettings};
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder}; use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES}; use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
@@ -237,7 +237,7 @@ fn run_mic_monitor(
/// Stops a standalone mic monitor if one is running. MUST NOT be called while a /// Stops a standalone mic monitor if one is running. MUST NOT be called while a
/// room session is active — `backend.stop()` would also tear down the call's /// room session is active — `backend.stop()` would also tear down the call's
/// capture/playback. Monitor and session are mutually exclusive by construction. /// capture/playback. Monitor and session are mutually exclusive by construction.
fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option<MicMonitor>) { fn stop_mic_monitor(backend: &PlatformAudioBackend, monitor: Option<MicMonitor>) {
if let Some(m) = monitor { if let Some(m) = monitor {
let _ = backend.stop(); let _ = backend.stop();
let _ = m.thread.join(); let _ = m.thread.join();
@@ -400,7 +400,7 @@ struct ActiveSession {
} }
impl ActiveSession { impl ActiveSession {
async fn shutdown(mut self, audio_backend: Arc<PipeWireBackend>) { async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
crate::log_msg("ActiveSession::shutdown started"); crate::log_msg("ActiveSession::shutdown started");
// Tear down any screen-share children first so the host stops streaming // Tear down any screen-share children first so the host stops streaming
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers // promptly (kill_on_drop is the backstop, but kill explicitly so viewers
@@ -584,7 +584,7 @@ async fn build_net_stack(
// report) is injected via `friends_handler`. // report) is injected via `friends_handler`.
let router = Router::builder(endpoint.clone()) let router = Router::builder(endpoint.clone())
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.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( .accept(
crate::presence_net::FRIENDS_ALPN, crate::presence_net::FRIENDS_ALPN,
crate::presence_net::FriendsProtocol::new(friends_handler), crate::presence_net::FriendsProtocol::new(friends_handler),
@@ -731,7 +731,7 @@ async fn run_core_loop(
let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> = let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> =
Arc::new(std::sync::Mutex::new(HashMap::new())); Arc::new(std::sync::Mutex::new(HashMap::new()));
let audio_backend = Arc::new(PipeWireBackend::new()); let audio_backend = Arc::new(PlatformAudioBackend::new());
let is_muted = Arc::new(AtomicBool::new(false)); let is_muted = Arc::new(AtomicBool::new(false));
let is_deafened = Arc::new(AtomicBool::new(false)); let is_deafened = Arc::new(AtomicBool::new(false));
+25 -8
View File
@@ -2,6 +2,7 @@ pub mod audio;
pub mod codec; pub mod codec;
pub mod dsp; pub mod dsp;
pub mod network; pub mod network;
pub mod protocol;
pub mod core; pub mod core;
pub mod app; pub mod app;
pub mod config; pub mod config;
@@ -23,6 +24,9 @@ use std::path::{Path, PathBuf};
use std::sync::OnceLock; use std::sync::OnceLock;
const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024; const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
// Owner-only log permissions are a Unix concept (mode bits); on Windows the log
// inherits the directory's default ACL. Only referenced under `cfg(unix)`.
#[cfg(unix)]
const LOG_MODE: u32 = 0o600; const LOG_MODE: u32 = 0o600;
/// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log` /// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log`
@@ -83,8 +87,6 @@ fn prepare_log_file(path: &Path) -> std::io::Result<File> {
} }
fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<File> { fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<File> {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
@@ -97,12 +99,23 @@ fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<F
} }
} }
let file = std::fs::OpenOptions::new() let mut opts = std::fs::OpenOptions::new();
.create(true) opts.create(true).append(true);
.append(true) // The log can carry capability-bearing values (redacted, but still): keep it
.mode(LOG_MODE) // owner-only on Unix via the open mode. Windows has no mode bits; it inherits
.open(path)?; // the directory ACL, so this hardening is Unix-only.
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE)); #[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(LOG_MODE);
}
let file = opts.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Re-assert the mode in case the file pre-existed with looser perms.
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
}
Ok(file) Ok(file)
} }
@@ -125,6 +138,7 @@ pub fn log_msg(msg: &str) {
mod tests { mod tests {
use super::*; use super::*;
use std::io::Write; use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
fn temp_log_dir() -> PathBuf { fn temp_log_dir() -> PathBuf {
@@ -144,6 +158,9 @@ mod tests {
assert_eq!(redact_for_log(" "), "<redacted:empty>"); assert_eq!(redact_for_log(" "), "<redacted:empty>");
} }
// Owner-only log perms are a Unix concept; on Windows the file inherits the
// directory ACL and there's no mode to assert.
#[cfg(unix)]
#[test] #[test]
fn log_file_is_created_private() { fn log_file_is_created_private() {
let dir = temp_log_dir(); let dir = temp_log_dir();
+6 -2
View File
@@ -12,7 +12,7 @@ use serde::{Serialize, Deserialize};
/// Domain-separation tag mixed into every signed gossip payload so a signature /// Domain-separation tag mixed into every signed gossip payload so a signature
/// can never be lifted out of this protocol/version into another context. /// 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 /// 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 /// 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) crate::redact_for_log(ticket_str)
)); ));
let ticket = ticket_str.parse::<PeerSpeakTicket>()?; let ticket = ticket_str.parse::<PeerSpeakTicket>()?;
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!( crate::log_msg(&format!(
"Parsed ticket. host_id={}, host_addrs={}, topic={}", "Parsed ticket. host_id={}, host_addrs={}, topic={}",
+1 -1
View File
@@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; 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 /// 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. /// useless latency — keep it shallow and drop the oldest frame when full.
+1 -1
View File
@@ -31,7 +31,7 @@ use std::time::Duration;
/// ALPN for the friends presence/control plane. Separate from the audio/gossip /// 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. /// 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 /// 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 /// member ticket (~300 chars), but rejects a peer trying to make us buffer a
+82
View File
@@ -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/<AUDIO_PROTO>`.
pub const AUDIO_ALPN: &[u8] = b"peerspeak/audio/1";
/// ALPN for the friends/presence plane: `peerspeak/friends/<FRIENDS_PROTO>`.
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/1";
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. 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]);
}
}