Files
peerspeak/src/protocol.rs
T
molluskandClaude Opus 4.8 6f14d2668d
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
docs(protocol): correct GOSSIP_PROTO version mapping (v5 = 0.6.0, not 0.7.0)
The const comment claimed "v5 (0.7.0)" while GOSSIP_PROTO has been 5 since the
v0.6.0 tag (introduced by bca2ccd, "release 0.6.0"). Git confirms the value went
straight 3 -> 5 in that one release and a GOSSIP_PROTO == 4 build never existed.
Merge the two mislabeled v4/v5 bullets into one accurate v4-v5 (0.6.0) entry and
note the 3->5 jump + that this breaking gossip change correctly rode the
0.5.1 -> 0.6.0 MINOR bump per VERSIONING.md (0.6.1 is a wire-compatible PATCH,
still proto 5). Comment-only; no wire/behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:24:53 -04:00

127 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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`].
///
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
/// to fail fast rather than half-work.
///
/// v3 (0.4.0): `PeerState` gained an optional `game` presence field (the
/// `Playing <name>` status). The field is `#[serde(default)]`, so the bump isn't
/// strictly required for decoding — but per the versioning discipline a wire-shape
/// change is isolated into its own topic + signature domain so v2 and v3 peers
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
///
/// v4v5 (0.6.0): the W22 shared-listening / music presence work. `PeerState`
/// gained an optional `music` presence field (a current shared-listening track
/// descriptor + playback timeline; the audio bytes still ride the files plane by
/// id, gossip carries only the descriptor/timeline), and `MusicPresence` then
/// gained optional prefetch hints for the next track so tuned-in listeners can
/// fetch it before the DJ advances. Both shipped together in the **0.6.0** release
/// (commit `bca2ccd`), where the const advanced straight `3 → 5`: there was never
/// a `GOSSIP_PROTO == 4` build — 4 is a skipped step. (Per `VERSIONING.md` this
/// breaking gossip change rode the `0.5.1 → 0.6.0` MINOR bump, so the discipline
/// was honoured; 0.6.1 is a wire-compatible PATCH on top, still proto 5.)
pub const GOSSIP_PROTO: u32 = 5;
/// File-transfer plane version (chat attachment request/stream shape). Bump on
/// any change. Mirrored in [`FILES_ALPN`].
pub const FILES_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";
/// ALPN for the file-transfer plane: `peerspeak/files/<FILES_PROTO>`. Carries
/// chat attachment bytes via direct QUIC streams (not gossip).
pub const FILES_ALPN: &[u8] = b"peerspeak/files/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-v5";
/// 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!(
FILES_ALPN,
format!("peerspeak/files/{FILES_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]);
}
}