Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff7766ede | ||
|
|
10ee765ffd | ||
|
|
3034c42f71 | ||
|
|
465c7ba2b0 | ||
|
|
3ec09de87e | ||
|
|
4b8fb92dc5 |
Generated
+1
-1
@@ -4594,7 +4594,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+4
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,112 @@
|
||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||
#
|
||||
# Test-pack split package: ONE `makepkg -si` builds + installs BOTH peerspeak
|
||||
# (voice chat) and pixelpass (screen sharing) from the public gitbutter repos
|
||||
# over https. pixelpass lands on /usr/bin so peerspeak's screen-share button
|
||||
# finds it. Shared version string is derived from peerspeak's git.
|
||||
#
|
||||
# Clone this repo and build from here:
|
||||
# git clone https://gitbutter.xyz/mollusk/peerspeak.git
|
||||
# cd peerspeak/packaging/test-pack
|
||||
# makepkg -si
|
||||
pkgbase=peerspeak-git
|
||||
pkgname=('peerspeak-git' 'pixelpass')
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
arch=('x86_64')
|
||||
url="https://gitbutter.xyz/mollusk/peerspeak"
|
||||
license=('custom' 'MIT' 'Apache-2.0' 'OFL-1.1')
|
||||
makedepends=('git' 'cargo' 'pkgconf')
|
||||
options=('!lto' '!debug')
|
||||
source=("peerspeak::git+https://gitbutter.xyz/mollusk/peerspeak.git"
|
||||
"pixelpass::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=main")
|
||||
sha256sums=('SKIP'
|
||||
'SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$srcdir/peerspeak"
|
||||
# Shared across both split packages. 0.1.0.r<commits>.g<short-sha>.
|
||||
printf '%s.r%s.g%s' \
|
||||
"$(awk -F'\"' '/^version =/{print $2; exit}' Cargo.toml)" \
|
||||
"$(git rev-list --count HEAD)" \
|
||||
"$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
prepare() {
|
||||
# Vendor deps up front so build() can run --frozen (no surprise network).
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
local host; host="$(rustc -vV | sed -n 's/host: //p')"
|
||||
cd "$srcdir/peerspeak"; cargo fetch --locked --target "$host"
|
||||
cd "$srcdir/pixelpass"; cargo fetch --locked --target "$host"
|
||||
}
|
||||
|
||||
build() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
|
||||
cd "$srcdir/peerspeak"
|
||||
cargo build --frozen --release --bin peerspeak
|
||||
|
||||
cd "$srcdir/pixelpass"
|
||||
# --features gui so the .desktop launcher (pixelpass --gui) works.
|
||||
cargo build --frozen --release --features gui
|
||||
}
|
||||
|
||||
check() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
# peerspeak library unit tests only — its integration suites bind real
|
||||
# iroh/QUIC endpoints and fail in a sandboxed/offline build environment.
|
||||
cd "$srcdir/peerspeak"
|
||||
cargo test --frozen --release --lib
|
||||
}
|
||||
|
||||
package_peerspeak-git() {
|
||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
depends=('pipewire' 'opus')
|
||||
optdepends=('pixelpass: screen sharing inside a room'
|
||||
'mpv: screen-share viewer (vlc is used as a fallback)')
|
||||
provides=('peerspeak')
|
||||
conflicts=('peerspeak')
|
||||
license=('custom')
|
||||
|
||||
cd "$srcdir/peerspeak"
|
||||
install -Dm755 "target/release/peerspeak" "$pkgdir/usr/bin/peerspeak"
|
||||
install -Dm644 "packaging/peerspeak.desktop" \
|
||||
"$pkgdir/usr/share/applications/peerspeak.desktop"
|
||||
|
||||
# Hicolor icon theme (scalable SVG + the rendered raster sizes).
|
||||
install -Dm644 "assets/icons/peerspeak.svg" \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/peerspeak.svg"
|
||||
local s
|
||||
for s in 16 24 32 48 64 128 256 512; do
|
||||
install -Dm644 "assets/icons/peerspeak-$s.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/peerspeak.png"
|
||||
done
|
||||
}
|
||||
|
||||
package_pixelpass() {
|
||||
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
|
||||
depends=('gstreamer' 'gst-plugins-base' 'gst-plugins-good' 'gst-plugins-bad'
|
||||
'gst-libav' 'gst-plugin-va' 'libpulse' 'hicolor-icon-theme'
|
||||
'libglvnd' 'libxkbcommon' 'wayland')
|
||||
optdepends=('mpv: recommended stream viewer (the GUI launches mpv)'
|
||||
'vlc: alternative stream viewer'
|
||||
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
|
||||
'gst-plugin-pipewire: screen capture on Wayland sessions'
|
||||
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)')
|
||||
license=('MIT' 'Apache-2.0' 'OFL-1.1')
|
||||
|
||||
cd "$srcdir/pixelpass"
|
||||
install -Dm0755 "target/release/pixelpass" "$pkgdir/usr/bin/pixelpass"
|
||||
install -Dm0644 assets/pixelpass.desktop \
|
||||
"$pkgdir/usr/share/applications/pixelpass.desktop"
|
||||
install -Dm0644 assets/pixelpass.svg \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/pixelpass.svg"
|
||||
install -Dm0644 README.md "$pkgdir/usr/share/doc/pixelpass/README.md"
|
||||
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/pixelpass/LICENSE-MIT"
|
||||
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/pixelpass/LICENSE-APACHE"
|
||||
install -Dm0644 assets/NotoSans-OFL.txt \
|
||||
"$pkgdir/usr/share/licenses/pixelpass/NotoSans-OFL.txt"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# PeerSpeak + PixelPass — CachyOS/Arch test pack
|
||||
|
||||
A single **split PKGBUILD** that builds the latest code from the public gitbutter
|
||||
repos and installs **both** programs at once:
|
||||
|
||||
- `peerspeak` — decentralized P2P voice chat
|
||||
- `pixelpass` — P2P screen sharing (peerspeak launches it for the screen-share button)
|
||||
|
||||
## Build & install (one command)
|
||||
|
||||
```sh
|
||||
git clone https://gitbutter.xyz/mollusk/peerspeak.git
|
||||
cd peerspeak/packaging/test-pack
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
`makepkg -si` auto-installs every dependency via pacman before building —
|
||||
including the Rust toolchain itself (the `cargo` makedepend is provided by the
|
||||
`rust` package), `git`, `pkgconf`, pipewire + opus for peerspeak, and the
|
||||
gstreamer/VA-API stack for pixelpass. The only prerequisite is the `base-devel`
|
||||
group (which provides `makepkg`). If you already use `rustup`, that satisfies the
|
||||
`cargo` makedepend and the `rust` package won't be pulled in — no conflict.
|
||||
|
||||
When it finishes you'll have `peerspeak` and `pixelpass` on your PATH at
|
||||
`/usr/bin`. To rebuild later with fresh upstream code, re-run `makepkg -si`; the
|
||||
git sources re-pull `main` and the version bumps automatically.
|
||||
|
||||
> Skip the test step with `makepkg -si --nocheck` for a faster build.
|
||||
|
||||
## Running the cross-internet test
|
||||
|
||||
1. Launch `peerspeak` on both machines.
|
||||
2. One person **creates** a room and shares the room code/ticket with the other.
|
||||
3. The other **joins** with that code.
|
||||
4. iroh does NAT hole-punching automatically; if a direct path can't be made it
|
||||
falls back to a public n0 relay — **no port forwarding required**.
|
||||
5. Allow the app through any local firewall if prompted (outbound UDP / QUIC;
|
||||
nothing needs to be opened inbound for relay mode).
|
||||
|
||||
### What we're smoke-testing
|
||||
- Two real humans, two networks, over the internet.
|
||||
- Mic capture + remote playback both directions, no crackle/dropouts.
|
||||
- Mute / deafen, push-to-talk.
|
||||
- Text chat in-room.
|
||||
- Avatars (presets + custom upload) show up on the other side.
|
||||
- Screen share: click the screen-share control → it launches `pixelpass`; the
|
||||
viewer opens in `mpv` on the receiving side.
|
||||
- Notification chimes (join/leave/etc.).
|
||||
- Leave / rejoin cleanly.
|
||||
|
||||
If anything misbehaves, grab the log path peerspeak prints on startup and the
|
||||
exact repro steps.
|
||||
+1
-1
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<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!(
|
||||
"Parsed ticket. host_id={}, host_addrs={}, topic={}",
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user