diff --git a/Cargo.lock b/Cargo.lock index bb3979f2..67ffefe7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -656,6 +656,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -691,6 +715,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -3137,6 +3162,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3293,6 +3329,7 @@ dependencies = [ "aes-gcm", "bytes", "cbindgen", + "chacha20poly1305", "criterion", "fec-rs", "hmac", diff --git a/clients/probe/src/main.rs b/clients/probe/src/main.rs index 10c56aee..72e2ab81 100644 --- a/clients/probe/src/main.rs +++ b/clients/probe/src/main.rs @@ -498,6 +498,13 @@ async fn session(args: Args) -> Result<()> { if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() { caps |= punktfunk_core::quic::VIDEO_CAP_444; } + // PUNKTFUNK_CLIENT_CHACHA20=1 advertises VIDEO_CAP_CHACHA20 — drives the + // host's ChaCha20-Poly1305 session-cipher resolution (the soft-AES armv7 + // negotiation, design/chacha20-session-cipher.md §7) without a webOS build; + // the negotiated cipher is reported in the welcome log line below. + if std::env::var_os("PUNKTFUNK_CLIENT_CHACHA20").is_some() { + caps |= punktfunk_core::quic::VIDEO_CAP_CHACHA20; + } caps }, // `--audio-channels` (default stereo); the probe multistream-decodes + validates the @@ -535,6 +542,11 @@ async fn session(args: Args) -> Result<()> { chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444, chroma_format_idc = welcome.chroma_format, codec = codec_ext(welcome.codec), + cipher = if welcome.cipher == punktfunk_core::quic::CIPHER_CHACHA20_POLY1305 { + "chacha20-poly1305" + } else { + "aes-128-gcm" + }, "session offer" ); diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index 96e9c1ef..a46aadc0 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -63,6 +63,13 @@ pub struct HostConfig { /// deliver full chroma, and the GPU/driver passed the encode probe — otherwise 4:2:0. /// `PUNKTFUNK_444=0`/`false`/`off`/`no` disables. Independent of `ten_bit` (chroma vs depth). pub four_four_four: bool, + /// `PUNKTFUNK_CHACHA20` — host policy gate for the negotiated ChaCha20-Poly1305 session + /// cipher (design/chacha20-session-cipher.md). **Default ON** (pure rollout safety — perf-only, + /// both AEADs are full-strength): the host merely *allows* it — a session only seals with + /// ChaCha when the client advertised `VIDEO_CAP_CHACHA20` (set by soft-AES armv7 clients, + /// e.g. webOS TVs, whose GCM decrypt caps at ~100 Mbps); everyone else stays AES-128-GCM. + /// `PUNKTFUNK_CHACHA20=0`/`false`/`off`/`no` disables. + pub chacha20: bool, /// `PUNKTFUNK_PERF` — per-stage timing instrumentation. pub perf: bool, /// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic). @@ -147,6 +154,16 @@ impl HostConfig { ) }) .unwrap_or(true), + // Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real + // per-session switch; see the field doc). + chacha20: val("PUNKTFUNK_CHACHA20") + .map(|s| { + !matches!( + s.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" + ) + }) + .unwrap_or(true), perf: flag("PUNKTFUNK_PERF"), video_source: val("PUNKTFUNK_VIDEO_SOURCE"), compositor: val("PUNKTFUNK_COMPOSITOR"), diff --git a/crates/punktfunk-core/Cargo.toml b/crates/punktfunk-core/Cargo.toml index 8f9d8047..3f1ca1d9 100644 --- a/crates/punktfunk-core/Cargo.toml +++ b/crates/punktfunk-core/Cargo.toml @@ -33,6 +33,11 @@ reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the w # NOT interoperable.) See vendor/fec-rs/LICENSE (BSD-2-Clause). fec-rs = { path = "vendor/fec-rs" } aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream +# ChaCha20-Poly1305 session crypto, negotiated by clients without hardware AES (the soft-AES +# armv7 targets — webOS TVs — where GCM caps decrypt at ~100 Mbps; ARX runs 4-7x faster there). +# Same RustCrypto `aead 0.5` generation as aes-gcm: identical trait/nonce/tag shapes, pure Rust, +# cross-compiles like aes-gcm (no cmake). See design/chacha20-session-cipher.md. +chacha20poly1305 = "0.10" zerocopy = { version = "0.8", features = ["derive"] } bytes = "1" socket2 = { version = "0.6", features = [ diff --git a/crates/punktfunk-core/benches/pipeline.rs b/crates/punktfunk-core/benches/pipeline.rs index 28740ec3..f875f61b 100644 --- a/crates/punktfunk-core/benches/pipeline.rs +++ b/crates/punktfunk-core/benches/pipeline.rs @@ -1,7 +1,8 @@ //! Tier-1 microbenchmarks for the punktfunk/1 hot path — GPU-free, so they run in normal CI. //! //! Two layers: -//! - `crypto/*` — the isolated AES-128-GCM primitives on one ~MTU shard. +//! - `crypto/*` — the isolated AEAD primitives (AES-128-GCM + the negotiated +//! ChaCha20-Poly1305) on one ~MTU shard. //! - `pipeline/*`— a whole frame through the real per-frame path end to end over the in-process //! loopback transport: FEC encode → AES-GCM seal → packetize → (loopback) → reassemble → //! FEC decode → open. This is what a throughput/latency regression in the core would show up in. @@ -11,11 +12,11 @@ use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role}; -use punktfunk_core::crypto::SessionCrypto; +use punktfunk_core::crypto::{SessionCrypto, SessionKey}; use punktfunk_core::session::Session; use punktfunk_core::transport::loopback_pair; -const TAG_LEN: usize = 16; // AES-GCM authentication tag +const TAG_LEN: usize = 16; // AEAD authentication tag (GCM and Poly1305 share the size) const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard fn cfg(role: Role, scheme: FecScheme) -> Config { @@ -38,48 +39,57 @@ fn cfg(role: Role, scheme: FecScheme) -> Config { shard_payload: SHARD, max_frame_bytes: 8 * 1024 * 1024, encrypt: true, // bench the real path — crypto is always on for punktfunk/1 - key: [7u8; 16], + key: SessionKey::Aes128Gcm([7u8; 16]), salt: [1, 2, 3, 4], loopback_drop_period: 0, // throughput run: no induced loss (loss-harness covers recovery) } } fn bench_crypto(c: &mut Criterion) { - let host = SessionCrypto::new(&[7u8; 16], [1, 2, 3, 4], Role::Host); - let client = SessionCrypto::new(&[7u8; 16], [1, 2, 3, 4], Role::Client); - let payload = vec![0xABu8; SHARD]; - let sealed = host.seal(0, &payload).unwrap(); - let mut g = c.benchmark_group("crypto"); g.throughput(Throughput::Bytes(SHARD as u64)); - g.bench_function("seal", |b| { - let mut seq = 0u64; - b.iter(|| { - let ct = host.seal(seq, black_box(&payload)).unwrap(); - seq += 1; - black_box(ct) - }) - }); - g.bench_function("seal_in_place", |b| { - let mut seq = 0u64; - let mut buf = vec![0xABu8; SHARD + TAG_LEN]; - b.iter(|| { - host.seal_in_place(seq, black_box(&mut buf)).unwrap(); - seq += 1; - }) - }); - g.bench_function("open", |b| { - b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap())) - }); - g.bench_function("open_in_place", |b| { - // In-place open consumes the buffer, so each iteration restores the ciphertext first — - // one memcpy, mirroring what the recv ring does when the next datagram lands in the slot. - let mut buf = sealed.clone(); - b.iter(|| { - buf.copy_from_slice(black_box(&sealed)); - black_box(client.open_in_place(0, &mut buf).unwrap()); - }) - }); + // Both negotiated session AEADs. On the x86 / Apple Silicon this runs on, both must be + // line-rate-trivial — the chacha20 series is the host-side sealing-cost check for the + // negotiated soft-AES-armv7 path (design/chacha20-session-cipher.md §7). The AES series + // keeps its unsuffixed names so the CI regression compare retains its history. + for (suffix, key) in [ + ("", SessionKey::Aes128Gcm([7u8; 16])), + ("_chacha20", SessionKey::ChaCha20Poly1305([7u8; 32])), + ] { + let host = SessionCrypto::new(&key, [1, 2, 3, 4], Role::Host); + let client = SessionCrypto::new(&key, [1, 2, 3, 4], Role::Client); + let payload = vec![0xABu8; SHARD]; + let sealed = host.seal(0, &payload).unwrap(); + + g.bench_function(format!("seal{suffix}"), |b| { + let mut seq = 0u64; + b.iter(|| { + let ct = host.seal(seq, black_box(&payload)).unwrap(); + seq += 1; + black_box(ct) + }) + }); + g.bench_function(format!("seal_in_place{suffix}"), |b| { + let mut seq = 0u64; + let mut buf = vec![0xABu8; SHARD + TAG_LEN]; + b.iter(|| { + host.seal_in_place(seq, black_box(&mut buf)).unwrap(); + seq += 1; + }) + }); + g.bench_function(format!("open{suffix}"), |b| { + b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap())) + }); + g.bench_function(format!("open_in_place{suffix}"), |b| { + // In-place open consumes the buffer, so each iteration restores the ciphertext first — + // one memcpy, mirroring what the recv ring does when the next datagram lands in the slot. + let mut buf = sealed.clone(); + b.iter(|| { + buf.copy_from_slice(black_box(&sealed)); + black_box(client.open_in_place(0, &mut buf).unwrap()); + }) + }); + } g.finish(); } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 820355f9..7744a5ad 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -11,6 +11,7 @@ //! - Panics never cross the boundary: every entry point is wrapped in `catch_unwind`. use crate::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role}; +use crate::crypto::SessionKey; use crate::error::PunktfunkStatus; use crate::input::InputEvent; use crate::reanchor::{GateVerdict, ReanchorGate}; @@ -94,7 +95,10 @@ impl PunktfunkConfig { shard_payload: self.shard_payload as usize, max_frame_bytes, encrypt: self.encrypt != 0, - key: self.key, + // The C ABI keeps its fixed 16-byte key and always selects AES-128-GCM — no + // ABI_VERSION bump. Raw-`Config` C embedders can't negotiate ChaCha; the Swift/ + // Kotlin clients are aarch64 with AES CE and never want it. + key: SessionKey::Aes128Gcm(self.key), salt: self.salt, loopback_drop_period: self.loopback_drop_period, }; diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 82aa953d..8018c4f0 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -1,5 +1,6 @@ //! Session configuration and protocol/FEC parameters. +use crate::crypto::SessionKey; use crate::error::{PunktfunkError, Result}; use crate::packet::{CRYPTO_OVERHEAD, HEADER_LEN, MAX_DATAGRAM_BYTES}; use zeroize::Zeroize; @@ -355,9 +356,11 @@ pub struct Config { /// hostile/​corrupt headers; see [`Session`](crate::session::Session)). pub max_frame_bytes: usize, pub encrypt: bool, - /// AES-128 session key established during pairing. MUST be unique per session when + /// The negotiated session AEAD + its key, established during pairing/handshake — + /// AES-128-GCM for every peer by default, ChaCha20-Poly1305 when the client negotiated it + /// (soft-AES armv7 targets; see [`SessionKey`]). MUST be unique per session when /// `encrypt` is set (see the nonce-uniqueness contract in [`crate::crypto`]). - pub key: [u8; 16], + pub key: SessionKey, /// Per-session nonce salt, established alongside `key` during pairing. MUST be /// unique per (key, session). pub salt: [u8; 4], @@ -382,7 +385,8 @@ impl std::fmt::Debug for Config { .field("shard_payload", &self.shard_payload) .field("max_frame_bytes", &self.max_frame_bytes) .field("encrypt", &self.encrypt) - .field("key", &"") + // SessionKey's own Debug redacts the material but keeps the cipher choice visible. + .field("key", &self.key) .field("salt", &"") .field("loopback_drop_period", &self.loopback_drop_period) .finish() @@ -426,7 +430,7 @@ impl Config { "max_frame_bytes too large for this shard/block configuration (block count overflows u16)", )); } - if self.encrypt && self.key == [0u8; 16] { + if self.encrypt && self.key.is_zero() { return Err(PunktfunkError::InvalidArg( "encrypt requires a non-zero session key (see crypto nonce-uniqueness contract)", )); @@ -449,7 +453,7 @@ impl Config { shard_payload: 1024, max_frame_bytes: 64 * 1024 * 1024, encrypt: false, - key: [0u8; 16], + key: SessionKey::Aes128Gcm([0u8; 16]), salt: [0u8; 4], loopback_drop_period: 0, } @@ -465,7 +469,12 @@ mod tests { let mut c = Config::p1_defaults(Role::Host); c.encrypt = true; // key is still all-zero assert!(c.validate().is_err()); - c.key = [1u8; 16]; + c.key = SessionKey::Aes128Gcm([1u8; 16]); + assert!(c.validate().is_ok()); + // The rejection follows whichever cipher variant is active. + c.key = SessionKey::ChaCha20Poly1305([0u8; 32]); + assert!(c.validate().is_err()); + c.key = SessionKey::ChaCha20Poly1305([1u8; 32]); assert!(c.validate().is_ok()); } diff --git a/crates/punktfunk-core/src/crypto.rs b/crates/punktfunk-core/src/crypto.rs index 6814a727..c0cb69c0 100644 --- a/crates/punktfunk-core/src/crypto.rs +++ b/crates/punktfunk-core/src/crypto.rs @@ -1,9 +1,10 @@ -//! AES-128-GCM session sealing, matching GameStream's video crypto in P1. +//! Session sealing with the negotiated AEAD — AES-128-GCM (matching GameStream's video +//! crypto in P1) by default, ChaCha20-Poly1305 (RFC 8439) for clients without hardware AES. //! -//! ## Nonce uniqueness (the GCM safety requirement) +//! ## Nonce uniqueness (the AEAD safety requirement) //! //! The 96-bit nonce is `salt (4 bytes) || sequence (8 bytes, big-endian)`. Reusing a -//! `(key, nonce)` pair under AES-GCM is catastrophic, so two precautions apply: +//! `(key, nonce)` pair is catastrophic under either AEAD, so two precautions apply: //! //! 1. **Per-direction salts.** Host and client share one `key` and `salt`, and each //! counts its sequence from 0. To stop the host's video stream and the client's input @@ -17,17 +18,96 @@ //! The sequence number is also passed as AEAD associated data, so tampering with the //! on-wire sequence is detected (the tag check fails) rather than silently shifting the //! nonce. Note: this layer does not provide anti-replay — see `Session`. +//! +//! ## Why two ciphers +//! +//! Both AEADs are full-strength; the choice (negotiated via `Welcome::cipher`) is purely a +//! performance one. On targets without hardware AES — the soft-AES armv7 clients (webOS TVs) — +//! GCM's fixsliced AES + software GHASH costs ~50–100 cycles/byte and caps decrypt at +//! ~100 Mbps, while ChaCha20-Poly1305's ARX construction runs ~10–17 cycles/byte in portable +//! software (design/chacha20-session-cipher.md). Same 96-bit nonce, 16-byte tag, and AAD +//! shape, so the entire nonce discipline above carries over verbatim. use crate::config::Role; use crate::error::{PunktfunkError, Result}; use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload}; use aes_gcm::{Aes128Gcm, Key, Nonce}; +use chacha20poly1305::ChaCha20Poly1305; +use zeroize::Zeroize; -/// 16-byte AEAD authentication tag appended by GCM. +/// 16-byte AEAD authentication tag appended by either session cipher. pub const TAG_LEN: usize = 16; +// The wire (CRYPTO_OVERHEAD) and every in-place split assume both negotiated AEADs append +// exactly TAG_LEN bytes — a different-tag cipher can never slip in behind this constant. +const _: () = assert!(std::mem::size_of::() == TAG_LEN); +const _: () = assert!(std::mem::size_of::() == TAG_LEN); + +/// The negotiated session AEAD together with its key material — merged so the invalid state +/// (a ChaCha cipher with an AES-sized key, or vice versa) is unrepresentable. AES-128-GCM is +/// the default every peer speaks; ChaCha20-Poly1305 is granted to clients that advertised +/// [`VIDEO_CAP_CHACHA20`](crate::quic::VIDEO_CAP_CHACHA20) (the soft-AES armv7 targets — +/// see the module docs). 256 bits for ChaCha is what RFC 8439 requires. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SessionKey { + Aes128Gcm([u8; 16]), + ChaCha20Poly1305([u8; 32]), +} + +impl SessionKey { + /// Canonical lowercase cipher name for session-start logs. + pub fn cipher_name(&self) -> &'static str { + match self { + SessionKey::Aes128Gcm(_) => "aes-128-gcm", + SessionKey::ChaCha20Poly1305(_) => "chacha20-poly1305", + } + } + + /// True when the key material is all zeros — the pairing-layer footgun `Config::validate` + /// rejects when encryption is on (see the nonce-uniqueness contract in the module docs). + pub fn is_zero(&self) -> bool { + match self { + SessionKey::Aes128Gcm(k) => k == &[0u8; 16], + SessionKey::ChaCha20Poly1305(k) => k == &[0u8; 32], + } + } +} + +/// Key material never appears in logs, whichever variant is active — only the cipher choice +/// (`Config`'s hand-written `Debug` relies on this). +impl std::fmt::Debug for SessionKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SessionKey::Aes128Gcm(_) => f.write_str("Aes128Gcm()"), + SessionKey::ChaCha20Poly1305(_) => f.write_str("ChaCha20Poly1305()"), + } + } +} + +/// Same zeroize-on-drop discipline the raw key array had (`Config`'s `Drop`). +impl Zeroize for SessionKey { + fn zeroize(&mut self) { + match self { + SessionKey::Aes128Gcm(k) => k.zeroize(), + SessionKey::ChaCha20Poly1305(k) => k.zeroize(), + } + } +} + +/// The two negotiated AEADs behind one seal/open surface. Both are the same RustCrypto +/// `aead 0.5` generation (identical trait shapes, nonce/tag types), so each call below is a +/// two-arm match right next to the cipher work itself. +// AES's precomputed round keys (~0.7 KB) dwarf ChaCha's 32-byte state, but there is exactly +// one long-lived `SessionCrypto` per session — boxing the variant would trade that one-off +// slack for a pointer chase on every per-datagram seal/open. +#[allow(clippy::large_enum_variant)] +enum Cipher { + Aes128Gcm(Aes128Gcm), + ChaCha20Poly1305(ChaCha20Poly1305), +} + pub struct SessionCrypto { - cipher: Aes128Gcm, + cipher: Cipher, /// Salt for nonces we seal with (our direction). send_salt: [u8; 4], /// Salt for nonces we open with (the peer's direction). @@ -35,11 +115,18 @@ pub struct SessionCrypto { } impl SessionCrypto { - pub fn new(key: &[u8; 16], salt: [u8; 4], role: Role) -> Self { - let key = Key::::from_slice(key); + pub fn new(key: &SessionKey, salt: [u8; 4], role: Role) -> Self { + let cipher = match key { + SessionKey::Aes128Gcm(k) => { + Cipher::Aes128Gcm(Aes128Gcm::new(Key::::from_slice(k))) + } + SessionKey::ChaCha20Poly1305(k) => Cipher::ChaCha20Poly1305(ChaCha20Poly1305::new( + Key::::from_slice(k), + )), + }; let own = direction(role); SessionCrypto { - cipher: Aes128Gcm::new(key), + cipher, send_salt: dir_salt(salt, own), recv_salt: dir_salt(salt, own ^ 1), } @@ -49,15 +136,16 @@ impl SessionCrypto { /// authenticated as associated data. pub fn seal(&self, seq: u64, plaintext: &[u8]) -> Result> { let nonce = nonce(self.send_salt, seq); - self.cipher - .encrypt( - Nonce::from_slice(&nonce), - Payload { - msg: plaintext, - aad: &seq.to_be_bytes(), - }, - ) - .map_err(|_| PunktfunkError::Crypto) + let aad = seq.to_be_bytes(); + let payload = Payload { + msg: plaintext, + aad: &aad, + }; + match &self.cipher { + Cipher::Aes128Gcm(c) => c.encrypt(Nonce::from_slice(&nonce), payload), + Cipher::ChaCha20Poly1305(c) => c.encrypt(Nonce::from_slice(&nonce), payload), + } + .map_err(|_| PunktfunkError::Crypto) } /// Seal in place, no per-packet allocation: `buf` is laid out as `[plaintext .. ][TAG_LEN]` (the @@ -69,10 +157,16 @@ impl SessionCrypto { let nonce = nonce(self.send_salt, seq); let split = buf.len() - TAG_LEN; let (plaintext, tag_slot) = buf.split_at_mut(split); - let tag = self - .cipher - .encrypt_in_place_detached(Nonce::from_slice(&nonce), &seq.to_be_bytes(), plaintext) - .map_err(|_| PunktfunkError::Crypto)?; + let aad = seq.to_be_bytes(); + let tag = match &self.cipher { + Cipher::Aes128Gcm(c) => { + c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext) + } + Cipher::ChaCha20Poly1305(c) => { + c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext) + } + } + .map_err(|_| PunktfunkError::Crypto)?; tag_slot.copy_from_slice(&tag); Ok(()) } @@ -80,20 +174,21 @@ impl SessionCrypto { /// Open `ciphertext || tag` for sequence `seq` (also bound as associated data). pub fn open(&self, seq: u64, ciphertext: &[u8]) -> Result> { let nonce = nonce(self.recv_salt, seq); - self.cipher - .decrypt( - Nonce::from_slice(&nonce), - Payload { - msg: ciphertext, - aad: &seq.to_be_bytes(), - }, - ) - .map_err(|_| PunktfunkError::Crypto) + let aad = seq.to_be_bytes(); + let payload = Payload { + msg: ciphertext, + aad: &aad, + }; + match &self.cipher { + Cipher::Aes128Gcm(c) => c.decrypt(Nonce::from_slice(&nonce), payload), + Cipher::ChaCha20Poly1305(c) => c.decrypt(Nonce::from_slice(&nonce), payload), + } + .map_err(|_| PunktfunkError::Crypto) } /// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and /// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length) - /// — byte-identical to `open`, just written in place. GCM verifies the tag *before* + /// — byte-identical to `open`, just written in place. Both AEADs verify the tag *before* /// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet /// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec` /// that `open`'s convenience API allocates for every datagram at line rate — the receive @@ -105,14 +200,22 @@ impl SessionCrypto { let nonce = nonce(self.recv_salt, seq); let split = buf.len() - TAG_LEN; let (ciphertext, tag) = buf.split_at_mut(split); - self.cipher - .decrypt_in_place_detached( + let aad = seq.to_be_bytes(); + match &self.cipher { + Cipher::Aes128Gcm(c) => c.decrypt_in_place_detached( Nonce::from_slice(&nonce), - &seq.to_be_bytes(), + &aad, ciphertext, aes_gcm::Tag::from_slice(tag), - ) - .map_err(|_| PunktfunkError::Crypto)?; + ), + Cipher::ChaCha20Poly1305(c) => c.decrypt_in_place_detached( + Nonce::from_slice(&nonce), + &aad, + ciphertext, + chacha20poly1305::Tag::from_slice(tag), + ), + } + .map_err(|_| PunktfunkError::Crypto)?; Ok(split) } } @@ -145,6 +248,13 @@ pub fn random_key() -> [u8; 16] { k } +/// Generate a fresh random ChaCha20-Poly1305 session key (RFC 8439's 256-bit size). +pub fn random_key32() -> [u8; 32] { + let mut k = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rng(), &mut k); + k +} + /// Generate a fresh random per-session nonce salt. pub fn random_salt() -> [u8; 4] { let mut s = [0u8; 4]; @@ -156,93 +266,143 @@ pub fn random_salt() -> [u8; 4] { mod tests { use super::*; + /// One fresh key per negotiated cipher — every sealing test below must hold for both. + fn both_keys() -> [SessionKey; 2] { + [ + SessionKey::Aes128Gcm(random_key()), + SessionKey::ChaCha20Poly1305(random_key32()), + ] + } + #[test] fn seal_open_roundtrip_cross_direction() { - let key = random_key(); - let salt = random_salt(); - let host = SessionCrypto::new(&key, salt, Role::Host); - let client = SessionCrypto::new(&key, salt, Role::Client); + for key in both_keys() { + let salt = random_salt(); + let host = SessionCrypto::new(&key, salt, Role::Host); + let client = SessionCrypto::new(&key, salt, Role::Client); - let msg = b"the quick brown fox"; - let sealed = host.seal(42, msg).unwrap(); // host -> client (video direction) - assert_ne!(&sealed[..msg.len()], &msg[..]); // actually encrypted - assert_eq!(sealed.len(), msg.len() + TAG_LEN); - assert_eq!(client.open(42, &sealed).unwrap(), msg); + let msg = b"the quick brown fox"; + let sealed = host.seal(42, msg).unwrap(); // host -> client (video direction) + assert_ne!(&sealed[..msg.len()], &msg[..]); // actually encrypted + assert_eq!(sealed.len(), msg.len() + TAG_LEN); + assert_eq!(client.open(42, &sealed).unwrap(), msg); - // Wrong sequence (nonce + AAD) → authentication failure. - assert!(client.open(43, &sealed).is_err()); - // Direction separation: the host opens with the peer (client) salt, so it cannot - // open its own outbound packet → distinct nonce spaces per direction. - assert!(host.open(42, &sealed).is_err()); + // Wrong sequence (nonce + AAD) → authentication failure. + assert!(client.open(43, &sealed).is_err()); + // Direction separation: the host opens with the peer (client) salt, so it cannot + // open its own outbound packet → distinct nonce spaces per direction. + assert!(host.open(42, &sealed).is_err()); + } } #[test] fn directions_use_distinct_nonce_spaces() { - let key = random_key(); - let salt = [0u8; 4]; // even an all-zero base salt must separate the directions - let host = SessionCrypto::new(&key, salt, Role::Host); - let client = SessionCrypto::new(&key, salt, Role::Client); - // Same seq, same key, opposite directions → different ciphertext (no reuse). - assert_ne!( - host.seal(0, b"abc").unwrap(), - client.seal(0, b"abc").unwrap() - ); + for key in both_keys() { + let salt = [0u8; 4]; // even an all-zero base salt must separate the directions + let host = SessionCrypto::new(&key, salt, Role::Host); + let client = SessionCrypto::new(&key, salt, Role::Client); + // Same seq, same key, opposite directions → different ciphertext (no reuse). + assert_ne!( + host.seal(0, b"abc").unwrap(), + client.seal(0, b"abc").unwrap() + ); + } } #[test] fn open_in_place_matches_open_and_rejects_tampering() { - let key = random_key(); - let salt = random_salt(); - let host = SessionCrypto::new(&key, salt, Role::Host); - let client = SessionCrypto::new(&key, salt, Role::Client); - for msg in [ - &b""[..], - b"x", - b"the quick brown fox jumps over 13 lazy dogs!!", - ] { - let sealed = host.seal(9, msg).unwrap(); - let mut buf = sealed.clone(); - let n = client.open_in_place(9, &mut buf).unwrap(); - assert_eq!( - &buf[..n], - msg, - "in-place open must be byte-identical to open" - ); - // Wrong sequence (nonce + AAD) → authentication failure, like `open`. - let mut buf = sealed.clone(); - assert!(client.open_in_place(8, &mut buf).is_err()); - // A flipped ciphertext/tag bit → authentication failure. - let mut buf = sealed.clone(); - let last = buf.len() - 1; - buf[last] ^= 1; - assert!(client.open_in_place(9, &mut buf).is_err()); + for key in both_keys() { + let salt = random_salt(); + let host = SessionCrypto::new(&key, salt, Role::Host); + let client = SessionCrypto::new(&key, salt, Role::Client); + for msg in [ + &b""[..], + b"x", + b"the quick brown fox jumps over 13 lazy dogs!!", + ] { + let sealed = host.seal(9, msg).unwrap(); + let mut buf = sealed.clone(); + let n = client.open_in_place(9, &mut buf).unwrap(); + assert_eq!( + &buf[..n], + msg, + "in-place open must be byte-identical to open" + ); + // Wrong sequence (nonce + AAD) → authentication failure, like `open`. + let mut buf = sealed.clone(); + assert!(client.open_in_place(8, &mut buf).is_err()); + // A flipped ciphertext/tag bit → authentication failure. + let mut buf = sealed.clone(); + let last = buf.len() - 1; + buf[last] ^= 1; + assert!(client.open_in_place(9, &mut buf).is_err()); + } + // Shorter than a tag can't be a sealed packet at all. + let mut runt = vec![0u8; TAG_LEN - 1]; + assert!(client.open_in_place(0, &mut runt).is_err()); } - // Shorter than a tag can't be a sealed packet at all. - let mut runt = vec![0u8; TAG_LEN - 1]; - assert!(client.open_in_place(0, &mut runt).is_err()); } #[test] fn seal_in_place_matches_seal_and_opens() { - let key = random_key(); - let salt = random_salt(); - let host = SessionCrypto::new(&key, salt, Role::Host); - let client = SessionCrypto::new(&key, salt, Role::Client); - for msg in [ - &b""[..], - b"x", - b"the quick brown fox jumps over 13 lazy dogs!!", - ] { - let reference = host.seal(7, msg).unwrap(); // ciphertext || tag - // In-place: [plaintext .. ][TAG_LEN scratch]. - let mut buf = msg.to_vec(); - buf.resize(msg.len() + TAG_LEN, 0); - host.seal_in_place(7, &mut buf).unwrap(); - assert_eq!( - buf, reference, - "in-place seal must be byte-identical to seal" - ); - assert_eq!(client.open(7, &buf).unwrap(), msg); + for key in both_keys() { + let salt = random_salt(); + let host = SessionCrypto::new(&key, salt, Role::Host); + let client = SessionCrypto::new(&key, salt, Role::Client); + for msg in [ + &b""[..], + b"x", + b"the quick brown fox jumps over 13 lazy dogs!!", + ] { + let reference = host.seal(7, msg).unwrap(); // ciphertext || tag + // In-place: [plaintext .. ][TAG_LEN scratch]. + let mut buf = msg.to_vec(); + buf.resize(msg.len() + TAG_LEN, 0); + host.seal_in_place(7, &mut buf).unwrap(); + assert_eq!( + buf, reference, + "in-place seal must be byte-identical to seal" + ); + assert_eq!(client.open(7, &buf).unwrap(), msg); + } } } + + #[test] + fn ciphers_are_not_interchangeable() { + // A packet sealed under one AEAD must not open under the other — negotiation skew has + // to fail loudly (a tag mismatch), never decode garbage. The ChaCha key repeats the AES + // key bytes so even overlapping key material can't accidentally interoperate. + let salt = random_salt(); + let aes = SessionKey::Aes128Gcm([7u8; 16]); + let chacha = SessionKey::ChaCha20Poly1305([7u8; 32]); + let sealed = SessionCrypto::new(&aes, salt, Role::Host) + .seal(1, b"cross-cipher") + .unwrap(); + assert!(SessionCrypto::new(&chacha, salt, Role::Client) + .open(1, &sealed) + .is_err()); + let sealed = SessionCrypto::new(&chacha, salt, Role::Host) + .seal(1, b"cross-cipher") + .unwrap(); + assert!(SessionCrypto::new(&aes, salt, Role::Client) + .open(1, &sealed) + .is_err()); + } + + #[test] + fn session_key_zero_check_and_debug_redaction() { + assert!(SessionKey::Aes128Gcm([0u8; 16]).is_zero()); + assert!(SessionKey::ChaCha20Poly1305([0u8; 32]).is_zero()); + assert!(!SessionKey::Aes128Gcm([1u8; 16]).is_zero()); + assert!(!SessionKey::ChaCha20Poly1305([1u8; 32]).is_zero()); + // Key bytes must never reach a log, whichever variant — only the cipher choice. + for key in both_keys() { + let dbg = format!("{key:?}"); + assert!(dbg.contains(""), "{dbg}"); + } + let mut k = SessionKey::ChaCha20Poly1305([9u8; 32]); + k.zeroize(); + assert!(k.is_zero()); + } } diff --git a/crates/punktfunk-core/src/packet/tests.rs b/crates/punktfunk-core/src/packet/tests.rs index 6222a6b5..47e502f5 100644 --- a/crates/punktfunk-core/src/packet/tests.rs +++ b/crates/punktfunk-core/src/packet/tests.rs @@ -1,6 +1,7 @@ use super::reassemble::LOSS_WINDOW_NS; use super::*; use crate::config::{Config, FecScheme}; +use crate::crypto::SessionKey; use crate::fec::coder_for; use crate::stats::StatsCounters; use zerocopy::{FromBytes, IntoBytes}; @@ -182,7 +183,7 @@ fn explicit_frame_index_is_stamped_and_internal_counter_untouched() { shard_payload: 16, max_frame_bytes: 4096, encrypt: false, - key: [0u8; 16], + key: SessionKey::Aes128Gcm([0u8; 16]), salt: [0u8; 4], loopback_drop_period: 0, }; @@ -292,7 +293,7 @@ fn e2e_config(scheme: FecScheme, fec_percent: u8) -> Config { shard_payload: 16, max_frame_bytes: 4096, encrypt: false, - key: [0u8; 16], + key: SessionKey::Aes128Gcm([0u8; 16]), salt: [0u8; 4], loopback_drop_period: 0, } diff --git a/crates/punktfunk-core/src/quic/caps.rs b/crates/punktfunk-core/src/quic/caps.rs index 0c89cbcf..034ba335 100644 --- a/crates/punktfunk-core/src/quic/caps.rs +++ b/crates/punktfunk-core/src/quic/caps.rs @@ -44,6 +44,17 @@ pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10; /// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so /// the fallback is zero-risk. pub const VIDEO_CAP_STREAMED_AU: u8 = 0x20; +/// [`Hello::video_caps`] bit: the client can open **ChaCha20-Poly1305**-sealed session datagrams +/// AND requests them — set by clients without hardware AES (the soft-AES armv7 targets, e.g. +/// webOS TVs), where GCM's software AES + GHASH caps decrypt at ~100 Mbps while ChaCha's ARX +/// construction runs 4–7× faster in portable code (design/chacha20-session-cipher.md). +/// Support-plus-request in one bit mirrors [`VIDEO_CAP_444`]'s "capable AND turned on" +/// precedent. The host grants it only when its `PUNKTFUNK_CHACHA20` kill-switch (default on) +/// allows, answering with [`Welcome::cipher`] `= 1` + the 32-byte [`Welcome::key_chacha`]; +/// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a +/// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS +/// control channel, so there is no downgrade surface. +pub const VIDEO_CAP_CHACHA20: u8 = 0x40; /// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`] /// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder @@ -225,6 +236,8 @@ mod tests { audio_channels: 2, codec: CODEC_HEVC, host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD, + cipher: 0, + key_chacha: None, }; let got = Welcome::decode(&w.encode()).unwrap(); assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD); diff --git a/crates/punktfunk-core/src/quic/handshake.rs b/crates/punktfunk-core/src/quic/handshake.rs index 076177b5..5f04256c 100644 --- a/crates/punktfunk-core/src/quic/handshake.rs +++ b/crates/punktfunk-core/src/quic/handshake.rs @@ -4,6 +4,7 @@ use super::*; use crate::config::{ CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role, }; +use crate::crypto::SessionKey; use crate::error::{PunktfunkError, Result}; /// `client → host`: open the session, requesting a display mode (the host creates its @@ -107,6 +108,13 @@ pub const HELLO_NAME_MAX: usize = 64; /// (`steam:` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field. pub const HELLO_LAUNCH_MAX: usize = 128; +/// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the +/// only one pre-cipher builds know). +pub const CIPHER_AES_128_GCM: u8 = 0; +/// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via +/// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES. +pub const CIPHER_CHACHA20_POLY1305: u8 = 1; + /// `host → client`: the complete session offer. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Welcome { @@ -173,6 +181,22 @@ pub struct Welcome { /// per-transition events otherwise). Appended after `codec` as a single trailing byte; an /// older host that omits it decodes to `0` (no capabilities — legacy events only). pub host_caps: u8, + /// The session AEAD the data plane seals with — [`CIPHER_AES_128_GCM`] (`0`, the default + /// every peer speaks) or [`CIPHER_CHACHA20_POLY1305`] (`1`). The host sets `1` ONLY toward + /// a client that advertised [`VIDEO_CAP_CHACHA20`] (the soft-AES armv7 targets). Appended + /// after `host_caps` at offset 68 and — unlike the earlier trailing fields — emitted only + /// when non-zero, so an AES session's Welcome stays **byte-identical** to the pre-cipher + /// wire form; an older host omits it (→ `0`, AES). Decode is fail-closed: an unknown id is + /// an `Err`, never a silent AES fallback — the host only picks a cipher this client + /// advertised, so an unknown id reaching us is a bug, and falling back would yield an + /// undecryptable session with a confusing failure signature. + pub cipher: u8, + /// The 256-bit ChaCha20-Poly1305 session key (RFC 8439 requires the full 32 bytes; wire + /// cost is once per handshake) — present iff `cipher == 1`, at offsets 69..101. The legacy + /// 16-byte `key` keeps its offset and stays independently random, so nothing downstream + /// ever observes an all-zero key. Decode rejects `cipher == 1` with fewer than 32 key + /// bytes following. + pub key_chacha: Option<[u8; 32]>, } /// `client → host`: data plane is bound, begin streaming. @@ -366,6 +390,21 @@ impl Welcome { b.push(self.codec); // Host input caps at offset 67 — older clients stop before this → 0 (legacy input only). b.push(self.host_caps); + // Session cipher at offset 68 + the 32-byte ChaCha key at 69..101 — emitted ONLY when a + // non-default cipher was negotiated, so an AES session's Welcome stays byte-identical + // to the pre-cipher wire form. The host only sets cipher toward a client that + // advertised VIDEO_CAP_CHACHA20, so an old client never sees these bytes at all. + debug_assert_eq!( + self.cipher == CIPHER_CHACHA20_POLY1305, + self.key_chacha.is_some(), + "key_chacha present iff cipher == 1" + ); + if self.cipher != CIPHER_AES_128_GCM { + b.push(self.cipher); + if let Some(k) = &self.key_chacha { + b.extend_from_slice(k); + } + } b } @@ -374,8 +413,10 @@ impl Welcome { // scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45] // salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59] // bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63] - // chroma_format[64] audio_channels[65] codec[66] (everything from compositor on is an - // optional trailing byte; an older host stops earlier). + // chroma_format[64] audio_channels[65] codec[66] host_caps[67] cipher[68] + // key_chacha[69..101] (everything from compositor on is an optional trailing byte; an + // older host stops earlier; cipher/key_chacha are present only when ChaCha was + // negotiated). if b.len() < 53 || &b[0..4] != MAGIC { return Err(PunktfunkError::InvalidArg("bad Welcome")); } @@ -385,6 +426,24 @@ impl Welcome { key.copy_from_slice(&b[29..45]); let mut salt = [0u8; 4]; salt.copy_from_slice(&b[45..49]); + // Session cipher at 68 — absent on an older host → AES-128-GCM. Fail-closed on + // anything else: `cipher == 1` with fewer than 32 key bytes must be an error (a silent + // AES fallback would yield an undecryptable session with a confusing failure + // signature), and an unknown id (≥ 2) reaching us is a bug — a host only picks a + // cipher this client advertised — never a legitimate negotiation. + let cipher = b.get(68).copied().unwrap_or(CIPHER_AES_128_GCM); + let key_chacha = match cipher { + CIPHER_AES_128_GCM => None, + CIPHER_CHACHA20_POLY1305 => { + let bytes = b + .get(69..101) + .ok_or(PunktfunkError::InvalidArg("bad Welcome"))?; + let mut k = [0u8; 32]; + k.copy_from_slice(bytes); + Some(k) + } + _ => return Err(PunktfunkError::InvalidArg("bad Welcome")), + }; Ok(Welcome { abi_version: u32at(4), udp_port: u16at(8), @@ -452,6 +511,8 @@ impl Welcome { // Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state // snapshots; the client keeps sending legacy per-transition events). host_caps: b.get(67).copied().unwrap_or(0), + cipher, + key_chacha, }) } @@ -462,7 +523,12 @@ impl Welcome { c.fec = self.fec; c.shard_payload = self.shard_payload as usize; c.encrypt = self.encrypt; - c.key = self.key; + // The negotiated AEAD: the ChaCha key when cipher == 1 (guaranteed present by decode — + // the `(1, None)` shape is unreachable off the wire), the legacy AES key otherwise. + c.key = match (self.cipher, self.key_chacha) { + (CIPHER_CHACHA20_POLY1305, Some(k)) => SessionKey::ChaCha20Poly1305(k), + _ => SessionKey::Aes128Gcm(self.key), + }; c.salt = self.salt; // Client-side reassembler ceiling: p1_defaults' 64 MiB hostile-header memory bound is // ~10x larger than any real access unit. Derive it from the negotiated rate instead: @@ -531,6 +597,8 @@ mod tests { audio_channels: 2, codec: CODEC_H264, // exercise a non-default codec through the roundtrip host_caps: HOST_CAP_GAMEPAD_STATE, + cipher: 0, + key_chacha: None, }; assert_eq!(Welcome::decode(&w.encode()).unwrap(), w); @@ -564,6 +632,81 @@ mod tests { assert!(derived > (8 << 20) && derived < (64 << 20)); } + #[test] + fn welcome_cipher_negotiation_wire_and_back_compat() { + use crate::crypto::SessionKey; + let base = Welcome { + abi_version: 2, + udp_port: 7000, + mode: Mode { + width: 1920, + height: 1080, + refresh_hz: 60, + }, + fec: FecConfig { + scheme: FecScheme::Gf16, + fec_percent: 20, + max_data_per_block: 4096, + }, + shard_payload: 1200, + encrypt: true, + key: [7u8; 16], + salt: [9, 8, 7, 6], + frames: 0, + compositor: CompositorPref::Auto, + gamepad: GamepadPref::Auto, + bitrate_kbps: 50_000, + bit_depth: 8, + color: ColorInfo::SDR_BT709, + chroma_format: CHROMA_IDC_420, + audio_channels: 2, + codec: CODEC_HEVC, + host_caps: 0, + cipher: CIPHER_AES_128_GCM, + key_chacha: None, + }; + // An AES session's Welcome is byte-identical to the pre-cipher wire form (68 bytes) — + // the old-client × new-host interop guarantee. + let enc = base.encode(); + assert_eq!(enc.len(), 68); + assert_eq!(Welcome::decode(&enc).unwrap(), base); + + // ChaCha roundtrip: cipher byte at 68, the 32-byte key at 69..101. + let k32: [u8; 32] = core::array::from_fn(|i| i as u8 + 1); + let cha = Welcome { + cipher: CIPHER_CHACHA20_POLY1305, + key_chacha: Some(k32), + ..base + }; + let cenc = cha.encode(); + assert_eq!(cenc.len(), 68 + 1 + 32); + assert_eq!(Welcome::decode(&cenc).unwrap(), cha); + + // A truncated old-host Welcome (no cipher byte) decodes to the AES default. + let old_host = Welcome::decode(&cenc[..68]).unwrap(); + assert_eq!(old_host.cipher, CIPHER_AES_128_GCM); + assert_eq!(old_host.key_chacha, None); + + // cipher == 1 with a missing / short key → Err, fail-closed (a silent AES fallback + // would yield an undecryptable session with a confusing failure signature). + assert!(Welcome::decode(&cenc[..69]).is_err()); + assert!(Welcome::decode(&cenc[..100]).is_err()); + + // An unknown cipher id (≥ 2) → Err: the host only picks a cipher we advertised, so an + // unknown id reaching us is a bug, never a legitimate negotiation. + let mut bad = cenc.clone(); + bad[68] = 2; + assert!(Welcome::decode(&bad).is_err()); + + // session_config maps both variants onto the data-plane key, and both validate. + let aes_cfg = base.session_config(Role::Client); + assert_eq!(aes_cfg.key, SessionKey::Aes128Gcm([7u8; 16])); + aes_cfg.validate().expect("AES config validates"); + let cha_cfg = cha.session_config(Role::Client); + assert_eq!(cha_cfg.key, SessionKey::ChaCha20Poly1305(k32)); + cha_cfg.validate().expect("ChaCha config validates"); + } + #[test] fn codec_negotiation_and_back_compat() { // resolve_codec precedence (HEVC > AV1 > H.264), no preference (0). @@ -656,6 +799,8 @@ mod tests { audio_channels: 2, codec: CODEC_PYROWAVE, host_caps: 0, + cipher: 0, + key_chacha: None, } .encode(), ) @@ -726,6 +871,8 @@ mod tests { audio_channels: 2, codec: CODEC_H264, host_caps: 0, + cipher: 0, + key_chacha: None, } .encode(), ) @@ -831,6 +978,8 @@ mod tests { audio_channels: 6, // 5.1 — exercises the non-default trailing byte codec: CODEC_HEVC, host_caps: HOST_CAP_GAMEPAD_STATE, + cipher: 0, + key_chacha: None, }; let wenc = w.encode(); assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps diff --git a/crates/punktfunk-core/src/session.rs b/crates/punktfunk-core/src/session.rs index eb72a3d6..30bf27fc 100644 --- a/crates/punktfunk-core/src/session.rs +++ b/crates/punktfunk-core/src/session.rs @@ -781,6 +781,7 @@ impl Session { mod wire_equivalence_tests { use super::*; use crate::config::{FecConfig, FecScheme, ProtocolPhase}; + use crate::crypto::SessionKey; use crate::transport::loopback_pair; fn host_cfg(scheme: FecScheme, fec_percent: u8, encrypt: bool) -> Config { @@ -798,7 +799,7 @@ mod wire_equivalence_tests { shard_payload: 64, max_frame_bytes: 8 * 1024 * 1024, encrypt, - key: [7u8; 16], + key: SessionKey::Aes128Gcm([7u8; 16]), salt: [3, 1, 4, 1], loopback_drop_period: 0, } @@ -930,7 +931,7 @@ mod wire_equivalence_tests { shard_payload: 1024, max_frame_bytes: 8 * 1024 * 1024, encrypt: false, - key: [0u8; 16], + key: SessionKey::Aes128Gcm([0u8; 16]), salt: [0u8; 4], loopback_drop_period: 0, }; diff --git a/crates/punktfunk-core/tests/loopback.rs b/crates/punktfunk-core/tests/loopback.rs index 7be7e9ee..51860c0c 100644 --- a/crates/punktfunk-core/tests/loopback.rs +++ b/crates/punktfunk-core/tests/loopback.rs @@ -5,6 +5,7 @@ use proptest::prelude::*; use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role}; +use punktfunk_core::crypto::SessionKey; use punktfunk_core::fec::coder_for; use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::session::Session; @@ -25,7 +26,7 @@ fn config(role: Role, scheme: FecScheme, encrypt: bool, drop_period: u32) -> Con shard_payload: 1024, max_frame_bytes: 8 * 1024 * 1024, encrypt, - key: [7u8; 16], + key: SessionKey::Aes128Gcm([7u8; 16]), salt: [1, 2, 3, 4], loopback_drop_period: drop_period, } @@ -101,6 +102,30 @@ fn encrypted_stream_recovers_under_loss() { assert_eq!(stats.frames_completed, frames.len() as u64); } +/// The negotiated ChaCha20-Poly1305 session cipher through the same lossy full-stream path: +/// loss/replay behavior is cipher-independent (the replay window keys off the authenticated +/// seq), so recovery must be byte-identical to the AES run above. +#[test] +fn chacha20_encrypted_stream_recovers_under_loss() { + let frames = sample_frames(); + let mk = |role| { + let mut c = config(role, FecScheme::Gf16, true, 8); + c.key = SessionKey::ChaCha20Poly1305([7u8; 32]); + c + }; + let (host_tp, client_tp) = loopback_pair(8, 0); + let mut host = Session::new(mk(Role::Host), Box::new(host_tp)).unwrap(); + let mut client = Session::new(mk(Role::Client), Box::new(client_tp)).unwrap(); + for (i, frame) in frames.iter().enumerate() { + host.submit_frame(frame, i as u64 * 1_000_000, 0).unwrap(); + let got = client + .poll_frame() + .expect("frame should recover despite loss"); + assert_eq!(&got.data, frame, "frame {i} mismatched after recovery"); + } + assert!(client.stats().fec_recovered_shards > 0); +} + #[test] fn lossless_stream_is_exact() { let frames = sample_frames(); diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 8f5779aa..f9d41128 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -354,6 +354,28 @@ pub(super) async fn negotiate( // just follow. let mut salt = [0u8; 4]; rand::thread_rng().fill_bytes(&mut salt); + // Session AEAD: ChaCha20-Poly1305 when the client asked for it (VIDEO_CAP_CHACHA20 — the + // soft-AES armv7 targets, whose GCM decrypt caps at ~100 Mbps) and the operator + // kill-switch allows (PUNKTFUNK_CHACHA20, default on — pure rollout safety; perf-only, + // both AEADs are full-strength). The fresh-per-session discipline above applies to this + // key identically; the legacy 16-byte `key` stays independently random so nothing + // downstream ever observes an all-zero key. + let client_wants_chacha = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_CHACHA20 != 0; + let chacha = client_wants_chacha && pf_host_config::config().chacha20; + let key_chacha = chacha.then(|| { + let mut k = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut k); + k + }); + tracing::info!( + cipher = if chacha { + "chacha20-poly1305" + } else { + "aes-128-gcm" + }, + client_wants_chacha, + "session cipher" + ); let welcome = Welcome { abi_version: punktfunk_core::WIRE_VERSION, udp_port, @@ -423,6 +445,16 @@ pub(super) async fn negotiate( } else { 0 }, + // The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha + // client; toward everyone else cipher 0 keeps the Welcome byte-identical to the + // pre-cipher wire form. The host's own data plane picks the cipher up via + // `welcome.session_config` — no other host change. + cipher: if chacha { + punktfunk_core::quic::CIPHER_CHACHA20_POLY1305 + } else { + punktfunk_core::quic::CIPHER_AES_128_GCM + }, + key_chacha, }; io::write_msg(send, &welcome.encode()).await?; bringup.mark("welcome"); diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 46202b13..7eddd74a 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -90,6 +90,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t | `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. | | `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. Windows host, plus the Linux **GNOME 50+ GameStream desktop mirror** (`PUNKTFUNK_VIDEO_SOURCE=portal`, mirrored monitor in HDR mode — check with `punktfunk-host hdr-probe`). Linux **virtual displays** (native protocol, GameStream default) stay 8-bit: Mutter's virtual-monitor screencast is SDR-only upstream. | | `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. | +| `PUNKTFUNK_CHACHA20` | `1` · `0` *(default on)* | ChaCha20-Poly1305 session encryption for clients without hardware AES (old ARM TVs, e.g. webOS), lifting their ~100 Mbps software-AES decrypt ceiling. **On by default** on the host; a session uses it only when the client requests it — everyone else stays on AES-GCM. Purely a performance choice (both ciphers are full-strength); set `0` to force AES-GCM for all sessions. | | `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. | | `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. | | `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. | diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 1d3b5574..1c55ceca 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -272,7 +272,7 @@ #define INBOUND_REQ_FLAG 2147483648 #endif -// 16-byte AEAD authentication tag appended by GCM. +// 16-byte AEAD authentication tag appended by either session cipher. #define TAG_LEN 16 // Wire tag distinguishing an input datagram from a video packet. @@ -465,6 +465,20 @@ #define VIDEO_CAP_STREAMED_AU 32 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`Hello::video_caps`] bit: the client can open **ChaCha20-Poly1305**-sealed session datagrams +// AND requests them — set by clients without hardware AES (the soft-AES armv7 targets, e.g. +// webOS TVs), where GCM's software AES + GHASH caps decrypt at ~100 Mbps while ChaCha's ARX +// construction runs 4–7× faster in portable code (design/chacha20-session-cipher.md). +// Support-plus-request in one bit mirrors [`VIDEO_CAP_444`]'s "capable AND turned on" +// precedent. The host grants it only when its `PUNKTFUNK_CHACHA20` kill-switch (default on) +// allows, answering with [`Welcome::cipher`] `= 1` + the 32-byte [`Welcome::key_chacha`]; +// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a +// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS +// control channel, so there is no downgrade surface. +#define VIDEO_CAP_CHACHA20 64 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`] // (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder @@ -855,6 +869,18 @@ #define HELLO_LAUNCH_MAX 128 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the +// only one pre-cipher builds know). +#define CIPHER_AES_128_GCM 0 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via +// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES. +#define CIPHER_CHACHA20_POLY1305 1 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`PairRequest`]. #define MSG_PAIR_REQUEST 16 diff --git a/tools/loss-harness/src/main.rs b/tools/loss-harness/src/main.rs index 6a52d6eb..04c300aa 100644 --- a/tools/loss-harness/src/main.rs +++ b/tools/loss-harness/src/main.rs @@ -6,6 +6,7 @@ //! harness adds `tc netem` jitter/reorder on the UDP path. use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role}; +use punktfunk_core::crypto::SessionKey; use punktfunk_core::error::PunktfunkError; use punktfunk_core::session::Session; use punktfunk_core::transport::loopback_pair; @@ -25,7 +26,7 @@ fn config(role: Role, scheme: FecScheme, drop_period: u32) -> Config { shard_payload: 1024, max_frame_bytes: 8 * 1024 * 1024, encrypt: false, - key: [0u8; 16], + key: SessionKey::Aes128Gcm([0u8; 16]), salt: [0u8; 4], loopback_drop_period: drop_period, }