//! 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 ` 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. pub const GOSSIP_PROTO: u32 = 3; /// 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/`. 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"; /// ALPN for the file-transfer plane: `peerspeak/files/`. 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`. 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-v3"; /// 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]); } }