feat(core+host): negotiate ChaCha20-Poly1305 as the session cipher for soft-AES clients
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Failing after 13s
audit / cargo-audit (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Successful in 10m1s
windows-host / package (push) Successful in 11m20s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m39s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Failing after 13s
audit / cargo-audit (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Successful in 10m1s
windows-host / package (push) Successful in 11m20s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m39s
Lifts the ~100 Mbps decrypt ceiling on clients without hardware AES — the armv7 soft-AES targets (webOS TVs), where AES-128-GCM resolves to fixsliced software AES + software GHASH (~50-100 cpb) while ChaCha20-Poly1305's ARX construction runs ~10-17 cpb portable, a 4-7x lift that PyroWave-on-TV needs (design/chacha20-session-cipher.md). Phase 1 (core crypto, no wire change): SessionKey merges cipher choice and key material (invalid combinations unrepresentable, zeroize + redacted-Debug discipline kept); SessionCrypto dispatches both aead-0.5 ciphers per call — the salt||seq nonce scheme, per-direction salts, seq-as-AAD and replay window carry over verbatim (same 96-bit nonce / 16-byte tag, const-asserted). Config.key becomes SessionKey; validate's zero-key rejection follows the active variant. The C ABI keeps its fixed 16-byte key mapped to AES — no ABI_VERSION bump. Phase 2 (negotiation): VIDEO_CAP_CHACHA20 (0x40) — support-plus-request in one bit, the VIDEO_CAP_444 precedent. Welcome grows cipher@68 + key_chacha@69..101, emitted only when non-zero so an AES session's Welcome stays byte-identical to the pre-cipher form; decode is fail-closed (short key or unknown id -> Err, never a silent AES fallback). No WIRE_VERSION bump; downgrade resistance inherited from the pinned-TLS control channel. Phase 3 (host): grant only when the client advertised the bit and the PUNKTFUNK_CHACHA20 kill-switch (default on, documented) allows; fresh 32-byte per-session key from the same RNG discipline, legacy key field stays independently random; resolved cipher logged at session start. Verification: seal/open suites parameterized over both ciphers + a cross-cipher tamper case; Welcome roundtrip/truncation/fail-closed tests; ChaCha lossy-loopback soak (loss/replay is cipher-independent); bench gains _chacha20 series (AES ids unchanged for CI history) — host-side sealing line-rate-trivial on both x86 (~640 MiB/s) and Apple Silicon (~535 MiB/s). punktfunk-probe drives the interop matrix via PUNKTFUNK_CLIENT_CHACHA20=1 and logs the negotiated cipher. Phase 4 (pf-webos pin bump + unconditional cap bit) follows the next core release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+37
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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", &"<redacted>")
|
||||
// SessionKey's own Debug redacts the material but keeps the cipher choice visible.
|
||||
.field("key", &self.key)
|
||||
.field("salt", &"<redacted>")
|
||||
.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());
|
||||
}
|
||||
|
||||
|
||||
+266
-106
@@ -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::<aes_gcm::Tag>() == TAG_LEN);
|
||||
const _: () = assert!(std::mem::size_of::<chacha20poly1305::Tag>() == 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(<redacted>)"),
|
||||
SessionKey::ChaCha20Poly1305(_) => f.write_str("ChaCha20Poly1305(<redacted>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::<Aes128Gcm>::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::<Aes128Gcm>::from_slice(k)))
|
||||
}
|
||||
SessionKey::ChaCha20Poly1305(k) => Cipher::ChaCha20Poly1305(ChaCha20Poly1305::new(
|
||||
Key::<ChaCha20Poly1305>::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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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("<redacted>"), "{dbg}");
|
||||
}
|
||||
let mut k = SessionKey::ChaCha20Poly1305([9u8; 32]);
|
||||
k.zeroize();
|
||||
assert!(k.is_zero());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:<appid>` / `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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user