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:
+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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user