diff --git a/Cargo.lock b/Cargo.lock index dab07418c9..6131ef4407 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + [[package]] name = "ahash" version = "0.8.12" @@ -349,6 +361,7 @@ dependencies = [ "busbar-transport-stdio", "busbar-transport-tcp", "busbar-transport-tcp-plugin", + "busbar-transport-webrtc", "busbar-transport-ws", "busbar-voice", "bytes", @@ -1045,6 +1058,19 @@ dependencies = [ "busbar-transport-tcp", ] +[[package]] +name = "busbar-transport-webrtc" +version = "1.6.0" +dependencies = [ + "aes", + "busbar-contract", + "busbar-plugin-loader", + "busbar-transport-webrtc", + "ring", + "str0m", + "zeroize", +] + [[package]] name = "busbar-transport-ws" version = "1.6.0" @@ -1170,6 +1196,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.6" @@ -1195,6 +1231,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1236,6 +1282,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.2" @@ -1464,7 +1525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2016,12 +2077,34 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6edbbdc611843e8c65878ee735f68c495cede11ae5a9582b17fe0369c456ef5" +dependencies = [ + "crc", + "serde", + "str0m-proto", + "subtle", + "tracing", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3282,7 +3365,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3390,6 +3473,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctp-proto" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250132c5814c3d4f01269ee7a261450bc38dd5119280262d3c13a40062cfde55" +dependencies = [ + "bytes", + "crc", + "log", + "rand 0.9.4", + "rustc-hash", + "slab", + "thiserror", +] + [[package]] name = "semver" version = "1.0.28" @@ -3631,6 +3729,40 @@ dependencies = [ "der", ] +[[package]] +name = "str0m" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e80c4a4ef148e4a9fd15b9525c7f376d97e86729f19880b591ad220f1288c0f" +dependencies = [ + "arrayvec", + "base64ct", + "combine", + "dimpl", + "fastrand", + "is", + "sctp-proto", + "serde", + "str0m-proto", + "subtle", + "time", + "tracing", +] + +[[package]] +name = "str0m-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8f3d99f2cf6c76a502e45feb896ea71e54787ede8744bcdb215acfbfcb905dd" +dependencies = [ + "base64ct", + "dimpl", + "fastrand", + "serde", + "subtle", + "time", +] + [[package]] name = "strum" version = "0.28.0" @@ -3720,7 +3852,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4406,7 +4538,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e1d7fa0dd1..541f455dc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "crates/plugin-loader", "crates/store-memory", "crates/busbar-transport-stdio", + "crates/busbar-transport-webrtc", "testing/ws-conformance/subject", "xtask", ] @@ -158,6 +159,15 @@ crc32fast = "1" # DTLS for the connector's dtls engine (WebRTC), on ring through the connector's own provider: # no default features, so neither aws-lc-rs nor the RustCrypto suite enters. dimpl = { version = "=0.7.4", default-features = false } +# THE WEBRTC FRAMER's media stack (busbar-transport-webrtc; ARCHITECT WEBRTC spike GO and D2 approval, +# THE DESIGN l.756-765): ICE, SRTP and SCTP, sans-IO. No default features: no aws-lc-rs, no OpenSSL, +# no example server; the framer installs its own crypto provider on ring and a host shim in place of +# str0m's DTLS (the framer holds no DTLS state). +str0m = { version = "=0.24.1", default-features = false } +# THE ONE EXCEPTION TO ring-ONLY (THE DESIGN l.759-761, QUESTIONS Q101): ring has no raw AES block, and +# the SRTP key derivation (RFC 3711 §4.3, RFC 7714 §11) is AES in counter mode over 16-byte blocks. +# aes/cipher/inout enter for that derivation alone; a dependency-closure test confines them to it. +aes = { version = "0.8", default-features = false } # THE MICROBENCHMARK HARNESS. Dev-only, and deliberately stripped: `default-features = false` drops # criterion's plotting stack (plotters, its font/image chain) and its rayon thread pool, keeping # `cargo_bench_support` — the part `cargo bench` actually needs to run a `harness = false` target. diff --git a/crates/busbar-contract/tests/reserved_keys_and_closed_codes.rs b/crates/busbar-contract/tests/reserved_keys_and_closed_codes.rs index c247eac336..40038e5921 100644 --- a/crates/busbar-contract/tests/reserved_keys_and_closed_codes.rs +++ b/crates/busbar-contract/tests/reserved_keys_and_closed_codes.rs @@ -117,12 +117,12 @@ fn a_composition_refusal_says_which_transport_and_which_layer() { // Each of the three is read by a human at boot, off a node that will not start. A refusal that // rendered as nothing would leave the operator with an exit code. let unregistered = CompositionError::UnregisteredLayer { - transport: "webrtc", + transport: "dgram", layer: "udp", }; let rendered = unregistered.to_string(); assert!( - rendered.contains("webrtc") && rendered.contains("udp"), + rendered.contains("dgram") && rendered.contains("udp"), "{rendered}" ); assert!(rendered.contains("no registered transport"), "{rendered}"); diff --git a/crates/busbar-contract/tests/transport_registry.rs b/crates/busbar-contract/tests/transport_registry.rs index e71235e5f3..3d0f8da47a 100644 --- a/crates/busbar-contract/tests/transport_registry.rs +++ b/crates/busbar-contract/tests/transport_registry.rs @@ -49,12 +49,12 @@ fn the_real_stack_boots() { fn a_declared_layer_that_nothing_registered_refuses_the_boot() { let registry = [ declared("tcp", &[], None), - declared("webrtc", &["udp"], None), + declared("dgram", &["udp"], None), ]; assert_eq!( check_composition(®istry), Err(CompositionError::UnregisteredLayer { - transport: "webrtc", + transport: "dgram", layer: "udp", }) ); diff --git a/crates/busbar-core-connector/src/dtls/mod.rs b/crates/busbar-core-connector/src/dtls/mod.rs index 856921f2ab..454b2af351 100644 --- a/crates/busbar-core-connector/src/dtls/mod.rs +++ b/crates/busbar-core-connector/src/dtls/mod.rs @@ -11,7 +11,7 @@ //! //! # One UDP port, three protocols (RFC 7983 §7) //! -//! A WebRTC association multiplexes STUN, DTLS and SRTP on one 5-tuple. [`classify`] splits them by +//! A media association multiplexes STUN, DTLS and SRTP on one 5-tuple. [`classify`] splits them by //! the first byte, and [`Association::ingest`] is the record layer's host side: //! //! * **STUN** goes up the raw lane to the framer's ICE agent — except the answers to core's own @@ -86,7 +86,7 @@ pub const CONSENT_TIMEOUT: Duration = Duration::from_secs(30); /// How long a probe is awaited before a heard path is probed again. const PROBE_RETRY: Duration = Duration::from_millis(500); -/// What one datagram on a WebRTC port is (RFC 7983 §7). +/// What one datagram on a media port is (RFC 7983 §7). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Class { /// First byte 0–3: STUN. @@ -242,7 +242,7 @@ pub enum Up { Connected, /// The exported SRTP keying material. Keyed(SrtpKeying), - /// Decrypted DTLS application data (SCTP, for WebRTC). + /// Decrypted DTLS application data (SCTP, for the data channels). Plaintext(Vec), /// The peer closed the DTLS session. Closed, @@ -284,7 +284,7 @@ pub enum Bind { WhenProven, } -/// One WebRTC association's DTLS engine and demux. +/// One media association's DTLS engine and demux. pub struct Association { cert: Arc, role: Role, @@ -467,7 +467,7 @@ impl Association { self.bound == Some(path) } - /// Seal `plaintext` (SCTP, for WebRTC) as DTLS application data toward the bound path. + /// Seal `plaintext` (SCTP, for the data channels) as DTLS application data toward the bound path. /// /// # Errors /// diff --git a/crates/busbar-core-connector/src/dtls/ring.rs b/crates/busbar-core-connector/src/dtls/ring.rs index 0d62cc59be..e217f26628 100644 --- a/crates/busbar-core-connector/src/dtls/ring.rs +++ b/crates/busbar-core-connector/src/dtls/ring.rs @@ -16,7 +16,7 @@ //! exactly the record-number mask for ChaCha20 (dimpl checks its first 5 bytes), so //! `TLS_CHACHA20_POLY1305_SHA256` is offered to satisfy it. The AES-GCM 1.3 suites need a full //! 16-byte AES-ECB block, which ring does not expose. Associations are built DTLS 1.2 -//! (`Dtls::new_12`), the version every browser's WebRTC stack speaks. +//! (`Dtls::new_12`), the version every browser's media stack speaks. use dimpl::crypto::{ Aad, ActiveKeyExchange, Buf, Cipher, CryptoProvider, Dtls12CipherSuite, Dtls13CipherSuite, diff --git a/crates/busbar-core-connector/src/lib.rs b/crates/busbar-core-connector/src/lib.rs index 6b5e00db3c..75b2da1285 100644 --- a/crates/busbar-core-connector/src/lib.rs +++ b/crates/busbar-core-connector/src/lib.rs @@ -16,7 +16,7 @@ //! * [`guard`] is THE DESTINATION GUARD, the one check deciding which addresses any outbound //! connection may be dialled at (private refused unless allowlisted, metadata always). //! * [`tls`] is connection security — core-only, never a plugin, never crossing the ABI. -//! * [`dtls`] is its datagram sibling — the DTLS engine a WebRTC association runs on (the RFC 7983 +//! * [`dtls`] is its datagram sibling — the DTLS engine a media association runs on (the RFC 7983 //! demux, the ICE-gated bind, the SRTP exporter), on ring like [`tls`]. //! * [`udp`] is the host's datagram socket: one bound port, a peer per datagram. //! diff --git a/crates/busbar-core-connector/src/udp.rs b/crates/busbar-core-connector/src/udp.rs index 75762d3bcf..f47d2e5c4c 100644 --- a/crates/busbar-core-connector/src/udp.rs +++ b/crates/busbar-core-connector/src/udp.rs @@ -5,7 +5,7 @@ //! binds one. The stream carriers are a separate path: a datagram has //! a peer per read and no connection, so it is never folded into the stream socket types. //! -//! One bound port serves every WebRTC association on it: each read answers the datagram AND the +//! One bound port serves every media association on it: each read answers the datagram AND the //! peer it came from, each write names the peer it goes to, and the dtls engine //! ([`crate::dtls`]) demultiplexes by path. Nothing here waits: a read or write that would block //! answers `None`, and the caller waits on the socket's read/write readiness — the per-worker diff --git a/crates/busbar-kernel-egress/tests/swrr_allocation.rs b/crates/busbar-kernel-egress/tests/swrr_allocation.rs index b219cdeae1..9b34b7aaf2 100644 --- a/crates/busbar-kernel-egress/tests/swrr_allocation.rs +++ b/crates/busbar-kernel-egress/tests/swrr_allocation.rs @@ -12,17 +12,29 @@ use busbar_kernel_egress::trust::{ select_weighted, BreakerView, LaneCandidate, LaneTable, SwrrState, }; use std::alloc::{GlobalAlloc, Layout, System}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::cell::Cell; +use std::sync::atomic::{AtomicUsize, Ordering}; /// An allocator that counts allocations while a test asks it to. struct CountingAllocator; -static COUNTING: AtomicBool = AtomicBool::new(false); static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0); +thread_local! { + // Per thread, not per process: the harness's own thread keeps allocating while the test runs + // (it files the test it just spawned), and those allocations are not the selection's. The + // selection runs wholly on the thread that measures it, so every allocation it makes is still + // counted. + static COUNTING: Cell = const { Cell::new(false) }; +} + +fn counting() -> bool { + COUNTING.try_with(Cell::get).unwrap_or(false) +} + unsafe impl GlobalAlloc for CountingAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - if COUNTING.load(Ordering::Relaxed) { + if counting() { ALLOCATIONS.fetch_add(1, Ordering::Relaxed); } unsafe { System.alloc(layout) } @@ -31,7 +43,7 @@ unsafe impl GlobalAlloc for CountingAllocator { unsafe { System.dealloc(ptr, layout) } } unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { - if COUNTING.load(Ordering::Relaxed) { + if counting() { ALLOCATIONS.fetch_add(1, Ordering::Relaxed); } unsafe { System.realloc(ptr, layout, new_size) } @@ -41,12 +53,12 @@ unsafe impl GlobalAlloc for CountingAllocator { #[global_allocator] static ALLOCATOR: CountingAllocator = CountingAllocator; -/// Count the allocations one closure performs. +/// Count the allocations one closure performs on this thread. fn allocations_during(f: impl FnOnce()) -> usize { ALLOCATIONS.store(0, Ordering::Relaxed); - COUNTING.store(true, Ordering::Relaxed); + COUNTING.with(|c| c.set(true)); f(); - COUNTING.store(false, Ordering::Relaxed); + COUNTING.with(|c| c.set(false)); ALLOCATIONS.load(Ordering::Relaxed) } diff --git a/crates/busbar-transport-webrtc/Cargo.toml b/crates/busbar-transport-webrtc/Cargo.toml new file mode 100644 index 0000000000..e6e4efe3c2 --- /dev/null +++ b/crates/busbar-transport-webrtc/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "busbar-transport-webrtc" +version = "1.6.0" +publish = false # internal workspace crate until its repo is extracted (step 40) +edition = "2021" +rust-version = "1.97" +description = "The webrtc framer: ICE, SRTP and SCTP over the host's datagram port and secure layer, sans-IO. It holds no DTLS state; the host's secure layer hands it the exported keying material." +license = "Apache-2.0" + +[lib] +name = "busbar_transport_webrtc" +path = "src/lib.rs" +# rlib: the linked door (`linked::door`) and the tests. cdylib: the dropped-in door, the same door +# exported as `busbar_plugin_door` under the `dropped-in` feature (Part 2 #2: one table, linked in or +# dropped in). The plugins.yaml twin (`busbar-transport-webrtc-plugin`, pending_crate) takes this +# crate when it moves to its repo; in this workspace the cdylib is this crate's own, as stdio's is. +crate-type = ["rlib", "cdylib"] + +[features] +# THE DROPPED-IN DOOR (`exported`): this crate's door as its image's one `busbar_plugin_door`. OFF in +# a build that links this crate (the linked door is `linked::door`); the dropped-in artifact is built +# on its own: `cargo build -p busbar-transport-webrtc --release --features dropped-in`. +dropped-in = [] + +# DEPENDENCY POLICY (THE DESIGN l.749-757, ARCHITECT D2 approval). A transport plugin's closure is +# busbar-contract plus what its framing needs: +# * str0m: ICE, SRTP, SCTP and the session description, sans-IO, default features off. +# * ring: every cryptographic primitive the framer runs (AEAD-AES-GCM, HMAC-SHA1, SHA-256, random). +# * aes: the SRTP key derivation's AES block, the one exception to ring-only, confined by +# tests/dependency_closure.rs. +# * zeroize: the keying material and the keys derived from it are cleared when dropped. +[dependencies] +busbar-contract = { path = "../busbar-contract" } +str0m = { workspace = true } +ring = { workspace = true } +aes = { workspace = true, features = ["zeroize"] } +zeroize = { workspace = true } + +[dev-dependencies] +# THE BOTH-WAYS WITNESS (tests/conformance.rs, its only user): the linked door and the dropped-in +# cdylib, each admitted by the real loader, run one script to one transcript (Part 2 #2). This crate +# names itself with its dropped-in door on, so `cargo test` builds the cdylib that test opens. +# Dev-only: absent from every release build. +busbar-plugin-loader = { path = "../plugin-loader", features = ["conformance"] } +busbar-transport-webrtc = { path = ".", features = ["dropped-in"] } diff --git a/crates/busbar-transport-webrtc/src/crypto.rs b/crates/busbar-transport-webrtc/src/crypto.rs new file mode 100644 index 0000000000..5183c66ad9 --- /dev/null +++ b/crates/busbar-transport-webrtc/src/crypto.rs @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE FRAMER'S CRYPTO PROVIDER: every primitive the media stack asks for, on ring, with one +//! exception (THE DESIGN l.759-761): +//! +//! * SRTP is AEAD-AES-GCM only (RFC 7714), 128- and 256-bit, on `ring::aead`. The AES_CM profile's +//! factory hands out a cipher that refuses every packet: `AES_CM` is refused here as it is at the +//! host's handshake and at the keying-material item ([`crate::framing`]). +//! * The SRTP key derivation needs a raw AES block (RFC 3711 §4.3 runs AES in counter mode over +//! the master key), which ring does not expose. That is the `aes` crate's one use, in +//! [`Ring::srtp_aes_128_ecb_round`]/[`Ring::srtp_aes_256_ecb_round`]; `tests/dependency_closure.rs` +//! holds the crate to this file. +//! * STUN's MESSAGE-INTEGRITY is HMAC-SHA1 and the certificate fingerprint SHA-256, both ring. +//! * DTLS is not here: the provider's DTLS factory is the host shim ([`crate::shim`]), which runs +//! no handshake and holds no DTLS key. + +use std::fmt; + +use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; +use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_128_GCM, AES_256_GCM}; +use str0m::crypto::{ + AeadAes128GcmCipher, AeadAes256GcmCipher, Aes128CmSha1_80Cipher, CryptoError, CryptoProvider, + Sha1HmacProvider, Sha256Provider, SrtpProvider, SupportedAeadAes128Gcm, SupportedAeadAes256Gcm, + SupportedAes128CmSha1_80, +}; +use zeroize::{Zeroize as _, Zeroizing}; + +/// The provider: one static instance of each component. +#[derive(Debug)] +pub struct Ring; + +static RING: Ring = Ring; + +/// The crypto provider every framing's media stack runs on (its handshake slot is the host shim's, +/// [`crate::shim::provider`]). +#[must_use] +pub fn provider() -> CryptoProvider { + crate::shim::provider(&RING) +} + +fn refused(why: &str) -> CryptoError { + CryptoError::Other(why.into()) +} + +/// The refusal an `AES_CM` packet meets. +pub const AES_CM_REFUSED: &str = "SRTP AES_CM is refused: AEAD-AES-GCM only"; + +// ── SRTP ───────────────────────────────────────────────────────────────────────────────────────── + +impl SrtpProvider for Ring { + fn aes_128_cm_sha1_80(&self) -> &'static dyn SupportedAes128CmSha1_80 { + &RING + } + + fn aead_aes_128_gcm(&self) -> &'static dyn SupportedAeadAes128Gcm { + &RING + } + + fn aead_aes_256_gcm(&self) -> &'static dyn SupportedAeadAes256Gcm { + &RING + } + + fn srtp_aes_128_ecb_round(&self, key: &[u8], input: &[u8], output: &mut [u8]) { + ecb_round::(key, input, output); + } + + fn srtp_aes_256_ecb_round(&self, key: &[u8], input: &[u8], output: &mut [u8]) { + ecb_round::(key, input, output); + } +} + +/// One AES block of the SRTP key derivation: `output[..16] = AES_key(input[..16])`. A key of the +/// wrong length, or a block shorter than 16 bytes, leaves `output` zeroed (the derived key is then +/// useless and every packet fails authentication; nothing panics across the door). +fn ecb_round(key: &[u8], input: &[u8], output: &mut [u8]) { + output.iter_mut().for_each(|b| *b = 0); + let (Ok(cipher), Some(block), true) = + (C::new_from_slice(key), input.get(..16), output.len() >= 16) + else { + return; + }; + let mut b = GenericArray::clone_from_slice(block); + cipher.encrypt_block(&mut b); + output[..16].copy_from_slice(&b); + b.as_mut_slice().zeroize(); +} + +impl SupportedAes128CmSha1_80 for Ring { + fn create_cipher(&self, key: [u8; 16], _encrypt: bool) -> Box { + let _ = Zeroizing::new(key); + Box::new(AesCmRefused) + } +} + +/// The `AES_CM` cipher: it refuses every packet. +#[derive(Debug)] +pub struct AesCmRefused; + +impl Aes128CmSha1_80Cipher for AesCmRefused { + fn encrypt(&mut self, _: &[u8; 16], _: &[u8], _: &mut [u8]) -> Result<(), CryptoError> { + Err(refused(AES_CM_REFUSED)) + } + + fn decrypt(&mut self, _: &[u8; 16], _: &[u8], _: &mut [u8]) -> Result<(), CryptoError> { + Err(refused(AES_CM_REFUSED)) + } +} + +/// An AEAD-AES-GCM cipher on ring: one key, sealing or opening in place. +pub struct Gcm { + key: LessSafeKey, +} + +impl fmt::Debug for Gcm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Gcm").finish_non_exhaustive() + } +} + +impl Gcm { + fn new(alg: &'static ring::aead::Algorithm, key: &[u8]) -> Option { + UnboundKey::new(alg, key).ok().map(|k| Gcm { + key: LessSafeKey::new(k), + }) + } + + /// `output = ciphertext(input) || tag`, `output.len() >= input.len() + 16`. + fn seal( + &self, + iv: &[u8; 12], + aad: &[u8], + input: &[u8], + output: &mut [u8], + ) -> Result<(), CryptoError> { + let tag_len = self.key.algorithm().tag_len(); + if output.len() < input.len() + tag_len { + return Err(refused("SRTP seal: output too short")); + } + output[..input.len()].copy_from_slice(input); + let tag = self + .key + .seal_in_place_separate_tag( + Nonce::assume_unique_for_key(*iv), + Aad::from(aad), + &mut output[..input.len()], + ) + .map_err(|_| refused("SRTP seal failed"))?; + output[input.len()..input.len() + tag_len].copy_from_slice(tag.as_ref()); + Ok(()) + } + + /// `input = ciphertext || tag`; the plaintext into `output`, its length answered. + fn open( + &self, + iv: &[u8; 12], + aads: &[&[u8]], + input: &[u8], + output: &mut [u8], + ) -> Result { + let tag_len = self.key.algorithm().tag_len(); + if input.len() < tag_len { + return Err(refused("SRTP open: shorter than its tag")); + } + let aad: Vec = aads.concat(); + let mut buf = Zeroizing::new(input.to_vec()); + let plain = self + .key + .open_in_place(Nonce::assume_unique_for_key(*iv), Aad::from(&aad), &mut buf) + .map_err(|_| refused("SRTP authentication failed"))?; + let n = plain.len(); + if output.len() < n { + return Err(refused("SRTP open: output too short")); + } + output[..n].copy_from_slice(plain); + Ok(n) + } +} + +impl AeadAes128GcmCipher for Gcm { + fn encrypt( + &mut self, + iv: &[u8; 12], + aad: &[u8], + input: &[u8], + output: &mut [u8], + ) -> Result<(), CryptoError> { + self.seal(iv, aad, input, output) + } + + fn decrypt( + &mut self, + iv: &[u8; 12], + aads: &[&[u8]], + input: &[u8], + output: &mut [u8], + ) -> Result { + self.open(iv, aads, input, output) + } +} + +impl AeadAes256GcmCipher for Gcm { + fn encrypt( + &mut self, + iv: &[u8; 12], + aad: &[u8], + input: &[u8], + output: &mut [u8], + ) -> Result<(), CryptoError> { + self.seal(iv, aad, input, output) + } + + fn decrypt( + &mut self, + iv: &[u8; 12], + aads: &[&[u8]], + input: &[u8], + output: &mut [u8], + ) -> Result { + self.open(iv, aads, input, output) + } +} + +/// A key ring refused (it cannot happen for a key of the profile's length): every packet fails. +#[derive(Debug)] +struct Unkeyed; + +impl AeadAes128GcmCipher for Unkeyed { + fn encrypt( + &mut self, + _: &[u8; 12], + _: &[u8], + _: &[u8], + _: &mut [u8], + ) -> Result<(), CryptoError> { + Err(refused("SRTP key refused")) + } + + fn decrypt( + &mut self, + _: &[u8; 12], + _: &[&[u8]], + _: &[u8], + _: &mut [u8], + ) -> Result { + Err(refused("SRTP key refused")) + } +} + +impl AeadAes256GcmCipher for Unkeyed { + fn encrypt( + &mut self, + _: &[u8; 12], + _: &[u8], + _: &[u8], + _: &mut [u8], + ) -> Result<(), CryptoError> { + Err(refused("SRTP key refused")) + } + + fn decrypt( + &mut self, + _: &[u8; 12], + _: &[&[u8]], + _: &[u8], + _: &mut [u8], + ) -> Result { + Err(refused("SRTP key refused")) + } +} + +impl SupportedAeadAes128Gcm for Ring { + fn create_cipher(&self, key: [u8; 16], _encrypt: bool) -> Box { + let key = Zeroizing::new(key); + match Gcm::new(&AES_128_GCM, key.as_slice()) { + Some(c) => Box::new(c), + None => Box::new(Unkeyed), + } + } +} + +impl SupportedAeadAes256Gcm for Ring { + fn create_cipher(&self, key: [u8; 32], _encrypt: bool) -> Box { + let key = Zeroizing::new(key); + match Gcm::new(&AES_256_GCM, key.as_slice()) { + Some(c) => Box::new(c), + None => Box::new(Unkeyed), + } + } +} + +// ── STUN integrity, fingerprints ───────────────────────────────────────────────────────────────── + +impl Sha1HmacProvider for Ring { + fn sha1_hmac(&self, key: &[u8], payloads: &[&[u8]]) -> [u8; 20] { + let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key); + let mut ctx = ring::hmac::Context::with_key(&key); + for p in payloads { + ctx.update(p); + } + let mut out = [0_u8; 20]; + out.copy_from_slice(ctx.sign().as_ref()); + out + } +} + +impl Sha256Provider for Ring { + fn sha256(&self, data: &[u8]) -> [u8; 32] { + let mut out = [0_u8; 32]; + out.copy_from_slice(ring::digest::digest(&ring::digest::SHA256, data).as_ref()); + out + } +} + +/// Random bytes from the OS, through ring. +#[must_use] +pub fn random() -> Option<[u8; N]> { + use ring::rand::SecureRandom as _; + let mut out = [0_u8; N]; + ring::rand::SystemRandom::new().fill(&mut out).ok()?; + Some(out) +} + +#[cfg(test)] +#[path = "tests/crypto_tests.rs"] +mod tests; diff --git a/crates/busbar-transport-webrtc/src/door.rs b/crates/busbar-transport-webrtc/src/door.rs new file mode 100644 index 0000000000..9da27c15ef --- /dev/null +++ b/crates/busbar-transport-webrtc/src/door.rs @@ -0,0 +1,733 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE `webrtc` DOOR: this transport as a DATAGRAM FRAMER on the transport kind's table +//! (`busbar_contract::abi::transport`, its datagram lane `abi::transport::datagram`). Every slot is +//! a [`SafeSlot`] over the SDK's generic lifecycle: no `unsafe` here. +//! +//! * `begin`: [`SIDE_ACCEPT`] (answers an offer) or [`SIDE_DIAL`] (yields its offer at once, on +//! stream 0). The host's certificate comes in [`ConnFacts::local_certificate`] (public DER; its +//! fingerprint is what the description states), the host port's address in the lane. A dialling +//! framing's opening fields name what it offers: one `channel` field per data channel (its label, +//! streams `1, 2, ...` in order) and `media: audio` for one Opus line (stream [`MEDIA_BASE`]). +//! * `ingest`: one datagram, or the far end's description, as the lane names it; the lane's host +//! state (bound path, verified bit, keying-material item, new paths) is read first, on every call +//! that carries a lane. +//! * `emit`: one message for a stream (a channel's message, an Opus frame for a media line), +//! gathered until `end_of_frame`; [`EMIT_TEXT`] marks a channel message as text. +//! * `timer`: the clock; `finish`: the association closes. +//! * `locate`, `encode`, `refuse`, `detach`, `adopt`: REFUSED (no authority to dial, no envelope, +//! no upgrade); every carrier op: REFUSED. +//! +//! A full host buffer is back-pressure: READY with [`YIELD_MORE`], and the re-call carries no new +//! bytes. No op pends. + +use core::net::SocketAddr; +use std::collections::HashMap; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +use busbar_contract::abi::mechanism::call::{AbiStr, OutHead, Outcome}; +use busbar_contract::abi::mechanism::door::{KindTailHead, Statement}; +use busbar_contract::abi::sdk::door::{abi_str, statement, AbiIn, AbiOut}; +use busbar_contract::abi::sdk::life::{Held, Life, Refreshed, Refusal}; +use busbar_contract::abi::sdk::{Instance, Lent, Out, Safe, SafeSlot}; +use busbar_contract::abi::transport::check::check_keying; +use busbar_contract::abi::transport::{ + AcceptIn, AcceptOut, AdoptIn, ArrivalIn, ArrivalOut, BeginIn, Claim, ConnFacts, ConnIn, + ConnOut, DatagramLane, DatagramRoute, DatagramYield, DialIn, EmitIn, EncodeIn, FinishIn, + FramePiece, FrameSpan, FramerOut, FramerSink, FramingIn, IngestIn, IoOut, ListenIn, ListenOut, + LocateIn, LocateOut, Ops, ReadIn, RefuseIn, RendezvousTerms, ShutIn, TransportTail, WriteIn, + CANCEL_NOTHING_MOVED, EMIT_TEXT, FRAMING_DATAGRAM, HANDSHAKE_ANSWERS, HANDSHAKE_INITIATES, + LANE_CLEAR, LANE_RENDEZVOUS, LANE_SECURED, PATH_REQUEST_BIND, PATH_REQUEST_REBIND, + PIECE_CONTINUED, PIECE_END_OF_FRAME, PIECE_FIELDS, PIECE_TEXT, ROLE_FRAMER, SIDE_ACCEPT, + SIDE_DIAL, UNIT0_FIRST_DATAGRAM, YIELD_ENDED, YIELD_HAS_DEADLINE, YIELD_MORE, +}; + +use crate::framing::{Framing, Lane, Opening, Piece, Request, Side, MEDIA_BASE}; + +// ── the statement ──────────────────────────────────────────────────────────────────────────────── + +/// The claim this entry answers for. +pub const KEY: &str = "webrtc"; + +const NONE: AbiStr = AbiStr { + ptr: std::ptr::null(), + len: 0, +}; + +const CLAIM_NAMES: &[AbiStr] = &[abi_str(KEY)]; + +/// The claim's row: no selector forms (an association is reached through the rendezvous, never +/// selected on its datagrams), a session whose first unit opens on its first datagram. +const CLAIMS: &[Claim] = &[Claim { + selector_forms: abi_str(""), + egress_selector_forms: abi_str(""), + facts: std::ptr::null(), + facts_len: 0, + status_namespace: NONE, + session: 1, + session_bound: 1, + unit0_trigger: UNIT0_FIRST_DATAGRAM, + status_at: 0, + _reserved: 0, +}]; + +/// The transport kind's tail: a datagram framer, composing over nothing (the port is the host's). +const TAIL: TransportTail = TransportTail { + head: KindTailHead { + size: std::mem::size_of::() as u32, + _reserved: 0, + }, + role: ROLE_FRAMER, + framing: FRAMING_DATAGRAM, + facts: 0, + handshake_max_steps: 0, + composes_over: std::ptr::null(), + composes_over_len: 0, + claim_rows: CLAIMS.as_ptr(), + claim_rows_len: CLAIMS.len(), + upgrades_to: std::ptr::null(), + upgrades_to_len: 0, + handoff_from: NONE, + handoff_to: NONE, + handoff_binding_fact: NONE, + handshake_frame_kind: NONE, + status_rows: std::ptr::null(), + status_rows_len: 0, + settings: std::ptr::null(), + settings_len: 0, +}; + +/// The door's Statement. +pub const STATEMENT: Statement = Statement { + kind_tail: (&TAIL as *const TransportTail).cast::(), + claims: CLAIM_NAMES.as_ptr(), + claims_len: CLAIM_NAMES.len(), + ..statement(KEY, env!("CARGO_PKG_VERSION"), 64) +}; + +// ── the instance ───────────────────────────────────────────────────────────────────────────────── + +/// One association's framing and the host clock it is read against. +struct Held1 { + f: Framing, + base_ns: u64, + base: Instant, + /// A message being gathered, per stream, and whether it is text. + gathering: HashMap, bool)>, + /// A piece split across calls: the bytes not yet written, and whether they continue a field + /// line an earlier piece began. + partial: Option<(Piece, bool)>, +} + +impl Held1 { + fn at(&self, ns: u64) -> Instant { + self.base + Duration::from_nanos(ns.saturating_sub(self.base_ns)) + } + + fn ns(&self, t: Instant) -> u64 { + let d = t.saturating_duration_since(self.base); + self.base_ns + .saturating_add(u64::try_from(d.as_nanos()).unwrap_or(u64::MAX)) + .max(1) + } +} + +/// The framings one instance holds. +pub struct Framings { + framings: Mutex>, + next: AtomicU64, +} + +type State = Held; + +impl Life for Framings { + /// No framer op pends, so a cancel finds nothing in flight. + const CANCEL: u32 = CANCEL_NOTHING_MOVED; + + /// This framer reads no setting. + fn validate(_: &[u8]) -> Result<(), Refusal> { + Ok(()) + } + + fn open(_: &[u8], _: &[&[u8]], _: u64) -> Result { + Ok(Self { + framings: Mutex::new(HashMap::new()), + next: AtomicU64::new(1), + }) + } + + fn refresh(&self, _: &[u8], _: &[&[u8]], _: u64) -> Result { + Ok(Refreshed::default()) + } +} + +impl Framings { + fn lock(&self) -> MutexGuard<'_, HashMap> { + self.framings.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +fn framings<'a>(i: &Instance<'a, State>) -> Result<&'a Framings, Refusal> { + i.get() + .map(Held::life) + .ok_or_else(|| Refusal::failed("no open instance")) +} + +fn addr(s: Lent<'_, AbiStr>) -> Option { + s.as_str().ok()?.parse().ok() +} + +/// Read the lane's host state into the framing: new paths, the bound path, the verified bit and +/// the keying-material item. +fn host_state(h: &mut Held1, lane: Lent<'_, DatagramLane>, now: Instant) -> Result<(), String> { + if let Some(a) = addr(lane.field(|l| &l.local_addr)) { + h.f.local(a); + } + h.f.paths( + lane.paths() + .iter() + .filter_map(|p| Some((p.path, addr(p.field(|x| &x.addr))?))), + ); + let keying = match lane.keying() { + Some(k) => { + check_keying(&k) + .map_err(|f| format!("the keying-material item is malformed: {}", f.field))?; + Some((k.profile, k.bytes())) + } + None => None, + }; + h.f.host(lane.bound, lane.verified == 1, keying, now) +} + +/// Answer into the sink and the lane what the framing owes: routes (bytes in `wire`), pieces (bytes +/// in `frame`), the rendezvous terms, one path request, the deadline, and the flags. +fn answer( + h: &mut Held1, + sink: Lent<'_, FramerSink>, + lane: Option>, + now_ns: u64, + o: &mut Out<'_, FramerOut>, +) { + let mut wire = sink.wire(); + let mut frame = sink.frame(); + let mut pieces = sink.pieces(); + let mut d = DatagramYield::default(); + let mut more = false; + + // Routes: only through a lane's buffer; a stream host gets none. + if let Some(lane) = lane { + let mut routes = lane.routes(); + while let Some(r) = h.f.next_route() { + let room = wire.cap().saturating_sub(wire.asked()); + if routes.asked() >= routes.cap() || room < r.bytes.len() { + h.f.unroute(r); + more = true; + break; + } + let at = wire.extend(&r.bytes); + routes.push(DatagramRoute { + offset: at as u64, + len: r.bytes.len() as u64, + path: r.path, + lane: match r.lane { + Lane::Clear => LANE_CLEAR, + Lane::Secured => LANE_SECURED, + }, + }); + } + d.routes_len = u32::try_from(routes.asked()).unwrap_or(u32::MAX); + if let Some(req) = h.f.take_request() { + (d.request, d.request_from, d.request_to) = match req { + Request::Bind(p) => (PATH_REQUEST_BIND, 0, p), + Request::Rebind(a, b) => (PATH_REQUEST_REBIND, a, b), + }; + } + } + + // The terms first, whole, so their spans are in this answer's frame. + if let Some(t) = h.f.take_terms() { + let parts: [&[u8]; 6] = [ + t.local_user.as_bytes(), + t.local_secret.as_bytes(), + t.remote_user.as_bytes(), + t.remote_secret.as_bytes(), + &t.peer_fingerprint, + &[], + ]; + let candidates: String = t.candidates.iter().map(|c| format!("{c}\n")).collect(); + let total: usize = parts.iter().map(|p| p.len()).sum::() + candidates.len(); + if frame.cap().saturating_sub(frame.asked()) >= total { + let mut span = |b: &[u8]| FrameSpan { + offset: frame.extend(b) as u64, + len: b.len() as u64, + }; + d.terms = RendezvousTerms { + role: if t.initiates { + HANDSHAKE_INITIATES + } else { + HANDSHAKE_ANSWERS + }, + _reserved: 0, + local_user: span(parts[0]), + local_secret: span(parts[1]), + remote_user: span(parts[2]), + remote_secret: span(parts[3]), + peer_fingerprint: span(parts[4]), + candidates: if candidates.is_empty() { + FrameSpan::default() + } else { + span(candidates.as_bytes()) + }, + }; + } else { + // Not room for them whole: the host drains and calls again. + h.partial_terms(t); + more = true; + } + } + + // Pieces. + while let Some((mut p, continued)) = h + .partial + .take() + .or_else(|| h.f.next_piece().map(|p| (p, false))) + { + let room = frame.cap().saturating_sub(frame.asked()); + if pieces.asked() >= pieces.cap() || (room == 0 && !p.bytes.is_empty()) { + h.partial = Some((p, continued)); + more = true; + break; + } + let whole = p.bytes.len() <= room; + if !whole && p.fields && frame.asked() != 0 { + // A field block starts a fresh answer rather than split mid-buffer. + h.partial = Some((p, continued)); + more = true; + break; + } + let n = p.bytes.len().min(room); + let at = frame.extend(&p.bytes[..n]); + let mut flags = 0_u16; + if p.fields { + flags |= PIECE_FIELDS; + if continued { + flags |= PIECE_CONTINUED; + } + } + if p.text && n > 0 && !p.fields { + flags |= PIECE_TEXT; + } + if whole { + flags |= PIECE_END_OF_FRAME; + } + pieces.push(FramePiece { + stream: p.stream, + offset: at as u64, + len: n as u64, + code: 0, + status_class: 0, + _reserved: 0, + flags, + retry_after_secs: 0, + }); + if !whole { + p.bytes.drain(..n); + // The rest of a field block continues the line this piece may have split. + let continues = p.fields; + h.partial = Some((p, continues)); + more = true; + break; + } + } + o.set(|o| &o.yielded.wire_len, wire.asked() as u64); + o.set(|o| &o.yielded.frame_len, frame.asked() as u64); + o.set( + |o| &o.yielded.pieces_len, + u32::try_from(pieces.asked()).unwrap_or(u32::MAX), + ); + o.set(|o| &o.datagram, d); + let mut flags = 0; + more |= h.f.owes() || h.partial.is_some(); + if more { + flags |= YIELD_MORE; + } else if h.f.ended() { + flags |= YIELD_ENDED; + } + if let Some(t) = h.f.next_timeout() { + let ns = h.ns(t).max(now_ns.saturating_add(1)); + flags |= YIELD_HAS_DEADLINE; + o.set(|o| &o.yielded.next_deadline_ns, ns); + } + o.set(|o| &o.yielded.flags, flags); +} + +impl Held1 { + fn partial_terms(&mut self, t: crate::framing::Terms) { + self.f.restore_terms(t); + } +} + +/// Run `op` on the framing `token` names, then answer what it owes. +fn with( + inst: &Instance<'_, State>, + token: u64, + sink: Lent<'_, FramerSink>, + lane: Option>, + o: &mut Out<'_, FramerOut>, + op: impl FnOnce(&mut Held1, Instant) -> Result<(), String>, +) -> Outcome { + let s = match framings(inst) { + Ok(s) => s, + Err(r) => return o.fail(r), + }; + let mut held = s.lock(); + let Some(h) = held.get_mut(&token) else { + return o.fail(Refusal::failed("no such framing")); + }; + let now_ns = sink.now_monotonic_ns; + let now = h.at(now_ns); + let run = match lane { + Some(l) => host_state(h, l, now).and_then(|()| op(h, now)), + None => op(h, now), + }; + if let Err(why) = run { + held.remove(&token); + return o.fail(Refusal::failed(why)); + } + if let Some(why) = h.f.failure() { + let why = why.to_owned(); + held.remove(&token); + return o.fail(Refusal::failed(why)); + } + answer(h, sink, lane, now_ns, o); + Outcome::Ready +} + +// ── the carrier ops: refused ───────────────────────────────────────────────────────────────────── + +/// A carrier op: REFUSED. The port is the host's. +pub struct NotACarrier(PhantomData<(I, O)>); + +impl SafeSlot for NotACarrier { + type In = I; + type Out = O; + type State = State; + fn call(_: Instance<'_, State>, _: Lent<'_, I>, _: Out<'_, O>) -> Outcome { + Outcome::Refused + } +} + +/// A framer op this framer has no use for: FAILED with its reason. +pub struct Unused(PhantomData); + +/// Why each unused op is refused. +pub trait Why { + /// The reason. + const WHY: &'static str; +} + +impl Why for EncodeIn { + const WHY: &'static str = "encode: an association carries no envelope"; +} +impl Why for RefuseIn { + const WHY: &'static str = "refuse: an association's refusal is the signalling unit's"; +} +impl Why for AdoptIn { + const WHY: &'static str = "adopt: an association is begun at its rendezvous, never adopted"; +} + +impl SafeSlot for Unused { + type In = I; + type Out = FramerOut; + type State = State; + fn call(_: Instance<'_, State>, _: Lent<'_, I>, mut o: Out<'_, FramerOut>) -> Outcome { + o.fail(Refusal::failed(I::WHY)) + } +} + +// ── the framer ─────────────────────────────────────────────────────────────────────────────────── + +/// `locate`: REFUSED. An association's far end comes from its description, judged by the host. +pub struct Locate; +impl SafeSlot for Locate { + type In = LocateIn; + type Out = LocateOut; + type State = State; + fn call(_: Instance<'_, State>, _: Lent<'_, LocateIn>, mut o: Out<'_, LocateOut>) -> Outcome { + o.fail(Refusal::failed( + "locate: an association's far end comes from its description, not a target", + )) + } +} + +/// What a dialling framing's opening fields offer. +fn opening(i: &Lent<'_, BeginIn>) -> Opening { + let mut o = Opening::default(); + for f in i.fields().iter() { + let (Ok(name), Ok(value)) = ( + f.field(|x| &x.name).as_str(), + f.field(|x| &x.value).as_str(), + ) else { + continue; + }; + match name { + "channel" if !value.is_empty() => o.channels.push(value.to_owned()), + "media" if value == "audio" => o.audio = true, + _ => {} + } + } + o +} + +/// The host's public certificate, from the facts. +fn certificate<'a>(i: &Lent<'a, BeginIn>) -> Option<&'a [u8]> { + let facts: Lent<'a, ConnFacts> = i.facts()?; + if (facts.size as usize) < std::mem::size_of::() { + return None; + } + let der = facts.field(|f| &f.local_certificate).bytes(); + (!der.is_empty()).then_some(der) +} + +/// `begin`: an accepting framing waits for the offer; a dialling one yields its offer now. +pub struct Begin; +impl SafeSlot for Begin { + type In = BeginIn; + type Out = FramerOut; + type State = State; + fn call(inst: Instance<'_, State>, i: Lent<'_, BeginIn>, mut o: Out<'_, FramerOut>) -> Outcome { + let s = match framings(&inst) { + Ok(s) => s, + Err(r) => return o.fail(r), + }; + let side = match i.side { + SIDE_ACCEPT => Side::Accept, + SIDE_DIAL => Side::Dial, + _ => { + return o.fail(Refusal::failed( + "begin: an association is accepted or dialled, never one stream", + )) + } + }; + let Some(lane) = i.lane() else { + return o.fail(Refusal::failed("begin: a datagram framer reads its lane")); + }; + let Some(cert) = certificate(&i) else { + return o.fail(Refusal::failed("begin: the host presented no certificate")); + }; + let sink = i.field(|x| &x.sink); + let now_ns = sink.now_monotonic_ns; + let base = Instant::now(); + let local = addr(lane.field(|l| &l.local_addr)); + let f = match Framing::begin(side, cert, local, &opening(&i), base) { + Ok(f) => f, + Err(why) => return o.fail(Refusal::failed(why)), + }; + let mut h = Held1 { + f, + base_ns: now_ns, + base, + gathering: HashMap::new(), + partial: None, + }; + if let Err(why) = host_state(&mut h, lane, base) { + return o.fail(Refusal::failed(why)); + } + answer(&mut h, sink, Some(lane), now_ns, &mut o); + let token = s.next.fetch_add(1, Ordering::Relaxed); + s.lock().insert(token, h); + o.set(|o| &o.framing, token); + Outcome::Ready + } +} + +/// `ingest`: one datagram, or the far end's description. +pub struct Ingest; +impl SafeSlot for Ingest { + type In = IngestIn; + type Out = FramerOut; + type State = State; + fn call( + inst: Instance<'_, State>, + i: Lent<'_, IngestIn>, + mut o: Out<'_, FramerOut>, + ) -> Outcome { + let Some(lane) = i.lane() else { + return o.fail(Refusal::failed("ingest: a datagram framer reads its lane")); + }; + let bytes = i.bytes(); + let ended = i.end != 0; + with( + &inst, + i.framing, + i.field(|x| &x.sink), + Some(lane), + &mut o, + |h, now| { + if bytes.is_empty() { + // The YIELD_MORE re-call (or a host-state-only call): nothing new. + if ended { + h.f.finish(now); + } + return Ok(()); + } + match lane.lane { + LANE_RENDEZVOUS => h.f.rendezvous(bytes, now), + LANE_CLEAR | LANE_SECURED => { + let from = addr(lane.field(|l| &l.path_addr)) + .ok_or("ingest: a datagram with no address")?; + let l = if lane.lane == LANE_CLEAR { + Lane::Clear + } else { + Lane::Secured + }; + h.f.datagram(lane.path, from, l, bytes, now); + Ok(()) + } + _ => Err("ingest: bytes on no lane".into()), + } + }, + ) + } +} + +/// `emit`: a message's pieces, gathered; its last piece sends it. +pub struct Emit; +impl SafeSlot for Emit { + type In = EmitIn; + type Out = FramerOut; + type State = State; + fn call(inst: Instance<'_, State>, i: Lent<'_, EmitIn>, mut o: Out<'_, FramerOut>) -> Outcome { + let bytes = i.bytes(); + let end = i.end_of_frame != 0; + let text = i.flags & EMIT_TEXT != 0; + let stream = i.stream; + with( + &inst, + i.framing, + i.field(|x| &x.sink), + i.lane(), + &mut o, + |h, now| { + if bytes.is_empty() && !end { + return Ok(()); + } + let g = h.gathering.entry(stream).or_default(); + g.0.extend_from_slice(bytes); + g.1 |= text; + if !end { + return Ok(()); + } + let (message, text) = h.gathering.remove(&stream).unwrap_or_default(); + if message.is_empty() { + return Ok(()); + } + h.f.emit(stream, &message, text, now) + }, + ) + } +} + +/// `timer`: the deadline the framing asked for passed. +pub struct Timer; +impl SafeSlot for Timer { + type In = FramingIn; + type Out = FramerOut; + type State = State; + fn call( + inst: Instance<'_, State>, + i: Lent<'_, FramingIn>, + mut o: Out<'_, FramerOut>, + ) -> Outcome { + with( + &inst, + i.framing, + i.field(|x| &x.sink), + i.lane(), + &mut o, + |h, now| { + h.f.timeout(now); + Ok(()) + }, + ) + } +} + +/// `detach`: REFUSED (an association is never handed to another framer). +pub struct Detach; +impl SafeSlot for Detach { + type In = FramingIn; + type Out = FramerOut; + type State = State; + fn call(_: Instance<'_, State>, _: Lent<'_, FramingIn>, mut o: Out<'_, FramerOut>) -> Outcome { + o.fail(Refusal::failed( + "detach: an association is never handed to another framer", + )) + } +} + +/// `finish`: the association closes, its last datagrams answered; the framing is forgotten. +pub struct Finish; +impl SafeSlot for Finish { + type In = FinishIn; + type Out = FramerOut; + type State = State; + fn call( + inst: Instance<'_, State>, + i: Lent<'_, FinishIn>, + mut o: Out<'_, FramerOut>, + ) -> Outcome { + let s = match framings(&inst) { + Ok(s) => s, + Err(r) => return o.fail(r), + }; + let mut held = s.lock(); + let Some(mut h) = held.remove(&i.framing) else { + return o.fail(Refusal::failed("finish: no such framing")); + }; + drop(held); + let sink = i.field(|x| &x.sink); + let now_ns = sink.now_monotonic_ns; + let now = h.at(now_ns); + h.f.finish(now); + // What the close owes is answered once; what does not fit is dropped with the framing. + answer(&mut h, sink, i.lane(), now_ns, &mut o); + let flags = o.get().yielded.flags & !(YIELD_MORE | YIELD_HAS_DEADLINE); + o.set(|o| &o.yielded.next_deadline_ns, 0); + o.set(|o| &o.yielded.flags, flags | YIELD_ENDED); + Outcome::Ready + } +} + +busbar_contract::plugin_door! { + ops: Ops, + statement: STATEMENT, + lifecycle: life(Framings), + kind_ops: { + listen: Safe>, + accept: Safe>, + dial: Safe>, + read: Safe>, + write: Safe>, + flush: Safe>, + shut: Safe>, + arrival: Safe>, + locate: Safe, + begin: Safe, + ingest: Safe, + emit: Safe, + encode: Safe>, + refuse: Safe>, + finish: Safe, + detach: Safe, + adopt: Safe>, + timer: Safe, + }, +} + +/// The first media stream, for a host that names streams. +pub const FIRST_MEDIA_STREAM: u64 = MEDIA_BASE; diff --git a/crates/busbar-transport-webrtc/src/framing.rs b/crates/busbar-transport-webrtc/src/framing.rs new file mode 100644 index 0000000000..438ec75f3d --- /dev/null +++ b/crates/busbar-transport-webrtc/src/framing.rs @@ -0,0 +1,1000 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! ONE ASSOCIATION'S FRAMING: the media stack (ICE, SRTP, SCTP) over the host's datagram port and +//! secure layer, with no ABI type in it ([`crate::door`] lowers it onto the transport kind's table). +//! +//! # What goes in +//! +//! * the far end's session description ([`Framing::rendezvous`]): the offer on the accepting side, +//! the answer on the dialling side; +//! * the host's state on every call ([`Framing::host`]): the bound path, the verified bit, the +//! keying-material item once; +//! * datagrams ([`Framing::datagram`]): clear (a connectivity check, protected media) or secured +//! (plaintext the host's secure layer opened); +//! * messages to send ([`Framing::emit`]) and the clock ([`Framing::timeout`]). +//! +//! # What comes out +//! +//! * routes ([`Route`]): a check or its answer to the path it concerns; protected media and +//! secured plaintext to the BOUND path only — the host's choice, never the media stack's; +//! * pieces ([`Piece`]): stream [`DESCRIPTION`] carries this end's own description; each data +//! channel and each media line is a stream whose first frame is a field block naming it +//! (`label` for a channel; `kind`, `codec` for media), then one frame per message; +//! * at most one path request: bind the path the far end nominated (or this end did, dialling), +//! or move to it from the bound one; +//! * the rendezvous terms the host's secure layer runs under. +//! +//! # The guards (THE DESIGN l.756-765) +//! +//! * AEAD-AES-GCM only: a keying-material item under any other profile fails the framing. +//! * Application data waits for peer verification: until the host's verified bit, a secured +//! datagram or protected media is dropped unread, and nothing is handed up. +//! * A path the host has not bound receives no media: a nomination the media stack's ICE agent +//! accepted is only a REQUEST; media follows the host's bound path, which moves only after the +//! host's own verified round trip. +//! * Keys are zeroised: the keying material this framing holds is cleared once handed on. + +use core::net::SocketAddr; +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use str0m::change::{SdpAnswer, SdpOffer, SdpPendingOffer}; +use str0m::channel::ChannelId; +use str0m::format::Codec; +use str0m::media::{Direction, Frequency, MediaKind, MediaTime, Mid, Pt}; +use str0m::net::{Protocol, Receive}; +use str0m::{Candidate, Event, IceConnectionState, IceCreds, Input, Output, Rtc}; +use zeroize::{Zeroize as _, Zeroizing}; + +use crate::shim::{self, Shim}; +use crate::stun::{self, Check}; + +/// The stream this end's own session description is yielded on. +pub const DESCRIPTION: u64 = 0; +/// The first media stream: media line `n` (in the order it appeared) is `MEDIA_BASE + n`. +pub const MEDIA_BASE: u64 = 1 << 32; + +/// The registry codes of the protection profiles this framer admits (RFC 7714 §14.2). +pub const PROFILE_AEAD_AES_128_GCM: u32 = 0x0007; +/// `SRTP_AEAD_AES_256_GCM`. +pub const PROFILE_AEAD_AES_256_GCM: u32 = 0x0008; + +/// The most messages held for a channel not yet open, and the most pieces or routes queued. +const MAX_HELD: usize = 256; +/// The most nominating checks awaiting their answer. +const MAX_NOMINATING: usize = 64; + +/// Which end of the association this is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + /// Answers the far end's offer (ICE-lite). + Accept, + /// Offers (full ICE, controlling). + Dial, +} + +/// The lane a datagram travels on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lane { + /// As the port carried it. + Clear, + /// Plaintext of the host's secure layer. + Secured, +} + +/// One datagram for the host to send. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Route { + /// Its bytes. + pub bytes: Vec, + /// Its path. + pub path: u32, + /// Its lane. + pub lane: Lane, +} + +/// One piece for the layer above. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Piece { + /// The stream. + pub stream: u64, + /// The bytes. + pub bytes: Vec, + /// A field block (a stream's head). + pub fields: bool, + /// The message was text. + pub text: bool, +} + +/// A path request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Request { + /// Bind this path. + Bind(u32), + /// Move from the first (bound) path to the second. + Rebind(u32, u32), +} + +/// What the host's secure layer runs under. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Terms { + /// This end opens the handshake. + pub initiates: bool, + /// The user name the far end's checks carry first (ours). + pub local_user: String, + /// Our secret. + pub local_secret: Zeroizing, + /// The far end's user name. + pub remote_user: String, + /// The far end's secret. + pub remote_secret: Zeroizing, + /// The far end's certificate fingerprint. + pub peer_fingerprint: [u8; 32], + /// The far end's datagram candidates, `ip:port` each. + pub candidates: Vec, +} + +/// What a dialling framing offers: its channels' labels and whether it carries audio. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Opening { + /// The data channels, by label, in stream order (stream `1 + i`). + pub channels: Vec, + /// One audio line (Opus, sent and received). + pub audio: bool, +} + +/// Why a framing failed. Never secret material. +pub type Failed = String; + +/// One association's framing. +pub struct Framing { + side: Side, + rtc: Rtc, + shim: Shim, + local: Option, + local_creds: IceCreds, + pending: Option, + described: bool, + paths: HashMap, + addrs: HashMap, + bound: u32, + verified: bool, + keyed: bool, + ice: IceConnectionState, + /// Checks that nominate, by transaction id, and the path each travels. + nominating: HashMap<[u8; 12], u32>, + nominated: u32, + asked: Option, + request: Option, + terms: Option, + routes: VecDeque, + pieces: VecDeque, + channels: HashMap, + channel_by_stream: HashMap, + held: HashMap)>>, + next_channel: u64, + medias: HashMap, + media_by_stream: HashMap, + media_clock: HashMap, + next_timeout: Option, + failed: Option, + ended: bool, +} + +impl std::fmt::Debug for Framing { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Framing") + .field("side", &self.side) + .field("bound", &self.bound) + .field("verified", &self.verified) + .field("keyed", &self.keyed) + .finish_non_exhaustive() + } +} + +impl Drop for Framing { + /// This framing's own copy of its check secret is cleared with it (the keying material is the + /// shim's, held zeroising). + fn drop(&mut self) { + self.local_creds.pass.zeroize(); + } +} + +fn hex_pair(s: &str) -> Option { + u8::from_str_radix(s, 16).ok() +} + +/// A description's line values for `prefix` (`a=ice-ufrag:` and the like), in order. +fn values<'a>(description: &'a str, prefix: &'a str) -> impl Iterator + 'a { + description + .lines() + .filter_map(move |l| l.trim_end_matches('\r').strip_prefix(prefix)) +} + +/// The far end's half of the terms: check user, check secret, certificate fingerprint, candidates. +type Remote = (String, Zeroizing, [u8; 32], Vec); + +/// The terms a description pair settles, read from the far end's description text. +fn read_remote(description: &str) -> Result { + let user = values(description, "a=ice-ufrag:") + .next() + .ok_or("the far end's description has no check user")? + .trim() + .to_owned(); + let secret = Zeroizing::new( + values(description, "a=ice-pwd:") + .next() + .ok_or("the far end's description has no check secret")? + .trim() + .to_owned(), + ); + let print = values(description, "a=fingerprint:") + .find_map(|v| { + let (alg, hex) = v.trim().split_once(' ')?; + alg.eq_ignore_ascii_case("sha-256").then_some(hex) + }) + .ok_or("the far end's description has no sha-256 fingerprint")?; + let bytes: Vec = print + .split(':') + .map(hex_pair) + .collect::>() + .ok_or("a malformed fingerprint")?; + let fingerprint: [u8; 32] = bytes + .try_into() + .map_err(|_| "a fingerprint that is not 32 bytes")?; + let candidates = values(description, "a=candidate:") + .filter_map(|v| { + let f: Vec<&str> = v.split_whitespace().collect(); + let (proto, ip, port) = (f.get(2)?, f.get(4)?, f.get(5)?); + if !proto.eq_ignore_ascii_case("udp") { + return None; + } + format!("{ip}:{port}") + .parse::() + .ok() + .or_else(|| { + let ip: core::net::IpAddr = ip.parse().ok()?; + Some(SocketAddr::new(ip, port.parse().ok()?)) + }) + }) + .collect(); + Ok((user, secret, fingerprint, candidates)) +} + +/// A fresh check credential: `n` random bytes as lower-case hex (RFC 8445 §5.3: the user at least +/// 24 bits, the secret at least 128). +fn credential(n: usize) -> Result { + let bytes: [u8; 32] = crate::crypto::random().ok_or("the host has no randomness")?; + Ok(bytes[..n].iter().map(|b| format!("{b:02x}")).collect()) +} + +/// How many 48 kHz samples an Opus packet spans, from its table-of-contents byte (RFC 6716 §3.1). +/// No codec runs: the frame is passed through, and this is only its length in time. +#[must_use] +pub fn opus_samples(packet: &[u8]) -> u64 { + let Some(&toc) = packet.first() else { + return 0; + }; + let config = toc >> 3; + // Frame length in units of 2.5 ms (120 samples). + let unit = match config { + 0..=11 => [4, 8, 16, 24][usize::from(config % 4)], + 12..=15 => [4, 8][usize::from(config % 2)], + _ => [1, 2, 4, 8][usize::from(config % 4)], + }; + let frames = match toc & 3 { + 0 => 1, + 1 | 2 => 2, + _ => packet.get(1).map_or(0, |b| u64::from(b & 0x3f)), + }; + 120 * unit * frames +} + +impl Framing { + /// Begin a framing on `side`. `certificate` is the host's public certificate (its fingerprint is + /// what this end's description states); `opening` is what a dialling end offers. + /// + /// # Errors + /// + /// No randomness, or a dialling end with nothing to offer. + pub fn begin( + side: Side, + certificate: &[u8], + local: Option, + opening: &Opening, + now: Instant, + ) -> Result { + let local_creds = IceCreds { + ufrag: credential(4)?, + pass: credential(16)?, + }; + let shim = Shim::default(); + shim::arm(shim.clone()); + let config = Rtc::builder().set_crypto_provider(Arc::new(crate::crypto::provider())); + let mut config = shim::present(config, certificate) + .set_local_ice_credentials(local_creds.clone()) + .set_ice_lite(side == Side::Accept) + .clear_codecs() + .enable_opus(true, false); + config = config.set_stats_interval(None); + let rtc = config.build(now); + let _ = shim::take_pending(); + let mut f = Framing { + side, + rtc, + shim, + local: None, + local_creds, + pending: None, + described: false, + paths: HashMap::new(), + addrs: HashMap::new(), + bound: 0, + verified: false, + keyed: false, + ice: IceConnectionState::New, + nominating: HashMap::new(), + nominated: 0, + asked: None, + request: None, + terms: None, + routes: VecDeque::new(), + pieces: VecDeque::new(), + channels: HashMap::new(), + channel_by_stream: HashMap::new(), + held: HashMap::new(), + next_channel: 1, + medias: HashMap::new(), + media_by_stream: HashMap::new(), + media_clock: HashMap::new(), + next_timeout: None, + failed: None, + ended: false, + }; + if let Some(addr) = local { + f.local(addr); + } + if side == Side::Dial { + if opening.channels.is_empty() && !opening.audio { + return Err("a dialling framing offers nothing".into()); + } + let mut api = f.rtc.sdp_api(); + let mut mids = Vec::new(); + if opening.audio { + mids.push(api.add_media(MediaKind::Audio, Direction::SendRecv, None, None, None)); + } + let mut ids = Vec::new(); + for label in &opening.channels { + ids.push(api.add_channel(label.clone())); + } + let (offer, pending) = api.apply().ok_or("the offer could not be built")?; + f.pending = Some(pending); + f.describe(offer.to_sdp_string()); + for (n, mid) in mids.into_iter().enumerate() { + let s = f.media(mid, n as u64); + f.head(s, &[("kind", "audio"), ("codec", "opus")]); + } + for id in ids { + f.channel_stream(id); + } + } + Ok(f) + } + + fn describe(&mut self, description: String) { + let description = self.with_candidate(description); + self.described = true; + self.pieces.push_back(Piece { + stream: DESCRIPTION, + bytes: description.into_bytes(), + fields: false, + text: true, + }); + } + + /// A description with this end's one host candidate, when the host has told it its port. + fn with_candidate(&self, description: String) -> String { + let Some(local) = self.local else { + return description; + }; + if description.contains("a=candidate:") { + return description; + } + let line = format!( + "a=candidate:1 1 udp 2130706431 {} {} typ host\r\n", + local.ip(), + local.port() + ); + let mut out = String::with_capacity(description.len() + line.len() * 2); + for l in description.split_inclusive('\n') { + out.push_str(l); + if l.starts_with("a=ice-pwd:") { + out.push_str(&line); + } + } + out + } + + /// The host port's own address: the one candidate this end advertises. + pub fn local(&mut self, addr: SocketAddr) { + if self.local.is_some() { + return; + } + self.local = Some(addr); + if let Ok(c) = Candidate::host(addr, "udp") { + self.rtc.add_local_candidate(c); + } + } + + /// Paths the host knows. + pub fn paths(&mut self, paths: impl IntoIterator) { + for (path, addr) in paths { + if path != 0 { + self.paths.insert(path, addr); + self.addrs.insert(addr, path); + } + } + } + + /// The far end's session description. + /// + /// # Errors + /// + /// A description the media stack refuses, or one with no terms. + pub fn rendezvous(&mut self, description: &[u8], now: Instant) -> Result<(), Failed> { + let text = + std::str::from_utf8(description).map_err(|_| "a description that is not text")?; + let (remote_user, remote_secret, peer_fingerprint, candidates) = read_remote(text)?; + match self.side { + Side::Accept => { + if self.described { + return Err("a second offer on one association".into()); + } + let offer = SdpOffer::from_sdp_string(text) + .map_err(|e| format!("the offer did not parse: {e}"))?; + let answer = self + .rtc + .sdp_api() + .accept_offer(offer) + .map_err(|e| format!("the offer was refused: {e}"))?; + self.describe(answer.to_sdp_string()); + } + Side::Dial => { + let pending = self.pending.take().ok_or("an answer with no offer out")?; + let answer = SdpAnswer::from_sdp_string(text) + .map_err(|e| format!("the answer did not parse: {e}"))?; + self.rtc + .sdp_api() + .accept_answer(pending, answer) + .map_err(|e| format!("the answer was refused: {e}"))?; + } + } + let initiates = self + .shim + .active() + .ok_or("the description settled no handshake role")?; + self.terms = Some(Terms { + initiates, + local_user: self.local_creds.ufrag.clone(), + local_secret: Zeroizing::new(self.local_creds.pass.clone()), + remote_user, + remote_secret, + peer_fingerprint, + candidates, + }); + self.drain(now); + Ok(()) + } + + /// The host's state at this call: the bound path, the verified bit, and the keying-material item + /// when it presents one (`(profile, material)`). + /// + /// # Errors + /// + /// Keying under a profile other than AEAD-AES-GCM, keying before verification, or a second + /// keying. + pub fn host( + &mut self, + bound: u32, + verified: bool, + keying: Option<(u32, &[u8])>, + now: Instant, + ) -> Result<(), Failed> { + if bound != self.bound { + self.bound = bound; + if self.asked.is_some_and(|r| target(r) == bound) { + self.asked = None; + } + } + if verified && !self.verified { + self.verified = true; + } + if let Some((profile, material)) = keying { + let material = Zeroizing::new(material.to_vec()); + if !self.verified { + return Err("keying material before the far end was verified".into()); + } + if self.keyed { + return Err("keying material twice".into()); + } + self.shim.keyed(&material, profile)?; + self.keyed = true; + } + if self.verified && self.keyed { + self.shim.connected(); + } + self.drain(now); + Ok(()) + } + + /// One datagram from `path` on `lane`. + pub fn datagram( + &mut self, + path: u32, + from: SocketAddr, + lane: Lane, + bytes: &[u8], + now: Instant, + ) { + if self.failed.is_some() || bytes.is_empty() { + return; + } + if path != 0 { + self.paths.insert(path, from); + self.addrs.insert(from, path); + } + match lane { + Lane::Secured => { + // Application data waits for peer verification. + if self.verified && self.keyed { + self.shim.opened(bytes); + self.drain(now); + } + } + Lane::Clear if stun::is_stun(bytes) => { + match stun::read(bytes) { + Some(Check::Request { + txid, + nominates: true, + }) if self.side == Side::Accept => { + self.track(txid, path); + } + Some(Check::Success { txid }) + if self.side == Side::Dial && self.nominating.get(&txid) == Some(&path) => + { + self.nominating.remove(&txid); + self.feed(from, bytes, now); + if matches!( + self.ice, + IceConnectionState::Connected | IceConnectionState::Completed + ) { + self.nominate(path); + } + return; + } + _ => {} + } + self.feed(from, bytes, now); + } + Lane::Clear => { + // Protected media: read only once verified and keyed, and only from the bound path. + if self.verified && self.keyed && path == self.bound && path != 0 { + self.feed(from, bytes, now); + } + } + } + } + + fn feed(&mut self, from: SocketAddr, bytes: &[u8], now: Instant) { + let Some(local) = self.local else { + return; + }; + let Ok(receive) = Receive::new(Protocol::Udp, from, local, bytes) else { + return; + }; + if let Err(e) = self.rtc.handle_input(Input::Receive(now, receive)) { + self.fail(format!("the media stack refused a datagram: {e}")); + return; + } + self.drain(now); + } + + /// Remember a nominating check, holding the set bounded (a flood of checks evicts the set, + /// never grows it). + fn track(&mut self, txid: [u8; 12], path: u32) { + if self.nominating.len() >= MAX_NOMINATING { + self.nominating.clear(); + } + self.nominating.insert(txid, path); + } + + fn nominate(&mut self, path: u32) { + self.nominated = path; + let want = if self.bound == 0 { + Request::Bind(path) + } else if self.bound != path { + Request::Rebind(self.bound, path) + } else { + return; + }; + if self.asked != Some(want) { + self.asked = Some(want); + self.request = Some(want); + } + } + + /// A message for the far end on `stream`. + /// + /// # Errors + /// + /// A stream this framing does not carry, or a media frame the stack refuses. + pub fn emit( + &mut self, + stream: u64, + bytes: &[u8], + text: bool, + now: Instant, + ) -> Result<(), Failed> { + if let Some(mid) = self.media_by_stream.get(&stream).copied() { + if !(self.verified && self.keyed) { + return Ok(()); + } + let clock = self.media_clock.entry(stream).or_insert(0); + let at = *clock; + *clock = clock.wrapping_add(opus_samples(bytes)); + let Some(writer) = self.rtc.writer(mid) else { + return Ok(()); + }; + let pt: Option = writer + .payload_params() + .find(|p| p.spec().codec == Codec::Opus) + .map(|p| p.pt()); + let Some(pt) = pt else { + return Err("the media line carries no Opus".into()); + }; + writer + .write( + pt, + now, + MediaTime::new(at, Frequency::FORTY_EIGHT_KHZ), + bytes.to_vec(), + ) + .map_err(|e| format!("the media frame was refused: {e}"))?; + self.drain(now); + return Ok(()); + } + let Some(id) = self.channel_by_stream.get(&stream).copied() else { + return Err(format!("no stream {stream} on this association")); + }; + let sent = match self.rtc.channel(id) { + Some(mut ch) => ch + .write(!text, bytes) + .map_err(|e| format!("the channel refused the message: {e}"))?, + None => false, + }; + if !sent { + let held = self.held.entry(stream).or_default(); + if held.len() >= MAX_HELD { + return Err("too many messages held for a channel not yet open".into()); + } + held.push_back((text, bytes.to_vec())); + } + self.drain(now); + Ok(()) + } + + /// The clock reached `now`. + pub fn timeout(&mut self, now: Instant) { + if self.failed.is_some() { + return; + } + if let Err(e) = self.rtc.handle_input(Input::Timeout(now)) { + self.fail(format!("the media stack failed: {e}")); + return; + } + self.drain(now); + } + + /// Close the association. + pub fn finish(&mut self, now: Instant) { + let _ = self.rtc.close(); + self.drain(now); + self.ended = true; + } + + fn fail(&mut self, why: Failed) { + self.rtc.disconnect(); + self.failed = Some(why); + self.routes.clear(); + } + + fn channel_stream(&mut self, id: ChannelId) -> u64 { + if let Some(s) = self.channels.get(&id) { + return *s; + } + let s = self.next_channel; + self.next_channel += 1; + self.channels.insert(id, s); + self.channel_by_stream.insert(s, id); + s + } + + fn media(&mut self, mid: Mid, n: u64) -> u64 { + let s = MEDIA_BASE + n; + self.medias.insert(mid, s); + self.media_by_stream.insert(s, mid); + s + } + + fn head(&mut self, stream: u64, fields: &[(&str, &str)]) { + let mut block = Vec::new(); + for (n, v) in fields { + block.extend_from_slice(n.as_bytes()); + block.extend_from_slice(b": "); + block.extend_from_slice(v.as_bytes()); + block.extend_from_slice(b"\r\n"); + } + self.pieces.push_back(Piece { + stream, + bytes: block, + fields: true, + text: false, + }); + } + + /// Run the media stack until it waits, collecting what it owes. + fn drain(&mut self, now: Instant) { + // Messages held for channels that have since opened. + let open: Vec = self.held.keys().copied().collect(); + for stream in open { + let Some(id) = self.channel_by_stream.get(&stream).copied() else { + continue; + }; + while let Some((text, bytes)) = self.held.get_mut(&stream).and_then(VecDeque::pop_front) + { + let sent = self + .rtc + .channel(id) + .is_some_and(|mut ch| ch.write(!text, &bytes).unwrap_or(false)); + if !sent { + if let Some(q) = self.held.get_mut(&stream) { + q.push_front((text, bytes)); + } + break; + } + } + } + for _ in 0..10_000 { + let out = match self.rtc.poll_output() { + Ok(o) => o, + Err(e) => { + self.fail(format!("the media stack failed: {e}")); + return; + } + }; + // What the stack handed the shim to seal during this poll (an SCTP packet is queued + // there, never transmitted), routed whatever the poll answered. + let waits = match out { + Output::Timeout(t) => { + self.next_timeout = Some(t.max(now)); + true + } + Output::Transmit(t) => { + self.transmit(t.destination, &t.contents); + false + } + Output::Event(e) => { + self.event(e); + false + } + }; + while let Some(plain) = self.shim.take_to_seal() { + if self.bound != 0 && self.routes.len() < MAX_HELD * 4 { + self.routes.push_back(Route { + bytes: plain, + path: self.bound, + lane: Lane::Secured, + }); + } + } + if waits { + break; + } + } + if !self.rtc.is_alive() && self.failed.is_none() { + self.ended = true; + } + } + + fn transmit(&mut self, to: SocketAddr, bytes: &[u8]) { + if self.routes.len() >= MAX_HELD * 4 { + return; + } + if stun::is_stun(bytes) { + let Some(path) = self.addrs.get(&to).copied() else { + // An address the host never judged or heard: nothing goes there. + return; + }; + match stun::read(bytes) { + Some(Check::Success { txid }) + if self.side == Side::Accept && self.nominating.remove(&txid) == Some(path) => + { + self.nominate(path); + } + Some(Check::Request { + txid, + nominates: true, + }) if self.side == Side::Dial => { + self.track(txid, path); + } + _ => {} + } + self.routes.push_back(Route { + bytes: bytes.to_vec(), + path, + lane: Lane::Clear, + }); + return; + } + // Protected media goes to the host's bound path, whatever the stack chose. + if self.bound != 0 && self.verified && self.keyed { + self.routes.push_back(Route { + bytes: bytes.to_vec(), + path: self.bound, + lane: Lane::Clear, + }); + } + } + + fn event(&mut self, e: Event) { + match e { + Event::IceConnectionStateChange(s) => { + self.ice = s; + if s == IceConnectionState::Disconnected { + self.ended = true; + } + } + Event::ChannelOpen(id, label) => { + let s = self.channel_stream(id); + self.head(s, &[("label", &label)]); + } + Event::ChannelData(d) => { + // Application data waits for peer verification (the shim hands none up before it, + // so this is the second lock on the same door). + if !(self.verified && self.keyed) || self.pieces.len() >= MAX_HELD * 4 { + return; + } + let s = self.channel_stream(d.id); + self.pieces.push_back(Piece { + stream: s, + bytes: d.data, + fields: false, + text: !d.binary, + }); + } + Event::ChannelClose(id) => { + if let Some(s) = self.channels.get(&id).copied() { + self.pieces.push_back(Piece { + stream: s, + bytes: Vec::new(), + fields: false, + text: false, + }); + } + } + Event::MediaAdded(m) + if m.kind == MediaKind::Audio && !self.medias.contains_key(&m.mid) => + { + let n = self.medias.len() as u64; + let s = self.media(m.mid, n); + self.head(s, &[("kind", "audio"), ("codec", "opus")]); + } + Event::MediaData(d) => { + if !(self.verified && self.keyed) || self.pieces.len() >= MAX_HELD * 4 { + return; + } + if let Some(s) = self.medias.get(&d.mid).copied() { + self.pieces.push_back(Piece { + stream: s, + bytes: d.data.to_vec(), + fields: false, + text: false, + }); + } + } + Event::Closed => self.ended = true, + _ => {} + } + } + + // ── what the framing owes ─────────────────────────────────────────────────────────────────── + + /// The next route. + pub fn next_route(&mut self) -> Option { + self.routes.pop_front() + } + + /// Put a route back at the front (the host's buffer was full). + pub fn unroute(&mut self, r: Route) { + self.routes.push_front(r); + } + + /// The next piece. + pub fn next_piece(&mut self) -> Option { + self.pieces.pop_front() + } + + /// Put a piece back at the front. + pub fn unpiece(&mut self, p: Piece) { + self.pieces.push_front(p); + } + + /// Whether anything is still owed. + #[must_use] + pub fn owes(&self) -> bool { + !self.routes.is_empty() || !self.pieces.is_empty() || self.terms.is_some() + } + + /// The path request, taken. + pub fn take_request(&mut self) -> Option { + self.request.take() + } + + /// The rendezvous terms, taken. + pub fn take_terms(&mut self) -> Option { + self.terms.take() + } + + /// Put the terms back (the host's frame buffer could not hold them whole). + pub fn restore_terms(&mut self, t: Terms) { + self.terms = Some(t); + } + + /// When [`Self::timeout`] is next owed. + #[must_use] + pub fn next_timeout(&self) -> Option { + self.next_timeout + } + + /// Why the framing failed. + #[must_use] + pub fn failure(&self) -> Option<&str> { + self.failed.as_deref() + } + + /// Whether the association ended. + #[must_use] + pub fn ended(&self) -> bool { + self.ended + } + + /// The path the far end nominated last, as this framer's ICE agent accepted it. + #[must_use] + pub fn nominated(&self) -> u32 { + self.nominated + } + + /// Whether keying material is held un-handed (it is cleared once the stack took it). + #[must_use] + pub fn holds_keying(&self) -> bool { + self.shim.holds_keying() + } + + /// How long until `deadline`, from `now`. + #[must_use] + pub fn until(deadline: Instant, now: Instant) -> Duration { + deadline.saturating_duration_since(now) + } +} + +fn target(r: Request) -> u32 { + match r { + Request::Bind(p) | Request::Rebind(_, p) => p, + } +} + +#[cfg(test)] +#[path = "tests/framing_tests.rs"] +mod tests; diff --git a/crates/busbar-transport-webrtc/src/lib.rs b/crates/busbar-transport-webrtc/src/lib.rs new file mode 100644 index 0000000000..48ca4fc415 --- /dev/null +++ b/crates/busbar-transport-webrtc/src/lib.rs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE `webrtc` TRANSPORT: a datagram framer holding ICE, SRTP and SCTP (`BUSBAR-1.6.0.md` THE +//! DESIGN §5, "Datagram media", l.756-765). +//! +//! WebRTC mirrors HTTPS layering. The datagram port is the host's (the connector's, no carrier +//! plugin); DTLS runs in the host's secure layer with the host certificate, demultiplexing the port +//! and gating each association on ICE. This framer holds what is left: the ICE agent (it answers and +//! nominates; the host binds only a path it has proven), SRTP (AEAD-AES-GCM only, on ring, with the +//! keying material the host's secure layer exported) and SCTP data channels. It holds no DTLS state +//! ([`shim`]). Opus passes through; no codec runs. +//! +//! ## One entry: the door +//! +//! [`door::door`] is the memory-ABI door, the same table compiled in (the `linked` row) and dropped +//! in (this crate's cdylib, built with the `dropped-in` feature). +//! +//! This crate is `deny`, not `forbid`: the export macro's `#[unsafe(no_mangle)]` is the one +//! reviewed exemption, compiled only into the dropped-in image. No other `unsafe` exists here. + +#![deny(unsafe_code)] +#![deny(missing_docs)] + +pub mod crypto; +pub mod door; +pub mod framing; +pub mod shim; +pub mod stun; + +/// THE TRANSPORT AXIS ENTRY: the row's key, the layers it declares and its door. +pub mod linked { + pub use crate::door::{door, KEY}; + /// The layers this wire declares it can be built over: none (the datagram port is the host's). + pub const COMPOSES_OVER: &[&str] = &[]; + /// Whether this wire carries sessions: yes, an association is one (its claim row's `session`). + pub const SESSION: bool = true; +} + +/// THE DROPPED-IN DOOR: the macro's `#[no_mangle]` symbol is the one exemption. +#[cfg(feature = "dropped-in")] +#[allow(unsafe_code)] +mod exported { + busbar_contract::export_door!(crate::door::door); +} diff --git a/crates/busbar-transport-webrtc/src/shim.rs b/crates/busbar-transport-webrtc/src/shim.rs new file mode 100644 index 0000000000..4eaf7509db --- /dev/null +++ b/crates/busbar-transport-webrtc/src/shim.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE HOST SHIM: what the media stack sees in place of its own DTLS (THE DESIGN l.707-712, +//! l.756-765: DTLS runs in the host's secure layer; the framer holds no DTLS state). +//! +//! It runs no handshake, parses no record and holds no DTLS key. It only RELAYS what the host's +//! secure layer reported through the datagram lane: +//! +//! * the exported keying material (the keying-material item), handed up once, then zeroed; +//! * "connected", once the host verified the far end; +//! * plaintext the secure layer opened (the secured lane), handed up as application data; +//! +//! and takes the application data the media stack wants sealed, for the framer to route on the +//! secured lane, which the host seals. Before the host's "connected", nothing is taken for sealing +//! (the media stack retries: `DtlsImplError::HandshakePending`). +//! +//! This file is the ONE place the framer names the media stack's DTLS interface (its provider and +//! instance traits, the certificate it is configured with, the profile type of the keying +//! material); no DTLS engine is named anywhere (`tests/no_dtls.rs` holds both). + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +use str0m::crypto::dtls::{ + DtlsCert, DtlsImplError, DtlsInstance, DtlsOutput, DtlsProvider, DtlsVersion, KeyingMaterial, + ProtocolVersion, SrtpProfile, +}; +use str0m::crypto::{CryptoError, CryptoProvider}; +use str0m::RtcConfig; +use zeroize::Zeroizing; + +use crate::framing::{PROFILE_AEAD_AES_128_GCM, PROFILE_AEAD_AES_256_GCM}; + +/// The DTLS factory of [`crate::crypto::provider`]: it hands out the shim armed for the +/// association being built ([`arm`]), never a DTLS engine. +#[derive(Debug)] +pub struct Host; + +/// The one factory. +pub static HOST: Host = Host; + +impl DtlsProvider for Host { + fn generate_certificate(&self) -> Option { + None + } + + fn new_dtls( + &self, + _cert: &DtlsCert, + _now: Instant, + _version: DtlsVersion, + _mtu: Option, + ) -> Result, CryptoError> { + take_pending() + .map(|s| Box::new(s) as Box) + .ok_or_else(|| { + CryptoError::Other("no host shim is pending for this association".into()) + }) + } +} + +/// The media stack's crypto provider: every primitive on `ring` ([`crate::crypto::Ring`]), the +/// DTLS factory the host shim. +#[must_use] +pub fn provider(ring: &'static crate::crypto::Ring) -> CryptoProvider { + CryptoProvider { + srtp_provider: ring, + sha1_hmac_provider: ring, + sha256_provider: ring, + dtls_provider: &HOST, + } +} + +/// `config` presenting the host's PUBLIC certificate (its fingerprint is what this end's description +/// states; there is no private key, nothing here signs), with the media stack's own fingerprint +/// check off: the host's secure layer verified the far end's certificate, and the media stack never +/// sees one. +#[must_use] +pub fn present(config: RtcConfig, certificate: &[u8]) -> RtcConfig { + config + .set_dtls_cert(DtlsCert { + certificate: certificate.to_vec(), + private_key: Vec::new(), + }) + .set_fingerprint_verification(false) +} + +std::thread_local! { + static PENDING: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Arm `shim` as the one the next association built on this thread receives. +pub fn arm(shim: Shim) { + PENDING.with(|p| *p.borrow_mut() = Some(shim)); +} + +/// The armed shim, taken. +pub fn take_pending() -> Option { + PENDING.with(|p| p.borrow_mut().take()) +} + +/// How far ahead the shim's timer lies: it has nothing to time. +const IDLE: Duration = Duration::from_secs(3600); + +/// The relay's state, shared by the media stack's handle and the framing's. +#[derive(Debug, Default)] +pub struct Relay { + active: Option, + keying: Option<(Zeroizing>, SrtpProfile)>, + connected: bool, + connected_owed: bool, + opened: VecDeque>>, + to_seal: VecDeque>, + closed: bool, + now: Option, +} + +/// One association's shim. +#[derive(Debug, Clone, Default)] +pub struct Shim(Arc>); + +impl Shim { + fn lock(&self) -> MutexGuard<'_, Relay> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// The host's secure layer exported `material` under the protection profile whose registry code + /// is `profile`. AEAD-AES-GCM only: any other profile (`AES_CM` and the rest) is refused and + /// nothing of the material is held. + /// + /// # Errors + /// + /// [`crate::crypto::AES_CM_REFUSED`], for a profile other than the two AEAD-AES-GCM ones. + pub fn keyed(&self, material: &[u8], profile: u32) -> Result<(), &'static str> { + let profile = match profile { + PROFILE_AEAD_AES_128_GCM => SrtpProfile::AEAD_AES_128_GCM, + PROFILE_AEAD_AES_256_GCM => SrtpProfile::AEAD_AES_256_GCM, + _ => return Err(crate::crypto::AES_CM_REFUSED), + }; + self.lock().keying = Some((Zeroizing::new(material.to_vec()), profile)); + Ok(()) + } + + /// The host verified the far end: the handshake is, for the media stack, connected. + pub fn connected(&self) { + let mut r = self.lock(); + if !r.connected { + r.connected = true; + r.connected_owed = true; + } + } + + /// Whether the host reported the far end verified. + #[must_use] + pub fn is_connected(&self) -> bool { + self.lock().connected + } + + /// Plaintext the host's secure layer opened. + pub fn opened(&self, plaintext: &[u8]) { + self.lock() + .opened + .push_back(Zeroizing::new(plaintext.to_vec())); + } + + /// The next plaintext the media stack wants sealed. + pub fn take_to_seal(&self) -> Option> { + self.lock().to_seal.pop_front() + } + + /// The role the media stack settled: `Some(true)` = this end opens the handshake. + #[must_use] + pub fn active(&self) -> Option { + self.lock().active + } + + /// Whether material is still held (not yet handed to the media stack). + #[must_use] + pub fn holds_keying(&self) -> bool { + self.lock().keying.is_some() + } +} + +impl DtlsInstance for Shim { + fn set_active(&mut self, active: bool) { + self.lock().active = Some(active); + } + + fn handle_packet(&mut self, _packet: &[u8]) -> Result<(), DtlsImplError> { + // The host's secure layer keeps every record: none reaches the framer. + Ok(()) + } + + fn poll_output<'a>(&mut self, buf: &'a mut [u8]) -> DtlsOutput<'a> { + let mut r = self.lock(); + if let Some((material, profile)) = r.keying.take() { + return DtlsOutput::KeyingMaterial(KeyingMaterial::new(&material), profile); + } + if r.connected_owed { + r.connected_owed = false; + return DtlsOutput::Connected; + } + while let Some(data) = r.opened.pop_front() { + if let Some(dst) = buf.get_mut(..data.len()) { + dst.copy_from_slice(&data); + drop(r); + return DtlsOutput::ApplicationData(&buf[..data.len()]); + } + // A plaintext larger than the stack's record buffer is no message it can read. + } + let at = r.now.unwrap_or_else(Instant::now) + IDLE; + DtlsOutput::Timeout(at) + } + + fn handle_timeout(&mut self, now: Instant) -> Result<(), DtlsImplError> { + self.lock().now = Some(now); + Ok(()) + } + + fn send_application_data(&mut self, data: &[u8]) -> Result<(), DtlsImplError> { + let mut r = self.lock(); + if !r.connected || r.closed { + return Err(DtlsImplError::HandshakePending); + } + r.to_seal.push_back(data.to_vec()); + Ok(()) + } + + fn is_active(&self) -> bool { + self.lock().active.unwrap_or(false) + } + + fn protocol_version(&self) -> Option { + None + } + + fn is_closed(&self) -> bool { + self.lock().closed + } + + fn close(&mut self) -> Result<(), DtlsImplError> { + self.lock().closed = true; + Ok(()) + } +} diff --git a/crates/busbar-transport-webrtc/src/stun.rs b/crates/busbar-transport-webrtc/src/stun.rs new file mode 100644 index 0000000000..25e8dfd67b --- /dev/null +++ b/crates/busbar-transport-webrtc/src/stun.rs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! The few facts of a connectivity check the framer reads itself (RFC 8489 framing, RFC 8445 +//! attributes): its class, its transaction id and whether it nominates. Integrity is the media +//! stack's ICE agent's to judge (it answers only a check whose MESSAGE-INTEGRITY verifies), and the +//! host's, independently; this module judges nothing. + +/// The STUN magic cookie. +const MAGIC: [u8; 4] = [0x21, 0x12, 0xA4, 0x42]; +/// Binding request. +const BINDING_REQUEST: u16 = 0x0001; +/// Binding success response. +const BINDING_SUCCESS: u16 = 0x0101; +/// USE-CANDIDATE (RFC 8445 §16.1). +const USE_CANDIDATE: u16 = 0x0025; + +/// What a datagram is, as far as nomination goes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Check { + /// A Binding request; `nominates` = it carries USE-CANDIDATE. + Request { + /// The transaction id. + txid: [u8; 12], + /// It nominates the pair it travels on. + nominates: bool, + }, + /// A Binding success response. + Success { + /// The transaction id. + txid: [u8; 12], + }, +} + +/// Read `datagram` as a Binding request or success response; `None` for anything else. +#[must_use] +pub fn read(datagram: &[u8]) -> Option { + let head = datagram.get(..20)?; + if head[0] & 0xC0 != 0 || head[4..8] != MAGIC { + return None; + } + let kind = u16::from_be_bytes([head[0], head[1]]); + let len = usize::from(u16::from_be_bytes([head[2], head[3]])); + let body = datagram.get(20..20 + len)?; + let mut txid = [0_u8; 12]; + txid.copy_from_slice(&head[8..20]); + match kind { + BINDING_REQUEST => Some(Check::Request { + txid, + nominates: attributes(body).any(|t| t == USE_CANDIDATE), + }), + BINDING_SUCCESS => Some(Check::Success { txid }), + _ => None, + } +} + +/// The attribute types of a STUN body, in order, stopping at the first malformed one. +fn attributes(body: &[u8]) -> impl Iterator + '_ { + let mut at = 0_usize; + std::iter::from_fn(move || { + let h = body.get(at..at + 4)?; + let t = u16::from_be_bytes([h[0], h[1]]); + let len = usize::from(u16::from_be_bytes([h[2], h[3]])); + let padded = len.checked_add(3)? & !3; + at = at.checked_add(4)?.checked_add(padded)?; + if at > body.len() { + return None; + } + Some(t) + }) +} + +/// Whether `datagram` is a STUN message at all (RFC 7983: first byte 0..=3). +#[must_use] +pub fn is_stun(datagram: &[u8]) -> bool { + matches!(datagram.first(), Some(0..=3)) +} diff --git a/crates/busbar-transport-webrtc/src/tests/crypto_tests.rs b/crates/busbar-transport-webrtc/src/tests/crypto_tests.rs new file mode 100644 index 0000000000..ddc50d02c4 --- /dev/null +++ b/crates/busbar-transport-webrtc/src/tests/crypto_tests.rs @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! SRTP IS AEAD-AES-GCM ONLY (THE DESIGN l.753): the provider's `AES_CM` cipher refuses every +//! packet, and the shim refuses keying material under any profile but the two GCM ones. The RED +//! arm beside each refusal is the same call under AEAD-AES-GCM, which goes through: the refusal is +//! the profile's, not a broken path. + +use super::*; +use crate::framing::{PROFILE_AEAD_AES_128_GCM, PROFILE_AEAD_AES_256_GCM}; +use crate::shim::Shim; + +/// `SRTP_AES128_CM_HMAC_SHA1_80` (RFC 5764 §4.1.2). +const PROFILE_AES_CM_SHA1_80: u32 = 0x0001; +/// `SRTP_AES128_CM_HMAC_SHA1_32`. +const PROFILE_AES_CM_SHA1_32: u32 = 0x0002; + +fn is_aes_cm_refusal(e: &CryptoError) -> bool { + matches!(e, CryptoError::Other(why) if why == AES_CM_REFUSED) +} + +#[test] +fn the_aes_cm_cipher_refuses_every_packet_both_ways() { + for encrypt in [true, false] { + let mut c = RING.aes_128_cm_sha1_80().create_cipher([9_u8; 16], encrypt); + let mut out = [0_u8; 64]; + let sealed = c.encrypt(&[1_u8; 16], b"a protected payload", &mut out); + assert!(sealed.as_ref().is_err_and(is_aes_cm_refusal), "{sealed:?}"); + assert_eq!(out, [0_u8; 64], "nothing was written"); + let opened = c.decrypt(&[1_u8; 16], b"a protected payload", &mut out); + assert!(opened.as_ref().is_err_and(is_aes_cm_refusal), "{opened:?}"); + } +} + +#[test] +fn red_arm_the_gcm_ciphers_of_the_same_provider_seal_and_open() { + let (iv, aad, plain) = ([3_u8; 12], b"rtp header", b"an opus frame"); + let mut sealed = vec![0_u8; plain.len() + 16]; + let mut opened = vec![0_u8; plain.len()]; + let mut seal = RING.aead_aes_128_gcm().create_cipher([5_u8; 16], true); + seal.encrypt(&iv, aad, plain, &mut sealed).expect("sealed"); + let mut open = RING.aead_aes_128_gcm().create_cipher([5_u8; 16], false); + let n = open + .decrypt(&iv, &[aad], &sealed, &mut opened) + .expect("opened"); + assert_eq!(&opened[..n], plain); + // A flipped tag bit fails authentication: the GCM path judges, it does not wave through. + sealed[plain.len()] ^= 1; + assert!(open.decrypt(&iv, &[aad], &sealed, &mut opened).is_err()); + + let mut sealed = vec![0_u8; plain.len() + 16]; + let mut seal = RING.aead_aes_256_gcm().create_cipher([6_u8; 32], true); + seal.encrypt(&iv, aad, plain, &mut sealed).expect("sealed"); + let mut open = RING.aead_aes_256_gcm().create_cipher([6_u8; 32], false); + let n = open + .decrypt(&iv, &[aad], &sealed, &mut opened) + .expect("opened"); + assert_eq!(&opened[..n], plain); +} + +#[test] +fn an_aes_cm_profile_is_refused_at_the_keying_material_and_nothing_is_held() { + let material = [7_u8; 60]; + for profile in [ + PROFILE_AES_CM_SHA1_80, + PROFILE_AES_CM_SHA1_32, + 0x0003, + 0xFFFF, + ] { + let shim = Shim::default(); + assert_eq!(shim.keyed(&material, profile), Err(AES_CM_REFUSED)); + assert!(!shim.holds_keying(), "profile {profile:#06x}: nothing held"); + } + // RED arm: the two AEAD-AES-GCM profiles are taken. + for profile in [PROFILE_AEAD_AES_128_GCM, PROFILE_AEAD_AES_256_GCM] { + let shim = Shim::default(); + assert_eq!(shim.keyed(&material, profile), Ok(())); + assert!(shim.holds_keying(), "profile {profile:#06x}: held"); + } +} + +#[test] +fn the_srtp_key_derivation_block_is_aes() { + // FIPS-197 Appendix C.1: AES-128 of 00112233445566778899aabbccddeeff under 000102…0f. + let key: Vec = (0..16).collect(); + let input: Vec = (0..16).map(|i| i * 0x11).collect(); + let mut out = [0_u8; 16]; + RING.srtp_aes_128_ecb_round(&key, &input, &mut out); + assert_eq!( + out, + [ + 0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, + 0xc5, 0x5a + ] + ); + // A key of the wrong length derives nothing (a zeroed block), and nothing panics. + RING.srtp_aes_128_ecb_round(&key[..15], &input, &mut out); + assert_eq!(out, [0_u8; 16]); +} diff --git a/crates/busbar-transport-webrtc/src/tests/framing_tests.rs b/crates/busbar-transport-webrtc/src/tests/framing_tests.rs new file mode 100644 index 0000000000..bfb913cf5c --- /dev/null +++ b/crates/busbar-transport-webrtc/src/tests/framing_tests.rs @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! Two framings, a dialling one and an accepting one, over a simulated host: the host assigns path +//! numbers, binds what a framing asks for (these tests judge the FRAMER's guards; the host's own +//! round-trip proof is the connector's, `busbar-core-connector/src/dtls/tests.rs`), stands in for +//! the secure layer (secured routes arrive as the far side's secured lane) and presents one keying +//! material to both ends once it marks them verified. + +use core::net::SocketAddr; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use super::*; + +/// A placeholder certificate: the framer states its SHA-256 and nothing else reads it. +const CERT: &[u8] = b"public certificate bytes"; + +const PROFILE: u32 = PROFILE_AEAD_AES_128_GCM; + +/// An Opus packet's bytes: one 20 ms CELT frame (config 31, code 0) and a payload. +const OPUS: &[u8] = &[0xF8, 0x01, 0x02, 0x03, 0x04]; + +/// One end: its framing and its host's view. +struct End { + f: Framing, + addr: SocketAddr, + paths: HashMap, + by_id: HashMap, + bound: u32, + verified: bool, + keyed: bool, + pieces: Vec, + requests: Vec, + sent: Vec, +} + +impl End { + fn new(side: Side, addr: &str, opening: &Opening, now: Instant) -> Self { + let addr: SocketAddr = addr.parse().expect("an address"); + End { + f: Framing::begin(side, CERT, Some(addr), opening, now).expect("begins"), + addr, + paths: HashMap::new(), + by_id: HashMap::new(), + bound: 0, + verified: false, + keyed: false, + pieces: Vec::new(), + requests: Vec::new(), + sent: Vec::new(), + } + } + + fn path(&mut self, a: SocketAddr) -> u32 { + let n = self.paths.len() as u32 + 1; + let p = *self.paths.entry(a).or_insert(n); + self.by_id.insert(p, a); + p + } + + /// The description this end yielded on stream 0. + fn description(&mut self) -> Vec { + self.collect(); + let at = self + .pieces + .iter() + .position(|p| p.stream == DESCRIPTION) + .expect("a description"); + self.pieces.remove(at).bytes + } + + fn collect(&mut self) { + while let Some(p) = self.f.next_piece() { + self.pieces.push(p); + } + while let Some(r) = self.f.take_request() { + self.requests.push(r); + } + } + + /// Take the terms; a dialling end's host judges and numbers the far end's candidates. + fn terms(&mut self) -> Terms { + let t = self.f.take_terms().expect("terms"); + let known: Vec<(u32, SocketAddr)> = + t.candidates.iter().map(|a| (self.path(*a), *a)).collect(); + self.f.paths(known); + t + } + + fn host(&mut self, keying: Option<&[u8]>, now: Instant) -> Result<(), Failed> { + let r = self + .f + .host(self.bound, self.verified, keying.map(|k| (PROFILE, k)), now); + if keying.is_some() && r.is_ok() { + self.keyed = true; + } + self.collect(); + r + } + + fn bind_what_was_asked(&mut self, now: Instant) { + self.collect(); + for r in std::mem::take(&mut self.requests) { + self.bound = match r { + Request::Bind(p) | Request::Rebind(_, p) => p, + }; + } + let _ = self.host(None, now); + } +} + +/// Carry every route each end owes to the other, until both are quiet. +fn pump(a: &mut End, b: &mut End, now: Instant) { + for _ in 0..200 { + let mut moved = false; + for flip in [false, true] { + let (from, to) = if flip { + (&mut *b, &mut *a) + } else { + (&mut *a, &mut *b) + }; + while let Some(r) = from.f.next_route() { + moved = true; + from.sent.push(r.clone()); + let path = to.path(from.addr); + let lane = r.lane; + if lane == Lane::Secured && !(from.verified && to.verified) { + continue; + } + to.f.datagram(path, from.addr, lane, &r.bytes, now); + } + from.collect(); + } + if !moved { + break; + } + } +} + +/// Run the clock and the pump for `ms`, in 10 ms steps. +fn run(a: &mut End, b: &mut End, start: &mut Instant, ms: u64) { + for _ in 0..ms / 10 { + *start += Duration::from_millis(10); + for e in [&mut *a, &mut *b] { + if e.f.next_timeout().is_some_and(|t| t <= *start) { + e.f.timeout(*start); + } + e.bind_what_was_asked(*start); + } + pump(a, b, *start); + } +} + +fn opening() -> Opening { + Opening { + channels: vec!["events".into()], + audio: true, + } +} + +/// Two ends through the rendezvous, ICE, verification and keying: a connected association. +fn connected() -> (End, End, Instant) { + let mut now = Instant::now(); + let mut dial = End::new(Side::Dial, "198.51.100.1:40000", &opening(), now); + let mut accept = End::new(Side::Accept, "203.0.113.9:3478", &Opening::default(), now); + let offer = dial.description(); + accept + .f + .rendezvous(&offer, now) + .expect("the offer is accepted"); + let answer = accept.description(); + dial.f + .rendezvous(&answer, now) + .expect("the answer is accepted"); + let (ta, td) = (accept.terms(), dial.terms()); + assert_ne!(ta.initiates, td.initiates, "one end opens the handshake"); + assert_eq!(ta.remote_user, td.local_user); + assert_eq!(td.remote_user, ta.local_user); + run(&mut dial, &mut accept, &mut now, 3000); + assert_ne!(dial.bound, 0, "the dialling end's nomination was bound"); + assert_ne!(accept.bound, 0, "the accepting end's nomination was bound"); + let material: Vec = (0..56).collect(); + for e in [&mut dial, &mut accept] { + e.verified = true; + e.host(Some(&material), now).expect("keyed"); + } + run(&mut dial, &mut accept, &mut now, 3000); + (dial, accept, now) +} + +fn heard(e: &End, stream: u64) -> Vec> { + e.pieces + .iter() + .filter(|p| p.stream == stream && !p.fields && !p.bytes.is_empty()) + .map(|p| p.bytes.clone()) + .collect() +} + +#[test] +fn an_offer_and_an_answer_settle_terms_and_a_session_carries_messages_and_media_both_ways() { + let (mut dial, mut accept, mut now) = connected(); + let head = |e: &End| { + e.pieces + .iter() + .find(|p| p.fields && p.bytes.starts_with(b"label: ")) + .map(|p| (p.stream, p.bytes.clone())) + }; + let (accept_stream, label) = head(&accept).expect("the accepting end opened the channel"); + assert_eq!(label, b"label: events\r\n"); + dial.f + .emit(1, b"{\"type\":\"hello\"}", true, now) + .expect("sent"); + accept + .f + .emit(accept_stream, b"{\"type\":\"world\"}", true, now) + .expect("sent"); + run(&mut dial, &mut accept, &mut now, 500); + assert_eq!( + heard(&accept, accept_stream), + [b"{\"type\":\"hello\"}".to_vec()] + ); + assert_eq!(heard(&dial, 1), [b"{\"type\":\"world\"}".to_vec()]); + // Opus passes through, byte for byte. + let media = accept + .pieces + .iter() + .find(|p| p.fields && p.bytes.starts_with(b"kind: audio")) + .map(|p| p.stream) + .expect("the accepting end saw the audio line"); + for _ in 0..5 { + dial.f.emit(MEDIA_BASE, OPUS, false, now).expect("a frame"); + run(&mut dial, &mut accept, &mut now, 20); + } + assert!( + heard(&accept, media).iter().any(|b| b == OPUS), + "the frame arrived as sent" + ); +} + +#[test] +fn a_keying_material_item_under_aes_cm_fails_the_framing() { + let now = Instant::now(); + let mut e = End::new(Side::Accept, "203.0.113.9:3478", &Opening::default(), now); + e.verified = true; + let material = [7_u8; 60]; + let refused = e.f.host(0, true, Some((0x0001, &material)), now); + assert_eq!(refused, Err(crate::crypto::AES_CM_REFUSED.to_string())); + assert!( + !e.f.holds_keying(), + "nothing of the refused material is held" + ); + // Any profile outside the two GCM ones is refused the same way. + assert!(e.f.host(0, true, Some((0x0002, &material)), now).is_err()); +} + +#[test] +fn keying_before_the_far_end_is_verified_is_refused() { + let now = Instant::now(); + let mut e = End::new(Side::Accept, "203.0.113.9:3478", &Opening::default(), now); + let material = [7_u8; 56]; + assert_eq!( + e.f.host(0, false, Some((PROFILE, &material)), now), + Err("keying material before the far end was verified".to_string()) + ); +} + +#[test] +fn application_data_before_peer_verification_is_dropped_unread() { + let mut now = Instant::now(); + let mut dial = End::new(Side::Dial, "198.51.100.1:40000", &opening(), now); + let mut accept = End::new(Side::Accept, "203.0.113.9:3478", &Opening::default(), now); + let offer = dial.description(); + accept.f.rendezvous(&offer, now).expect("accepted"); + let answer = accept.description(); + dial.f.rendezvous(&answer, now).expect("accepted"); + let _ = (accept.terms(), dial.terms()); + run(&mut dial, &mut accept, &mut now, 3000); + // The far end's host has NOT verified: a secured datagram (an SCTP packet, say) and protected + // media from the bound path reach the framer, and nothing is read or handed up. + let path = accept.bound; + assert_ne!(path, 0); + let from = accept.by_id[&path]; + accept.f.datagram( + path, + from, + Lane::Secured, + &[0x13, 0x88, 0x13, 0x88, 0, 0, 0, 0, 0, 0, 0, 0], + now, + ); + accept.f.datagram( + path, + from, + Lane::Clear, + &[0x80, 0x6f, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 9, 9, 9, 9], + now, + ); + accept.collect(); + assert!( + accept + .pieces + .iter() + .all(|p| p.fields || p.stream == DESCRIPTION), + "no application data before verification: {:?}", + accept.pieces + ); + // And this end sends nothing secured or protected either. + run(&mut dial, &mut accept, &mut now, 500); + assert!( + accept + .sent + .iter() + .chain(dial.sent.iter()) + .all(|r| r.lane == Lane::Clear && stun::is_stun(&r.bytes)), + "only checks and their answers cross before verification" + ); +} + +/// The accepting end's own copy of the dialling end's last nominating check. +fn last_nomination(dial: &End) -> Vec { + dial.sent + .iter() + .rev() + .find(|r| { + matches!( + stun::read(&r.bytes), + Some(Check::Request { + nominates: true, + .. + }) + ) + }) + .map(|r| r.bytes.clone()) + .expect("the dialling end nominated") +} + +#[test] +fn a_spoofed_migration_never_moves_media_off_the_bound_path() { + let (mut dial, mut accept, mut now) = connected(); + let bound = accept.bound; + let spoof: SocketAddr = "192.0.2.66:5555".parse().expect("an address"); + let spoof_path = accept.path(spoof); + + // A check with a forged MESSAGE-INTEGRITY from a new path: the ICE agent refuses it, no path + // request is made. + let mut forged = last_nomination(&dial); + let n = forged.len(); + forged[n - 9] ^= 0xFF; + accept + .f + .datagram(spoof_path, spoof, Lane::Clear, &forged, now); + accept.collect(); + assert!( + accept.requests.is_empty(), + "a forged check asks for no path: {:?}", + accept.requests + ); + + // A REPLAYED nomination (its integrity intact, its source spoofed) is only a request: media + // stays on the bound path until the host proves the new one by its own round trip. + let replayed = last_nomination(&dial); + accept + .f + .datagram(spoof_path, spoof, Lane::Clear, &replayed, now); + accept.collect(); + assert_eq!( + accept.requests, + [Request::Rebind(bound, spoof_path)], + "a request, never a move" + ); + accept.requests.clear(); + let media = accept + .pieces + .iter() + .find(|p| p.fields && p.bytes.starts_with(b"kind: audio")) + .map(|p| p.stream) + .expect("the audio line"); + accept.sent.clear(); + for _ in 0..5 { + accept.f.emit(media, OPUS, false, now).expect("a frame"); + now += Duration::from_millis(20); + accept.f.timeout(now); + while let Some(r) = accept.f.next_route() { + accept.sent.push(r); + } + } + let media_routes: Vec<&Route> = accept + .sent + .iter() + .filter(|r| !stun::is_stun(&r.bytes)) + .collect(); + assert!(!media_routes.is_empty(), "media was sent"); + assert!( + media_routes.iter().all(|r| r.path == bound), + "every media datagram went to the bound path, none to the spoofed one" + ); + // Protected media arriving from the unproven path is not read. + let before = accept.pieces.len(); + accept.f.datagram( + spoof_path, + spoof, + Lane::Clear, + &[0x80, 0x6f, 0, 2, 0, 0, 0, 0, 0, 0, 0, 1, 9, 9, 9, 9], + now, + ); + accept.collect(); + assert_eq!( + accept.pieces.len(), + before, + "nothing read from an unbound path" + ); + let _ = &mut dial; +} + +#[test] +fn the_keying_material_is_handed_on_and_not_held() { + let (dial, accept, _) = connected(); + assert!(!dial.f.holds_keying() && !accept.f.holds_keying()); +} + +#[test] +fn opus_frame_lengths_come_from_the_toc_byte() { + // CELT 20 ms, one frame. + assert_eq!(opus_samples(&[0xF8]), 960); + // SILK 10 ms (config 0), two frames (code 1). + assert_eq!(opus_samples(&[0x01]), 960); + // Hybrid 20 ms (config 13), one frame. + assert_eq!(opus_samples(&[13 << 3]), 960); + // CELT 2.5 ms (config 16), code 3 with 6 frames. + assert_eq!(opus_samples(&[(16 << 3) | 3, 6]), 720); + assert_eq!(opus_samples(&[]), 0); +} + +#[test] +fn a_dialling_framing_with_nothing_to_offer_is_refused() { + let r = Framing::begin(Side::Dial, CERT, None, &Opening::default(), Instant::now()); + assert_eq!( + r.err(), + Some("a dialling framing offers nothing".to_string()) + ); +} diff --git a/crates/busbar-transport-webrtc/tests/conformance.rs b/crates/busbar-transport-webrtc/tests/conformance.rs new file mode 100644 index 0000000000..c866594047 --- /dev/null +++ b/crates/busbar-transport-webrtc/tests/conformance.rs @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! **ONE FRAMER, BOTH DOORS, ONE TABLE** — the `webrtc` framer compiled in (`linked::door`, the +//! `linked` row) and dropped in (this crate's `cdylib` with its `dropped-in` door, `busbar_plugin_door`), +//! each admitted by the ONE loader against the same Statement rendering and bound to a dispatcher +//! of its own (Part 2 #2: a plugin is a plugin; THE DESIGN §11.4: compiled in or dropped in, the +//! same table). The pattern is `plugin-loader`'s export both-ways proof +//! (`src/tests/export_conformance_tests.rs`): one script, two doors, equal transcripts. +//! +//! The published suite's transport script drives a STREAM framer (its `begin` carries no lane, its +//! `emit` becomes wire bytes); a DATAGRAM framer's every call reads its lane, so the script here is +//! the datagram one: the Statement's tail, the lifecycle, the carrier ops it does not play, the +//! framer ops it refuses, a `begin` without a lane refused, a dialled `begin` over a lane answering +//! its session description, a rendezvous that is no description failing the framing, an accepted +//! framing finished, and the instance closed. +//! +//! THE RED ARM, kept: the dropped-in library admitted against a Statement rendering that is not its +//! own is refused, so the equality the witness asserts is between two admitted doors. + +use busbar_contract::abi::mechanism::call::{AbiStr, Field}; +use busbar_contract::abi::transport::{ + slot, BeginIn, ConnFacts, DatagramLane, DatagramRoute, EncodeIn, FinishIn, FramePiece, + FramerOut, FramerSink, IngestIn, ListenIn, ListenOut, LocateIn, LocateOut, LANE_RENDEZVOUS, + PIECE_END_OF_FRAME, PIECE_TEXT, ROLE_FRAMER, SIDE_ACCEPT, SIDE_DIAL, YIELD_ENDED, YIELD_MORE, +}; +use busbar_plugin_loader::conformance::{self as conf, Leg, Subject}; +use busbar_plugin_loader::dispatch::kinds::transport::{Transport, TransportFacts}; +use busbar_plugin_loader::dispatch::{load_dropped, Frame, InFrame, LinkedRow, OutFrame, Plugin}; + +/// The crate whose cdylib is the dropped-in door. +const CDYLIB: &str = "busbar_transport_webrtc"; +/// The host port's address, the one candidate the framer advertises. +const LOCAL: &str = "198.51.100.1:40000"; +/// The host's public certificate (the framer states its SHA-256; nothing else reads it). +const CERT: &[u8] = b"the host's public certificate, DER"; + +fn subject() -> Subject { + Subject::new(busbar_transport_webrtc::linked::door, CDYLIB, "{}") +} + +const fn abi(s: &[u8]) -> AbiStr { + AbiStr { + ptr: s.as_ptr(), + len: s.len(), + } +} + +const NONE: AbiStr = AbiStr { + ptr: std::ptr::null(), + len: 0, +}; + +/// The host's buffers for one framer op. +struct Host { + wire: Vec, + frame: Vec, + pieces: Vec, + routes: Vec, +} + +impl Host { + fn new() -> Self { + let piece = FramePiece { + stream: 0, + offset: 0, + len: 0, + code: 0, + status_class: 0, + _reserved: 0, + flags: 0, + retry_after_secs: 0, + }; + Self { + wire: vec![0; 16 * 1024], + frame: vec![0; 16 * 1024], + pieces: vec![piece; 16], + routes: vec![DatagramRoute::default(); 16], + } + } + + fn sink(&mut self) -> FramerSink { + FramerSink { + wire: self.wire.as_mut_ptr(), + wire_cap: self.wire.len(), + frame: self.frame.as_mut_ptr(), + frame_cap: self.frame.len(), + pieces: self.pieces.as_mut_ptr(), + pieces_cap: self.pieces.len(), + now_monotonic_ns: 1, + now_unix_ns: 1, + heads: std::ptr::null_mut(), + heads_cap: 0, + } + } + + /// The lane of a call ingesting on `lane` (`0`: nothing), nothing bound or verified yet. + fn lane(&mut self, lane: u32) -> DatagramLane { + DatagramLane { + size: std::mem::size_of::() as u32, + lane, + path: 0, + bound: 0, + path_addr: NONE, + local_addr: abi(LOCAL.as_bytes()), + verified: 0, + _reserved: 0, + keying: std::ptr::null(), + routes: self.routes.as_mut_ptr(), + routes_cap: self.routes.len(), + paths: std::ptr::null(), + paths_len: 0, + } + } + + /// What the last answer yielded, as one deterministic line: the pieces (stream, flags, and for + /// the description the facts a host reads of it), never the random credentials. + fn yielded(&self, o: &FramerOut) -> String { + let n = (o.yielded.pieces_len as usize).min(self.pieces.len()); + let pieces: Vec = self.pieces[..n] + .iter() + .map(|p| { + let at = p.offset as usize; + let bytes = &self.frame[at..at + p.len as usize]; + let text = String::from_utf8_lossy(bytes); + let described = p.stream == 0 + && text.starts_with("v=0") + && text.contains("a=fingerprint:sha-256 ") + && text.contains("198.51.100.1 40000 typ host") + && text.contains("m=application"); + format!( + "stream={} flags={} described={described}", + p.stream, p.flags + ) + }) + .collect(); + format!( + "pieces={pieces:?} routes={} flags={}", + o.datagram.routes_len, + o.yielded.flags & (YIELD_ENDED | YIELD_MORE) + ) + } +} + +fn go(p: &Plugin, s: u32, i: I) -> (String, O) { + let mut f = Frame::new(i, conf::output::()); + let c = p.call(s, &mut f); + (conf::called(&c), f.out) +} + +/// `begin` on `side`, with the host's certificate and (when `lane`) a lane: its line and token. +fn begin(p: &Plugin, side: u32, lane: bool) -> (String, u64) { + let mut host = Host::new(); + let facts = ConnFacts { + size: std::mem::size_of::() as u32, + _reserved: 0, + offered_name: NONE, + agreed_protocol: NONE, + peer_subject: NONE, + peer_issuer: NONE, + peer_fingerprint: NONE, + claim: NONE, + local_certificate: abi(CERT), + }; + let fields = [Field { + name: abi(b"channel"), + value: abi(b"events"), + }]; + let l = host.lane(0); + let mut i: BeginIn = conf::input(); + i.side = side; + i.facts = &facts; + i.sink = host.sink(); + if side == SIDE_DIAL { + (i.fields, i.fields_len) = (fields.as_ptr(), fields.len()); + } + if lane { + i.lane = &l; + } + let (called, o): (String, FramerOut) = go(p, slot::BEGIN, i); + ( + format!("{called} framing={} {}", o.framing != 0, host.yielded(&o)), + o.framing, + ) +} + +/// One leg's transcript. +fn fold(s: &Subject, leg: Leg) -> Vec { + let d = conf::dispatcher(); + let p = conf::load::(s, leg, s.bind(&d, "webrtc")).expect("the door loads"); + let facts = p + .context::() + .cloned() + .expect("a transport states its tail"); + let mut t = vec![format!( + "role={} claims={:?} composes_over={:?}", + facts.role, facts.claims, facts.composes_over + )]; + let settings = s.settings(); + t.push(format!( + "validate {}", + conf::called(&conf::validate(&p, &settings)) + )); + t.push(format!("open {}", conf::called(&conf::open(&p, &settings)))); + + // The role it does not play, and the framer ops it has no use for. + t.push(format!( + "listen {}", + go::(&p, slot::LISTEN, conf::input()).0 + )); + t.push(format!( + "locate {}", + go::(&p, slot::LOCATE, conf::input()).0 + )); + let mut host = Host::new(); + let mut e: EncodeIn = conf::input(); + e.sink = host.sink(); + t.push(format!( + "encode {}", + go::(&p, slot::ENCODE, e).0 + )); + + // A datagram framer reads its lane. + t.push(format!( + "begin without a lane {}", + begin(&p, SIDE_DIAL, false).0 + )); + + // Dialled over a lane: the offer, at once, on stream 0. + let (line, token) = begin(&p, SIDE_DIAL, true); + t.push(format!("begin dial {line}")); + + // A rendezvous that is no description fails the framing, which is then forgotten. + let mut host = Host::new(); + let junk = b"not a session description"; + for step in ["rendezvous junk", "rendezvous after failure"] { + let l = host.lane(LANE_RENDEZVOUS); + let mut i: IngestIn = conf::input(); + (i.framing, i.bytes, i.len, i.sink, i.lane) = + (token, junk.as_ptr(), junk.len(), host.sink(), &l); + t.push(format!( + "{step} {}", + go::(&p, slot::INGEST, i).0 + )); + } + + // Accepted: it waits for the offer; finished, it ends, and is forgotten. + let (line, token) = begin(&p, SIDE_ACCEPT, true); + t.push(format!("begin accept {line}")); + for step in ["finish", "finish again"] { + let mut host = Host::new(); + let l = host.lane(0); + let mut i: FinishIn = conf::input(); + (i.framing, i.sink, i.lane) = (token, host.sink(), &l); + let (called, o): (String, FramerOut) = go(&p, slot::FINISH, i); + t.push(format!("{step} {called} {}", host.yielded(&o))); + } + t.push(format!("close {}", conf::called(&conf::close(&p)))); + t +} + +/// What the contract requires of each line, by its start. +fn expected() -> Vec { + let piece = PIECE_END_OF_FRAME | PIECE_TEXT; + vec![ + format!("role={ROLE_FRAMER} claims=[\"webrtc\"] composes_over=[]"), + "validate Ready lease=false ".into(), + "open Ready lease=false ".into(), + "listen Refused".into(), + "locate Failed".into(), + "encode Failed".into(), + "begin without a lane Failed lease=false begin: a datagram framer reads its lane".into(), + format!( + "begin dial Ready lease=false framing=true pieces=[\"stream=0 flags={piece} described=true\"] routes=0 flags=0" + ), + "rendezvous junk Failed".into(), + "rendezvous after failure Failed lease=false no such framing".into(), + "begin accept Ready lease=false framing=true pieces=[] routes=0 flags=0".into(), + format!("finish Ready lease=false pieces=[] routes=0 flags={YIELD_ENDED}"), + "finish again Failed lease=false finish: no such framing".into(), + "close Ready lease=false ".into(), + ] +} + +/// THE WITNESS: the dropped-in library states exactly the linked door's Statement, and the two +/// doors run the script to the same transcript, the one the contract requires. +#[test] +fn the_linked_and_the_dropped_in_framer_are_one_plugin_on_one_table() { + let s = subject(); + let linked_row = LinkedRow::of(s.door).expect("the linked door is admitted"); + assert_eq!( + linked_row.statement, + s.stated(), + "the dropped-in library states exactly the linked door's Statement" + ); + let linked = fold(&s, Leg::Linked); + let dropped = fold(&s, Leg::Dropped); + assert_eq!(linked, dropped, "the two doors part"); + let want = expected(); + assert_eq!(linked.len(), want.len(), "{linked:#?}"); + for (got, want) in linked.iter().zip(&want) { + assert!(got.starts_with(want.as_str()), "got: {got}\nwant: {want}…"); + } +} + +/// THE RED ARM: the dropped-in library admitted against a rendering that is not its own (one byte +/// of its Statement moved) is refused; its own is admitted. +#[test] +fn red_arm_a_dropped_in_library_stating_another_statement_is_refused() { + let s = subject(); + let d = conf::dispatcher(); + let own = s.stated(); + let mut other = own.clone(); + let last = other.len() - 1; + other[last] ^= 0x01; + assert!( + load_dropped::(&s.cdylib(), &other, s.bind(&d, "webrtc-other")).is_err(), + "a library admitted against another Statement must be refused" + ); + assert!(load_dropped::(&s.cdylib(), &own, s.bind(&d, "webrtc-own")).is_ok()); + assert!(conf::load::(&s, Leg::Linked, s.bind(&d, "webrtc-linked")).is_ok()); +} diff --git a/crates/busbar-transport-webrtc/tests/dependency_closure.rs b/crates/busbar-transport-webrtc/tests/dependency_closure.rs new file mode 100644 index 0000000000..9cb379ba76 --- /dev/null +++ b/crates/busbar-transport-webrtc/tests/dependency_closure.rs @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE ONE EXCEPTION TO `ring`-ONLY, CONFINED (THE DESIGN l.753, "Datagram media"): the +//! `aes`/`cipher`/`inout` crates enter the build for the SRTP key derivation alone. +//! +//! * In the resolved graph (`Cargo.lock`, every feature any member turns on): `aes` is depended on +//! by this crate and nothing else, `cipher` by `aes` alone and `inout` by `cipher` alone. +//! * In this crate's source: `aes` is named only by `src/crypto.rs`, and there only by its import +//! and by the two SRTP key-derivation rounds (`srtp_aes_128_ecb_round`/`srtp_aes_256_ecb_round`), +//! the one AES block (`ecb_round`) having no other caller. +//! +//! THE RED ARMS, kept: the same checks over a graph where another crate takes `aes` (or `cipher` +//! straight), and over a source where the framing names `aes`, must refuse. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// The crate the exception is granted to. +const GRANTED: &str = "busbar-transport-webrtc"; + +/// Each confined crate and the one crate allowed to depend on it. +const CONFINED: [(&str, &str); 3] = [("aes", GRANTED), ("cipher", "aes"), ("inout", "cipher")]; + +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("the workspace root") + .to_path_buf() +} + +/// Every package in `lock` and the names it depends on. +fn graph(lock: &str) -> BTreeMap> { + let mut out: BTreeMap> = BTreeMap::new(); + for block in lock.split("[[package]]").skip(1) { + let mut name = None; + let mut deps = Vec::new(); + let mut in_deps = false; + for line in block.lines().map(str::trim) { + if let Some(n) = line.strip_prefix("name = ") { + name = Some(n.trim_matches('"').to_owned()); + } else if line.starts_with("dependencies = [") { + in_deps = true; + } else if in_deps && line.starts_with(']') { + in_deps = false; + } else if in_deps { + // `"name"`, `"name 1.2.3"` or `"name 1.2.3 (registry+…)"`. + let entry = line.trim_end_matches(',').trim_matches('"'); + if let Some(dep) = entry.split_whitespace().next() { + deps.push(dep.to_owned()); + } + } + } + if let Some(n) = name { + out.entry(n).or_default().extend(deps); + } + } + out +} + +/// `Err` naming the first package that depends on a confined crate it may not. +fn confined(lock: &str) -> Result<(), String> { + let g = graph(lock); + if !g.get(GRANTED).is_some_and(|d| d.iter().any(|x| x == "aes")) { + return Err(format!( + "{GRANTED} does not depend on aes: the lock is not this tree's" + )); + } + for (krate, only) in CONFINED { + for (from, deps) in &g { + if deps.iter().any(|d| d == krate) && from != only { + return Err(format!( + "{from} depends on {krate}: only {only} may (the SRTP key derivation's exception)" + )); + } + } + } + Ok(()) +} + +/// Every non-comment line of `text` that names `needle` as code. +fn naming<'a>(text: &'a str, needle: &str) -> Vec<&'a str> { + text.lines() + .map(|l| l.split("//").next().unwrap_or_default().trim()) + .filter(|l| l.contains(needle)) + .collect() +} + +/// The lines of the crypto file that may name `aes`: its import and the two SRTP rounds. +const CRYPTO_AES_LINES: [&str; 3] = [ + "use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};", + "ecb_round::(key, input, output);", + "ecb_round::(key, input, output);", +]; + +/// `Err` naming the first source line that names `aes` (or its block traits) outside the SRTP key +/// derivation. `files` are `(path relative to src, text)`. +fn source_confined(files: &[(String, String)]) -> Result<(), String> { + let mut rounds = 0; + for (path, text) in files { + for needle in [ + "aes::", + "cipher::", + "inout::", + "BlockEncrypt", + "KeyInit", + "ecb_round", + ] { + for line in naming(text, needle) { + let allowed = path == "crypto.rs" + && (CRYPTO_AES_LINES.contains(&line) + || line.starts_with("fn ecb_round(") + || line.starts_with("fn srtp_aes_128_ecb_round(") + || line.starts_with("fn srtp_aes_256_ecb_round(")); + if !allowed { + return Err(format!("src/{path} names `{needle}`: {line}")); + } + } + } + if path == "crypto.rs" { + rounds = naming(text, "fn srtp_aes_") + .iter() + .filter(|l| l.contains("_ecb_round(")) + .count(); + } + } + if rounds != 2 { + return Err(format!( + "{rounds} SRTP derivation rounds in src/crypto.rs, expected 2" + )); + } + Ok(()) +} + +fn sources(dir: &Path) -> Vec<(String, String)> { + let mut out = Vec::new(); + for e in std::fs::read_dir(dir).expect("src is readable") { + let p = e.expect("an entry").path(); + let name = p + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_owned(); + if p.is_dir() { + // The crate's own tests (`src/tests/`) name what they test; they never ship. + if name != "tests" { + out.extend( + sources(&p) + .into_iter() + .map(|(n, t)| (format!("{name}/{n}"), t)), + ); + } + } else if name.ends_with(".rs") { + out.push((name, std::fs::read_to_string(&p).expect("a source file"))); + } + } + out +} + +#[test] +fn aes_cipher_and_inout_are_confined_to_the_srtp_key_derivation_in_the_resolved_graph() { + let lock = std::fs::read_to_string(root().join("Cargo.lock")).expect("the workspace lock"); + confined(&lock).unwrap_or_else(|e| panic!("{e}")); +} + +#[test] +fn aes_is_named_only_by_the_srtp_key_derivation_in_the_source() { + let mine = sources(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src")); + assert!( + mine.iter().any(|(p, _)| p == "crypto.rs"), + "src/crypto.rs is read" + ); + source_confined(&mine).unwrap_or_else(|e| panic!("{e}")); +} + +// ── THE RED ARMS ──────────────────────────────────────────────────────────────────────────────── + +/// The real lock with `extra` appended as a package of its own. +fn with_package(extra: &str) -> String { + let lock = std::fs::read_to_string(root().join("Cargo.lock")).expect("the workspace lock"); + format!("{lock}\n[[package]]\n{extra}") +} + +#[test] +fn red_arm_another_crate_taking_aes_or_cipher_is_refused() { + let aes = with_package("name = \"busbar-kernel-extra\"\nversion = \"1.6.0\"\ndependencies = [\n \"aes\",\n \"ring\",\n]\n"); + let refused = confined(&aes).expect_err("a second crate on aes is refused"); + assert!( + refused.contains("busbar-kernel-extra depends on aes"), + "{refused}" + ); + + let cipher = with_package( + "name = \"stream-thing\"\nversion = \"0.1.0\"\ndependencies = [\n \"cipher 0.4.4\",\n]\n", + ); + let refused = confined(&cipher).expect_err("a crate on cipher straight is refused"); + assert!( + refused.contains("stream-thing depends on cipher"), + "{refused}" + ); + + assert!( + confined("").is_err(), + "a lock without the grant is not this tree's" + ); +} + +#[test] +fn red_arm_the_framing_naming_aes_is_refused() { + let mut files = sources(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src")); + files.push(( + "framing.rs".to_owned(), + "fn key(k: &[u8]) { let _ = aes::Aes128::new_from_slice(k); }".to_owned(), + )); + let refused = source_confined(&files).expect_err("aes outside the derivation is refused"); + assert!( + refused.contains("src/framing.rs names `aes::`"), + "{refused}" + ); + + // A third caller of the AES block, even inside the crypto file, is refused too. + let crypto = files + .iter() + .position(|(p, _)| p == "crypto.rs") + .expect("crypto.rs"); + files[crypto] + .1 + .push_str("\nfn more(k: &[u8]) { ecb_round::(k, k, &mut []); }\n"); + files.pop(); + assert!(source_confined(&files).is_err()); +} diff --git a/crates/busbar-transport-webrtc/tests/no_dtls.rs b/crates/busbar-transport-webrtc/tests/no_dtls.rs new file mode 100644 index 0000000000..688bcf9efb --- /dev/null +++ b/crates/busbar-transport-webrtc/tests/no_dtls.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE FRAMER HOLDS NO DTLS (THE DESIGN l.749-757 and the who-sees-what row, l.657): DTLS runs in +//! the host's secure layer, and the framer never sees its keys, the certificate's private key or +//! its state. So the framer's source names no DTLS type: +//! +//! * no file of this crate (its dropped-in door included) names a DTLS ENGINE (`dimpl`, `rustls`, OpenSSL +//! and its forks, `webrtc-dtls`, mbed TLS, wolfSSL), as code or as a dependency; +//! * no file but `src/shim.rs` names anything DTLS at all. The shim is the one adapter between the +//! media stack's DTLS interface (the traits it calls in place of a handshake) and the host's +//! lane: it relays the exported keying material and the verified bit, and runs no handshake. +//! +//! THE RED ARMS, kept: the same scan over a shim that names an engine, and over a framing that +//! names the media stack's DTLS interface, must refuse. + +use std::path::{Path, PathBuf}; + +/// The DTLS engines: no file here may name one. +const ENGINES: [&str; 8] = [ + "dimpl", + "rustls", + "openssl", + "boring", + "webrtc_dtls", + "webrtc-dtls", + "mbedtls", + "wolfssl", +]; + +/// The one file allowed to name the media stack's DTLS interface. +const SHIM: &str = "shim.rs"; + +fn crate_dir(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates/") + .join(name) +} + +/// The line without its comment (`//` for Rust, `#` for a manifest). +fn code(line: &str, comment: &str) -> String { + line.split(comment) + .next() + .unwrap_or_default() + .to_ascii_lowercase() +} + +/// What `path` (relative to its crate) names that it may not. +fn findings(path: &str, text: &str) -> Vec { + let manifest = path.ends_with(".toml"); + let comment = if manifest { "#" } else { "//" }; + let file = path.rsplit('/').next().unwrap_or(path); + let mut out = Vec::new(); + for (n, line) in text.lines().enumerate() { + let code = code(line, comment); + for engine in ENGINES { + if code.contains(engine) { + out.push(format!( + "{path}:{}: names the DTLS engine `{engine}`", + n + 1 + )); + } + } + if !manifest && file != SHIM && code.contains("dtls") { + out.push(format!( + "{path}:{}: names DTLS outside the shim: {}", + n + 1, + line.trim() + )); + } + } + out +} + +/// Every shipped source file under `dir/src` (the crate's own `src/tests/` aside: tests name +/// what they test) and the crate's manifest, as `(path, text)`. +fn shipped(dir: &Path) -> Vec<(String, String)> { + fn walk(d: &Path, rel: &str, out: &mut Vec<(String, String)>) { + for e in std::fs::read_dir(d).expect("readable") { + let p = e.expect("an entry").path(); + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + let at = format!("{rel}/{name}"); + if p.is_dir() { + if name != "tests" { + walk(&p, &at, out); + } + } else if name.ends_with(".rs") { + out.push((at, std::fs::read_to_string(&p).expect("a source file"))); + } + } + } + let mut out = vec![( + "Cargo.toml".to_owned(), + std::fs::read_to_string(dir.join("Cargo.toml")).expect("the manifest"), + )]; + walk(&dir.join("src"), "src", &mut out); + out +} + +fn scan(files: &[(String, String)]) -> Vec { + files.iter().flat_map(|(p, t)| findings(p, t)).collect() +} + +#[test] +fn the_framer_source_names_no_dtls_type() { + let files = shipped(&crate_dir("busbar-transport-webrtc")); + assert!(files.len() > 1, "the source is read"); + let found = scan(&files); + assert!(found.is_empty(), "{}", found.join("\n")); + // The scan read the shim with its allowance: the shim is where the interface is named. + let shim = shipped(&crate_dir("busbar-transport-webrtc")) + .into_iter() + .find(|(p, _)| p == "src/shim.rs") + .expect("src/shim.rs"); + assert!(shim.1.contains("impl DtlsInstance for Shim")); +} + +// ── THE RED ARMS ──────────────────────────────────────────────────────────────────────────────── + +#[test] +fn red_arm_an_engine_in_the_shim_or_the_manifest_is_refused() { + let found = findings("src/shim.rs", "use dimpl::{Config, Dtls};\n"); + assert_eq!(found.len(), 1, "{found:?}"); + assert!(found[0].contains("`dimpl`")); + let found = findings( + "Cargo.toml", + "[dependencies]\nrustls = { workspace = true } # a second secure layer\n", + ); + assert_eq!(found.len(), 1, "{found:?}"); + // A comment that names an engine is not code. + assert!(findings( + "src/framing.rs", + "// unlike dimpl, nothing here handshakes\n" + ) + .is_empty()); +} + +#[test] +fn red_arm_the_dtls_interface_outside_the_shim_is_refused() { + for (path, line) in [ + ("src/framing.rs", "use str0m::crypto::dtls::DtlsInstance;"), + ("src/door.rs", "let cert: DtlsCert = todo!();"), + ("src/crypto.rs", "impl DtlsProvider for Ring {}"), + ] { + let found = findings(path, line); + assert_eq!(found.len(), 1, "{path}: {found:?}"); + assert!(found[0].contains("outside the shim")); + } + // The same line in the shim is its job. + assert!(findings("src/shim.rs", "use str0m::crypto::dtls::DtlsInstance;").is_empty()); +} diff --git a/crates/busbar-transport-webrtc/tests/zeroise.rs b/crates/busbar-transport-webrtc/tests/zeroise.rs new file mode 100644 index 0000000000..166659b23a --- /dev/null +++ b/crates/busbar-transport-webrtc/tests/zeroise.rs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! KEYS ARE ZEROISED (THE DESIGN l.753): the keying material the framer holds is cleared before +//! its memory goes back to the allocator, whether it was held to the end or refused. +//! +//! THE WITNESS is this binary's allocator: every block freed is read first, and a block still +//! holding a marked key (eight marker bytes, then the key's id 48 times) is counted against that +//! id. Each test marks its own id, so tests running side by side never count each other's frees. +//! +//! THE RED ARM, kept: the same marked key in a plain `Vec`, dropped, IS seen, so a zero count +//! below is the zeroising, never a witness that cannot see. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use busbar_transport_webrtc::framing::{Framing, Opening, Side, PROFILE_AEAD_AES_128_GCM}; +use busbar_transport_webrtc::shim::Shim; + +const MARK: [u8; 8] = *b"ZEROISE!"; +/// A key: the marker and 48 bytes of its id (the AEAD-AES-128-GCM material's 56 bytes). +const KEY_LEN: usize = 56; +const IDS: usize = 8; + +static FREED_UNZEROED: [AtomicUsize; IDS] = [const { AtomicUsize::new(0) }; IDS]; + +/// The system allocator, reading every block it frees for a marked key. +struct Watch; + +/// The id of the marked key at the start of `w`, if one is there whole. +fn marked(w: &[u8]) -> Option { + let (mark, rest) = w.split_at_checked(MARK.len())?; + let id = *rest.first()?; + (mark == MARK + && rest.len() >= KEY_LEN - MARK.len() + && rest[..KEY_LEN - MARK.len()].iter().all(|b| *b == id)) + .then_some(usize::from(id)) + .filter(|id| *id < IDS) +} + +// SAFETY: every call is forwarded to `System` with its own arguments; `dealloc` only reads the +// block it is handed before forwarding. +unsafe impl GlobalAlloc for Watch { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + // SAFETY: the caller's contract, forwarded. + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + if layout.size() >= KEY_LEN { + // SAFETY: `ptr` is a live block of `layout.size()` bytes until `System` frees it below, + // and its owner has given it up: nothing else reads or writes it during `dealloc`. It + // is read as plain bytes and nothing read is kept. + let block = unsafe { std::slice::from_raw_parts(ptr, layout.size()) }; + for at in 0..=block.len() - KEY_LEN { + if block[at] == MARK[0] { + if let Some(id) = marked(&block[at..]) { + FREED_UNZEROED[id].fetch_add(1, Ordering::SeqCst); + } + } + } + } + // SAFETY: the caller's contract, forwarded. + unsafe { System.dealloc(ptr, layout) } + } +} + +#[global_allocator] +static WATCH: Watch = Watch; + +/// The marked key `id`, on the stack (the stack is not the allocator's to watch). +fn key(id: u8) -> [u8; KEY_LEN] { + let mut k = [id; KEY_LEN]; + k[..MARK.len()].copy_from_slice(&MARK); + k +} + +fn freed_unzeroed(id: u8) -> usize { + FREED_UNZEROED[usize::from(id)].load(Ordering::SeqCst) +} + +#[test] +fn keying_material_held_by_the_shim_is_zeroised_when_it_drops() { + let material = key(1); + let shim = Shim::default(); + shim.keyed(&material, PROFILE_AEAD_AES_128_GCM) + .expect("AEAD-AES-128-GCM is taken"); + assert!(shim.holds_keying(), "the material was held, on the heap"); + drop(shim); + assert_eq!(freed_unzeroed(1), 0, "the held material was freed unzeroed"); +} + +#[test] +fn keying_material_a_framing_refuses_is_zeroised() { + let material = key(2); + let now = Instant::now(); + let mut f = Framing::begin( + Side::Accept, + b"public certificate bytes", + Some("203.0.113.9:3478".parse().expect("an address")), + &Opening::default(), + now, + ) + .expect("begins"); + // Keying before verification: the framing copies the item, refuses it, and holds nothing. + assert!(f + .host(0, false, Some((PROFILE_AEAD_AES_128_GCM, &material)), now) + .is_err()); + assert!(!f.holds_keying()); + // AES_CM, verified: refused the same way. + assert!(f.host(0, true, Some((0x0001, &material)), now).is_err()); + assert!(!f.holds_keying()); + drop(f); + assert_eq!(freed_unzeroed(2), 0, "a refused item was freed unzeroed"); +} + +#[test] +fn red_arm_a_key_in_a_plain_vec_is_seen_freed_unzeroed() { + let material = key(3); + let plain: Vec = material.to_vec(); + drop(std::hint::black_box(plain)); + assert!( + freed_unzeroed(3) >= 1, + "the witness must see a key freed without zeroising" + ); + // And a zeroising holder of the same bytes is not seen. + let held = zeroize::Zeroizing::new(key(4).to_vec()); + drop(std::hint::black_box(held)); + assert_eq!(freed_unzeroed(4), 0); +} diff --git a/crates/busbar/Cargo.toml b/crates/busbar/Cargo.toml index d9ed4e298c..3c5c30e615 100644 --- a/crates/busbar/Cargo.toml +++ b/crates/busbar/Cargo.toml @@ -176,6 +176,10 @@ busbar-transport-grpc = { workspace = true } # exact leak `cargo xtask gate duplex-ws-default-edge` exists to catch. `plane-streaming` carries the edge. busbar-transport-ws = { workspace = true, optional = true } busbar-transport-stdio = { path = "../busbar-transport-stdio" } +# WebRTC: the datagram framer door (ICE, SRTP, SCTP) over the connector's datagram port and secure +# layer. Optional and off by default: its linked row is the wire's one registration, and a build +# that does not turn `transport-webrtc` on carries neither the crate nor its media stack. +busbar-transport-webrtc = { path = "../busbar-transport-webrtc", optional = true } # The root's card is SWAPPED by a live config apply and PINNED by every unit that prices against it, # which is one atomic store against many lock-free readers — the same shape, and the same crate, the # engine already swaps its config snapshot with. @@ -253,6 +257,8 @@ plane-streaming = ["dep:busbar-voice", "transport-ws", "busbar-voice?/runtime", # THE WS WIRE, the one optional transport: the voice plane's edge and nothing else's, so `plane-streaming` # carries it and `--no-default-features` drops the crate edge and its linked row together. transport-ws = ["dep:busbar-transport-ws"] +# THE WEBRTC WIRE, optional and never in `default`: on, the crate edge and its linked row together. +transport-webrtc = ["dep:busbar-transport-webrtc"] # THE TCP WIRE'S LINKED ROW, DEFAULT ON (#3: a transport is swappable, compiled in OR dropped in). Off, # the build links no `tcp` row and the wire is served only when its tarball is dropped into # `plugins/`: the boot seal folds it in the same pass as the linked rows, and the data door accepts @@ -377,6 +383,7 @@ transport-http = "busbar-transport-http" transport-ws = "busbar-transport-ws" transport-grpc = "busbar-transport-grpc" transport-stdio = "busbar-transport-stdio" +transport-webrtc = "busbar-transport-webrtc" # THE PLANE DOOR AXIS (#2, THE DESIGN section 11.4): a plane's memory-ABI `door`, bound through the # loader's one load beside the dropped-in plane doors. plane-decisions = "busbar-plane-decisions" @@ -423,6 +430,7 @@ transport-http = "transport transport-door connector-door" transport-ws = "transport transport-door connector-door" transport-grpc = "transport transport-door" transport-stdio = "transport transport-door" +transport-webrtc = "transport transport-door" plane-decisions = "plane-door" llm-on-driver = "plane-door" plane-mcp-door = "plane-door" diff --git a/crates/busbar/src/root/serve.rs b/crates/busbar/src/root/serve.rs index 83e4f32059..b56e67cc92 100644 --- a/crates/busbar/src/root/serve.rs +++ b/crates/busbar/src/root/serve.rs @@ -3370,7 +3370,7 @@ impl http_body::Body for ReplyBody { /// THE FRAMER THAT FRAMES A CLAIM'S STREAM, when another framer than the data listener's own /// answers its carrier (ARCHITECT 4l). The root's own line carrier (a process's stdin/stdout, /// [`lines`]) frames its lines itself: each arrives already one unit, so it is never handed to a -/// framer, even one that claims its carrier's name (the stdio transport's). Handing it over stalled +/// framer, even one that claims its carrier's name (the line transport's). Handing it over stalled /// every line served on the carrier. fn framed_by( carrier: &str, diff --git a/crates/busbar/src/root/serve_lines.rs b/crates/busbar/src/root/serve_lines.rs index 8c3d56aaaf..9c56d2f8fc 100644 --- a/crates/busbar/src/root/serve_lines.rs +++ b/crates/busbar/src/root/serve_lines.rs @@ -4,7 +4,7 @@ //! THE LINE CARRIER (SEAM-S1; ARCHITECT Q1a, consistent with round 4 Q-L3B-STDIO-SHAPE (B)): the //! process's own stdin/stdout, held open as ONE carrier session for one caller. busbar is somebody's //! child process here, binds no listener, and serves the door claim a served plane makes over the -//! stdio transport's key. +//! line carrier's key ([`LINE_CARRIER`]). //! //! - The session is only the CARRIER ([`LineCaller`], the driver's one `SessionCaller` for a held //! carrier): each line it reads is ONE unit, opened exactly as a data-listener arrival is (its own @@ -31,7 +31,7 @@ use tokio::sync::{mpsc, Mutex as AsyncMutex}; use super::{DoorRequest, Served}; -/// The stdio transport's claim key: the claims made over it are the line carrier's. +/// The line transport's claim key: the claims made over it are the line carrier's. pub const LINE_CARRIER: &str = "stdio"; /// The environment variable carrying the session credential for the plane keyed `plane` (its diff --git a/crates/busbar/src/root/tests/fixtures/transport_fold.txt b/crates/busbar/src/root/tests/fixtures/transport_fold.txt index 3d3fc267d9..d64f83622a 100644 --- a/crates/busbar/src/root/tests/fixtures/transport_fold.txt +++ b/crates/busbar/src/root/tests/fixtures/transport_fold.txt @@ -10,3 +10,4 @@ sse - ws - grpc - stdio - +webrtc - diff --git a/crates/busbar/tests/fixtures/transport_sessions.txt b/crates/busbar/tests/fixtures/transport_sessions.txt index c6a2f3313e..e3d0668246 100644 --- a/crates/busbar/tests/fixtures/transport_sessions.txt +++ b/crates/busbar/tests/fixtures/transport_sessions.txt @@ -10,3 +10,4 @@ http - ws session grpc session stdio session +webrtc session diff --git a/crates/busbar/tests/plane_transport_neutrality.rs b/crates/busbar/tests/plane_transport_neutrality.rs index 5dce170c00..06b228aeb5 100644 --- a/crates/busbar/tests/plane_transport_neutrality.rs +++ b/crates/busbar/tests/plane_transport_neutrality.rs @@ -8,7 +8,7 @@ //! learn a protocol's TRANSPORT vocabulary. plane-purity-lint bans the plane KEYS (mcp/a2a/llm/voice) //! and the LLM dialects; the Plane-4 (busbar-voice) duplex/live-voice plane drags in a SECOND //! vocabulary that plane-purity does not name — the transport/media nouns -//! `rtc / sdp / webrtc / twilio / dtmf / rtp / sideband / realtime / audio / mulaw / g711 / barge`. +//! of `tests/fixtures/session_transport_nouns.txt` (`rtc / sdp / dtmf / rtp / audio / mulaw / …`). //! A leak of any of them into a neutral crate is the forward-edge regression the plane ABI exists to //! prevent. //! @@ -98,7 +98,7 @@ const NOUN_EXEMPTIONS: &[(&str, &str, &str)] = &[( usage object reports, modelled since 1.2 and a release older than the fourth plane. It names a \ UNIT OF BILLABLE WORK, exactly as its two siblings do; nothing about a carrier, a codec or a \ session reaches this type. The vocabulary this gate exists to keep out is the one a duplex \ - session drags in (rtc/sdp/webrtc/dtmf/rtp/mulaw/g711/barge) — spelling the third modality \ + session drags in (rtc/sdp/dtmf/rtp/mulaw/g711/barge) — spelling the third modality \ obliquely while `input_text` and `input_image` stay plain would cost a reader the meaning of \ the field and buy no neutrality at all.", )]; @@ -177,7 +177,7 @@ fn strip_comments(line: &str, in_block: &mut bool) -> String { } /// Capitalize a noun for the CamelCase rule: first byte upper, the rest lower (`sdp`→`Sdp`, -/// `webrtc`→`Webrtc`, `g711`→`G711`). +/// `mulaw`→`Mulaw`, `g711`→`G711`). fn capitalized(noun: &str) -> String { let mut cs = noun.chars(); match cs.next() { @@ -191,7 +191,7 @@ fn capitalized(noun: &str) -> String { fn hit_in_code(code: &str) -> Option<&'static str> { // WORD rule: tokenize into maximal identifier runs, then split each run on `_` and compare every // SEGMENT case-insensitively. `_` is a boundary because snake_case is how Rust spells a compound - // name: `sdp_offer`, `rtp_stream`, `webrtc_session` and `input_audio` all carry the banned noun + // name: `sdp_offer`, `rtp_stream`, `dtmf_digit` and `input_audio` all carry the banned noun // just as plainly as the bare word does. Treating `_` as part of the identifier would let the // entire snake_case half of the language through a gate whose whole subject is Rust source. for run in code.split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_')) { @@ -306,20 +306,20 @@ fn neutral_crates_name_no_session_transport_noun() { } /// SELF-TEST (the detector is not vacuous): the SAME detector must fire on a planted CamelCase -/// `SdpOffer`, a bare `webrtc` word AND an underscore-joined `input_audio`, while still ignoring +/// `SdpOffer`, a bare `mulaw` word AND an underscore-joined `input_audio`, while still ignoring /// comments. A green real witness means nothing if the detector cannot see a leak. #[test] fn detector_fires_on_planted_transport_nouns_and_ignores_comments() { // RED: real code — a CamelCase type, a bare word in a string, a lowercase word. - let red = "pub struct SdpOffer;\nfn f() { let k = \"webrtc\"; }\nlet realtime = true;"; + let red = "pub struct SdpOffer;\nfn f() { let k = \"mulaw\"; }\nlet realtime = true;"; let red_hits = scan_source(red); assert!( red_hits.iter().any(|(_, n, _)| *n == "sdp"), "detector missed the CamelCase SdpOffer: {red_hits:?}" ); assert!( - red_hits.iter().any(|(_, n, _)| *n == "webrtc"), - "detector missed the bare `webrtc` word: {red_hits:?}" + red_hits.iter().any(|(_, n, _)| *n == "mulaw"), + "detector missed the bare `mulaw` word: {red_hits:?}" ); assert!( red_hits.iter().any(|(_, n, _)| *n == "realtime"), @@ -332,7 +332,7 @@ fn detector_fires_on_planted_transport_nouns_and_ignores_comments() { ("pub struct S { pub sdp_offer: u8 }", "sdp"), ("fn f(rtp_stream: u8) {}", "rtp"), ("pub input_audio: Option,", "audio"), - ("let n = webrtc_session_id;", "webrtc"), + ("let n = dtmf_digit_count;", "dtmf"), ] { let hits = scan_source(src); assert!( @@ -342,7 +342,7 @@ fn detector_fires_on_planted_transport_nouns_and_ignores_comments() { } // GREEN: comments only — a doc-comment that discusses the vocabulary is not a leak. - let green = "// a comment naming sdp webrtc audio and SdpOffer and sdp_offer\n\ + let green = "// a comment naming sdp mulaw audio and SdpOffer and sdp_offer\n\ /* block naming rtp and barge and input_audio */\n\ pub struct PlaneRecord;"; let green_hits = scan_source(green); diff --git a/crates/plugin-loader/src/conformance/mod.rs b/crates/plugin-loader/src/conformance/mod.rs index e099ab661e..d02258e41e 100644 --- a/crates/plugin-loader/src/conformance/mod.rs +++ b/crates/plugin-loader/src/conformance/mod.rs @@ -284,9 +284,9 @@ impl Subject { KindCode::from_raw(raw).unwrap_or_else(|| panic!("the door names kind {raw}")) } - /// The dropped-in library: this crate's cdylib in the test binary's target directory, uplifted - /// or under `deps` with its metadata hash, newest wins. A missing one is a FAILURE: this suite - /// IS the dropped-in door's proof, and never skips. + /// The dropped-in library: this crate's cdylib in the test binary's target directory, the one + /// image that exports the dropped-in door, chosen by rank, never by age ([`cdylib_of`]). A + /// missing one is a FAILURE: this suite IS the dropped-in door's proof, and never skips. /// /// # Panics /// When the cdylib is not built. @@ -465,16 +465,24 @@ impl Subject { /// `plugin-ci.yml`'s conformance step sets it). It wins over every other image. pub const CDYLIB_ENV: &str = "BUSBAR_CONFORMANCE_CDYLIB"; -/// `crate_snake`'s cdylib, CHOSEN EXPLICITLY, never by age (audit loader-conformance #13): +/// `crate_snake`'s cdylib, CHOSEN EXPLICITLY, never by age (audit loader-conformance #13), and only +/// ever an image that EXPORTS the dropped-in door (`busbar_plugin_door`): /// /// 1. the path [`CDYLIB_ENV`] names (this run's cargo build output); -/// 2. else the image cargo UPLIFTED for the crate's latest build (`target//` or -/// `target//examples/`, unhashed: cargo's own pointer to that unit's newest -/// output); -/// 3. else the ONE hashed image under `deps/` (or `examples/`); several images with different +/// 2. else the image of the crate's own lib unit, `target//deps/`: cargo names a +/// workspace cdylib with no metadata hash, and a TEST build never uplifts it (the lib is not a +/// root of `cargo test`), so this is where the test build that turned on the crate's +/// `dropped-in` feature put its image; +/// 3. else the image cargo UPLIFTED (`target//` or `target//examples/`, +/// unhashed: a root unit's newest output, an example's under `cargo test`); +/// 4. else the ONE hashed image under `deps/` (or `examples/`); several images with different /// bytes (stale builds, another feature set's hash) are refused, naming them and /// [`CDYLIB_ENV`], since nothing says which one this run built. /// +/// A candidate that exports no door is passed over at every rank: an earlier plain build of the +/// crate (no `dropped-in` feature) leaves an uplifted image with no door, and it is never the +/// dropped-in leg. When only such images are built the choice fails, naming them. +/// /// # Panics /// When none is built, the named one does not exist, or the choice is ambiguous. #[must_use] @@ -495,16 +503,30 @@ pub fn cdylib_of(crate_snake: &str) -> PathBuf { choose_cdylib(profile, crate_snake).unwrap_or_else(|e| panic!("{e}")) } -/// [`cdylib_of`]'s choice under `profile` (`target/`), [`CDYLIB_ENV`] aside. +/// [`cdylib_of`]'s choice under `profile` (`target/`), [`CDYLIB_ENV`] aside: an image +/// exports the door when it loads and its `busbar_plugin_door` resolves. /// /// # Errors -/// None is built, or several different images are and none is uplifted. +/// None is built, none built exports the door, or several different images rank first. pub(crate) fn choose_cdylib(profile: &Path, crate_snake: &str) -> Result { + choose_cdylib_by(profile, crate_snake, &|p: &Path| { + matches!( + crate::dispatch::load::door_addresses_of_library(p), + Ok(Some(_)) + ) + }) +} + +/// [`choose_cdylib`] with `exports_door` deciding which images export the dropped-in door. +/// +/// # Errors +/// As [`choose_cdylib`]. +pub(crate) fn choose_cdylib_by( + profile: &Path, + crate_snake: &str, + exports_door: &dyn Fn(&Path) -> bool, +) -> Result { let name = crate::plugin_library_filename(crate_snake); - let uplifted = [profile.join(&name), profile.join("examples").join(&name)]; - if let Some(up) = uplifted.iter().find(|p| p.is_file()) { - return Ok(up.clone()); - } let (prefix, suffix) = name .split_once(crate_snake) .expect("the library name carries the crate's"); @@ -524,24 +546,49 @@ pub(crate) fn choose_cdylib(profile: &Path, crate_snake: &str) -> Result, PathBuf)> = Vec::new(); - for p in hashed { - let bytes = std::fs::read(&p).unwrap_or_default(); - if !images.iter().any(|(b, _)| *b == bytes) { - images.push((bytes, p)); + let ranks = [ + vec![profile.join("deps").join(&name)], + vec![profile.join(&name), profile.join("examples").join(&name)], + hashed, + ]; + let mut doorless: Vec = Vec::new(); + for rank in ranks { + // One image under two names (a hard link, a copy) is one image. + let mut images: Vec<(Vec, PathBuf)> = Vec::new(); + for p in rank.into_iter().filter(|p| p.is_file()) { + let bytes = std::fs::read(&p).unwrap_or_default(); + if images.iter().any(|(b, _)| *b == bytes) { + continue; + } + if exports_door(&p) { + images.push((bytes, p)); + } else { + doorless.push(p); + } + } + match images.len() { + 0 => {} + 1 => return Ok(images.remove(0).1), + _ => { + return Err(format!( + "the plugin's cdylib ({name}) is built more than once under {profile:?} \ + ({:?}); name this run's image in {CDYLIB_ENV}", + images.iter().map(|(_, p)| p).collect::>() + )) + } } } - match images.len() { - 0 => Err(format!( + if doorless.is_empty() { + Err(format!( "the plugin's cdylib ({name}) is not built under {profile:?}" - )), - 1 => Ok(images.remove(0).1), - _ => Err(format!( - "the plugin's cdylib ({name}) is built more than once under {profile:?} ({:?}); \ - name this run's image in {CDYLIB_ENV}", - images.iter().map(|(_, p)| p).collect::>() - )), + )) + } else { + Err(format!( + "the plugin's cdylib ({name}) is built under {profile:?} only without its dropped-in \ + door ({doorless:?} do not load or export no busbar_plugin_door): build it with \ + the crate's `dropped-in` feature (its dev-dependency on itself turns it on for \ + `cargo test`), or name the image in {CDYLIB_ENV}" + )) } } diff --git a/crates/plugin-loader/src/tests/conformance_suite_tests.rs b/crates/plugin-loader/src/tests/conformance_suite_tests.rs index 6d2774c055..ef4bcff7a2 100644 --- a/crates/plugin-loader/src/tests/conformance_suite_tests.rs +++ b/crates/plugin-loader/src/tests/conformance_suite_tests.rs @@ -1087,7 +1087,14 @@ mod dropped_image { use busbar_contract::abi::mechanism::door::{Door, DoorFn}; - use super::super::{cdylib_of, choose_cdylib, distinct, test_door, TestDoor, CDYLIB_ENV}; + use super::super::{cdylib_of, choose_cdylib_by, distinct, test_door, TestDoor, CDYLIB_ENV}; + + /// The fake images below export the door unless their bytes say they were built without it. + fn choose_cdylib(profile: &Path, krate: &str) -> Result { + choose_cdylib_by(profile, krate, &|p: &Path| { + !std::fs::read(p).unwrap_or_default().starts_with(b"no door") + }) + } /// A fresh `target/` of this test's own, with `deps/` and `examples/`. struct Profile(PathBuf); @@ -1157,6 +1164,59 @@ mod dropped_image { assert_eq!(choose_cdylib(p.path(), "plug").ok(), Some(up)); } + /// RED (#721): an earlier plain build of the crate (no `dropped-in` feature) left an uplifted + /// image with no door, and the test build's own image sits under `deps/` with no hash: the + /// image with the door is chosen, whichever was built last; only doorless images built is a + /// failure naming them, never a leg run on a library with no door. + #[test] + fn red_an_image_without_the_door_is_never_the_dropped_in_leg() { + let p = Profile::new("doorless"); + let plain = p.put("", "plug", None, b"no door: a plain build"); + let e = choose_cdylib(p.path(), "plug").expect_err("only a doorless image"); + assert!(e.contains("only without its dropped-in door"), "{e}"); + assert!(e.contains(&plain.display().to_string()), "{e}"); + let test_build = p.put("deps", "plug", None, b"the dropped-in door"); + assert_eq!( + choose_cdylib(p.path(), "plug").ok(), + Some(test_build.clone()) + ); + // The plain build again, after: its image still has no door, and is still passed over. + std::thread::sleep(std::time::Duration::from_millis(20)); + p.put("", "plug", None, b"no door: a plain build, again"); + assert_eq!(choose_cdylib(p.path(), "plug").ok(), Some(test_build)); + // A plain build that overwrote the lib unit's own image leaves no door anywhere: refused. + p.put( + "deps", + "plug", + None, + b"no door: the plain build overwrote it", + ); + let e = choose_cdylib(p.path(), "plug").expect_err("no image with the door"); + assert!(e.contains("only without its dropped-in door"), "{e}"); + } + + /// The real probe: the example cdylib exports its door and is chosen; this test binary (a + /// library image with no `busbar_plugin_door`) is passed over. + #[test] + fn the_real_probe_tells_an_image_with_the_door_from_one_without() { + let path = crate::both_ways::example_cdylib("plane_door_plugin"); + let p = Profile::new("probe"); + let with = p.put( + "deps", + "plug", + None, + &std::fs::read(&path).expect("the example cdylib"), + ); + assert_eq!( + super::super::choose_cdylib(p.path(), "plug").ok(), + Some(with) + ); + let q = Profile::new("probe-none"); + let doorless = q.put("", "plug", None, b"not a library at all"); + let e = super::super::choose_cdylib(q.path(), "plug").expect_err("no door"); + assert!(e.contains(&doorless.display().to_string()), "{e}"); + } + static DROPPED_DOOR: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); /// A "linked" door that is in truth the dropped image's own. diff --git a/qa/construction.toml b/qa/construction.toml index a0aa636c9d..902aecd76e 100644 --- a/qa/construction.toml +++ b/qa/construction.toml @@ -740,6 +740,10 @@ reviewed_allowlist = ["busbar-api", "serde", "serde_json", "tracing", "async-tra # of which wire library a transport links to reach them. [rules.manifest-allowlist.reviewed_extra] busbar-transport-stdio = ["tokio", "futures"] +# The webrtc framer (THE DESIGN l.749-757): str0m for ICE/SRTP/SCTP, ring for its primitives, aes for +# the SRTP key derivation alone (l.753's one exception, confined by its tests/dependency_closure.rs), +# zeroize for the keying material. +busbar-transport-webrtc = ["str0m", "ring", "aes", "zeroize"] # The MCP plane's outbound wire (fold A5, ARCHITECT 2026-09-29 under the PLUGIN LIBRARIES ruling): # the `Mcp-Name` header's `=?base64?...?=` sentinel for a name a header value cannot carry. busbar-plane-mcp = ["base64", "indexmap"] diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index c257d53e69..918659712a 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -52,6 +52,16 @@ from = "busbar-a2a" to = "busbar-plane-a2a" reason = "legacy drain: the A2A wire dialect folded into the plane crate and the retiring engine reads it" +# `busbar-llm` is the retiring 1.5.x LLM engine and `busbar-plane-llm` the pure plane it hosts and is +# draining into (K2e TRANSPORT-AXIS, 0ba019cd81: the root dropped its plane edges and the engine took +# this one, as `busbar-mcp -> busbar-plane-mcp` had). The dialect codecs folded into the plane crate's +# own `codec` module (R7) and the engine re-exports them, so the edge is the drain, named on the same +# terms as the voice and A2A rows above. +[[transitional]] +from = "busbar-llm" +to = "busbar-plane-llm" +reason = "legacy drain: the retiring LLM engine hosts the pure plane and reads the codecs that folded into it" + # The MCP rows (`busbar-mcp -> busbar-plane-mcp`, `busbar-mcp -> busbar-core-admin`) are STRUCK: the # drain completed (P3 DEL-MCP, ARCHITECT 2026-10-05) — busbar-mcp is deleted and the mcp plane is its # door crate alone, so neither edge can be taken. @@ -673,6 +683,16 @@ cite = "BUSBAR-1.6.0.md THE DESIGN, §1 / §9 — the composition root is the why = "busbar names busbar-transport-stdio: declared normal in the shipped graph, 1 declaration(s) at this commit. The count is TODAY'S MEASUREMENT, exact in both directions — above it is the landing that grew the coupling, below it is stale slack that was not drained on the commit that drained the edge." drain = "none: BUSBAR-1.6.0.md grants this class. Draining it would be an architecture change and not a landing — what this row holds is the NUMBER, so a second declaration of a granted edge is still a landing that has to say why." +[[dep]] +from = "busbar" +to = "busbar-transport-webrtc" +half = "shipped" +count = "1" +verdict = "allowed" +cite = "BUSBAR-1.6.0.md THE DESIGN, §1 / §9 — the composition root is the one thing that names all three axes." +why = "busbar names busbar-transport-webrtc: declared normal, optional in the shipped graph, 1 declaration(s) at this commit — the webrtc wire's one registration (its `transport-webrtc` linked row, off by default). The count is TODAY'S MEASUREMENT, exact in both directions — above it is the landing that grew the coupling, below it is stale slack that was not drained on the commit that drained the edge." +drain = "none: BUSBAR-1.6.0.md grants this class. Draining it would be an architecture change and not a landing — what this row holds is the NUMBER, so a second declaration of a granted edge is still a landing that has to say why." + [[dep]] from = "busbar" to = "busbar-voice" @@ -725,6 +745,26 @@ cite = "BUSBAR-1.6.0.md THE DESIGN, §2 \"Core -> plugin. Never plugin -> cor why = "busbar-a2a names busbar-plane-a2a: declared normal in the shipped graph, 1 declaration(s) at this commit. The codec fold (#39) moved the A2A wire dialect into the plane crate, which is where definition 16-20 puts a protocol's dialect, and the retiring engine now READS it rather than restating it. The edge is permitted by the `[[transitional]]` drain row above and by nothing else; this row holds the NUMBER." drain = "delete the busbar-plane-a2a dependency from busbar-a2a's Cargo.toml — which happens when busbar-a2a itself is deleted into busbar-kernel (#19), because the dialect is already where it belongs and it is the ENGINE that is still in the wrong crate." +[[dep]] +from = "busbar-llm" +to = "busbar-plane-llm" +half = "shipped" +count = "1" +verdict = "not-allowed" +cite = "BUSBAR-1.6.0.md THE DESIGN, §2 \"Core -> plugin. Never plugin -> core.\" / BUSBAR-1.6.0.md THE DESIGN, §1 the three axes are blind to each other. The retiring 1.5.x crates fuse plane+core+transport in one name." +why = "busbar-llm names busbar-plane-llm: declared normal in the shipped graph, 1 declaration(s) at this commit. K2e (0ba019cd81) moved the edge off the root onto the engine, whose entry hands the boot seal the plane and its claims; the codec fold (R7) put the dialect readers/writers in the plane crate's `codec` module, which the engine re-exports. The edge is permitted by the `[[transitional]]` drain row above and by nothing else; this row holds the NUMBER." +drain = "delete the busbar-plane-llm dependency from busbar-llm's Cargo.toml — which happens when busbar-llm itself is retired, because the plane and its codecs are already where they belong and it is the ENGINE that is still in the wrong crate." + +[[dep]] +from = "busbar-voice" +to = "busbar-plane-streaming" +half = "shipped" +count = "1" +verdict = "not-allowed" +cite = "BUSBAR-1.6.0.md THE DESIGN, §2 \"Core -> plugin. Never plugin -> core.\" / BUSBAR-1.6.0.md THE DESIGN, §1 the three axes are blind to each other. The retiring 1.5.x crates fuse plane+core+transport in one name." +why = "busbar-voice names busbar-plane-streaming: declared normal in the shipped graph, 1 declaration(s) at this commit. The streaming plane carries the session constants the root's own leg is built from, the governed-call port (`busbar_plane_streaming::governed`) and the duplex IR and dialect grammars as its `codec` module, all of which the retiring engine re-exports under their old paths. The edge is permitted by the `[[transitional]]` drain row above and by nothing else; this row holds the NUMBER." +drain = "delete the busbar-plane-streaming dependency from busbar-voice's Cargo.toml — which happens when busbar-voice itself is retired, because the plane is already where it belongs and it is the ENGINE that is still in the wrong crate." + [[dep]] from = "busbar" to = "busbar-llm" diff --git a/xtask/src/gates/population.rs b/xtask/src/gates/population.rs index 9ded30abd7..8e1d7aa6ce 100644 --- a/xtask/src/gates/population.rs +++ b/xtask/src/gates/population.rs @@ -17,7 +17,7 @@ //! //! * [`Population::below_floor`] — the aggregate ratchet. It is not `> 0`: a walk that collapses to //! a handful of files reports no findings and reads exactly like a clean tree. The floor is a -//! RATCHET pinned AT the measured count ([`FLOOR`], 941 on predev bd39476618), not a margin +//! RATCHET pinned AT the measured count ([`FLOOR`], 947 since the webrtc framer), not a margin //! below it: one file fewer is refused until a reviewed diff re-measures. It is raised as the //! tree grows, never lowered to accommodate a scan that stopped finding things. //! * [`Population::drained`] — a crate that has a `src/` and contributed NOTHING. The floor catches @@ -33,12 +33,14 @@ use crate::ctx::{Ctx, Overlay, SourceFile, WalkSpec}; /// and added 4, and a floor above the count leaves every gate's floor row red on a clean tree. It /// was re-pinned from 941 to 942 when predev grew by one file, which the selftest plant caught, /// and back to 941 when #648 (q128-kernel-ledger) deleted `busbar-kernel-ledger/src/usage/series.rs` -/// — the one non-test file that merge removed, a reviewed removal and not a scan that went blind. A -/// drop below 941 is refused until a reviewed diff re-measures. The selftest plant -/// ([`one_file_short`]) cuts the live population to `FLOOR - 1` whatever its size, so predev -/// growing a file no longer forces a re-pin for the plant's sake. Lowering the floor is how a gate -/// stops reading the repository without saying so, so a diff that lowers it is the diff to refuse. -pub const FLOOR: usize = 941; +/// — the one non-test file that merge removed, a reviewed removal and not a scan that went blind. It +/// was raised from 941 to 947 when the webrtc framer landed its six `busbar-transport-webrtc/src` +/// files (crypto, door, framing, lib, shim, stun). A drop below 947 is refused until a reviewed diff +/// re-measures. The selftest plant ([`one_file_short`]) cuts the live population to `FLOOR - 1` +/// whatever its size, so predev growing a file no longer forces a re-pin for the plant's sake. +/// Lowering the floor is how a gate stops reading the repository without saying so, so a diff that +/// lowers it is the diff to refuse. +pub const FLOOR: usize = 947; /// The `.rs` under `crates/` that are not test scaffolding, plus the accounting to refuse a /// population that cannot support a verdict.