From 053d330a1be9a1732dd82b05ac96eb0d7b04c58d Mon Sep 17 00:00:00 2001 From: Tobias Waurick Date: Sun, 19 Jul 2026 13:58:12 +0200 Subject: [PATCH] feat(sframe)!: migrate to existing sframe crate Switching the custom sframe implementation to the public sframe crate. This library is fully compliant to the latest spec (RFC 9605). It also allows swapping the crypto backend. Switched to ring as it more performant than rust crypto and the AES ctr modes are not used. The sframe-gbp public API was left untouched, but as derived keys differ from the old build, all peers must upgrade together, so this is breaking --- Cargo.lock | 84 +++++++------- Cargo.toml | 3 +- crates/gbp-sframe/Cargo.toml | 4 +- crates/gbp-sframe/src/cipher.rs | 188 ++++++++++---------------------- crates/gbp-sframe/src/header.rs | 142 ++++++------------------ crates/gbp-sframe/src/kdf.rs | 82 ++------------ crates/gbp-sframe/src/lib.rs | 13 +-- crates/gbp-sframe/src/replay.rs | 170 ----------------------------- 8 files changed, 153 insertions(+), 533 deletions(-) delete mode 100644 crates/gbp-sframe/src/replay.rs diff --git a/Cargo.lock b/Cargo.lock index 7ab658a..33fcd50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,6 +254,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bitfield" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -403,12 +423,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" - [[package]] name = "codespan" version = "0.13.1" @@ -578,15 +592,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -697,7 +702,6 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", - "ctutils", ] [[package]] @@ -774,7 +778,7 @@ dependencies = [ "ff", "generic-array", "group", - "hkdf 0.12.4", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -1066,10 +1070,8 @@ dependencies = [ name = "gbp-sframe" version = "1.8.2" dependencies = [ - "aes-gcm", "gbp-mls", - "hkdf 0.13.0", - "sha2 0.11.0", + "sframe", "thiserror 2.0.18", ] @@ -1339,16 +1341,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", -] - -[[package]] -name = "hkdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" -dependencies = [ - "hmac 0.13.0", + "hmac", ] [[package]] @@ -1360,15 +1353,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - [[package]] name = "hpke-rs" version = "0.6.1" @@ -1423,7 +1407,7 @@ checksum = "14b28be6cba9081c7feda2651d51c2a900029798e78b4c1e093e792f4571a870" dependencies = [ "aes-gcm", "chacha20poly1305", - "hkdf 0.12.4", + "hkdf", "hpke-rs-crypto", "k256", "p256", @@ -2100,8 +2084,8 @@ dependencies = [ "aes-gcm", "chacha20poly1305", "ed25519-dalek", - "hkdf 0.12.4", - "hmac 0.12.1", + "hkdf", + "hmac", "hpke-rs", "hpke-rs-crypto", "hpke-rs-rust-crypto", @@ -2723,7 +2707,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -2968,6 +2952,20 @@ dependencies = [ "zmij", ] +[[package]] +name = "sframe" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99c78c214a65f32373c967ef03cee6e39a12f9d16dacd1b85d9b2c9f855cd491" +dependencies = [ + "bitfield", + "cfg-if", + "cfg_aliases", + "log", + "ring", + "thiserror 2.0.18", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/Cargo.toml b/Cargo.toml index 6882250..366ae6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,9 +56,8 @@ serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" chacha20poly1305 = "0.10" -aes-gcm = "0.10" -hkdf = "0.13" sha2 = "0.11" +sframe = { version = "1.3", default-features = false, features = ["ring"] } gbp-sframe = { path = "crates/gbp-sframe", version = "1.8.2" } gbp-proto = { path = "crates/gbp-proto", version = "1.8.2" } diff --git a/crates/gbp-sframe/Cargo.toml b/crates/gbp-sframe/Cargo.toml index 069939b..255fc87 100644 --- a/crates/gbp-sframe/Cargo.toml +++ b/crates/gbp-sframe/Cargo.toml @@ -14,9 +14,7 @@ categories = ["cryptography", "multimedia::audio"] [dependencies] gbp-mls = { workspace = true } thiserror = { workspace = true } -hkdf = "0.13" -sha2 = "0.11" -aes-gcm = "0.10" +sframe = { workspace = true } [dev-dependencies] gbp-mls = { workspace = true } diff --git a/crates/gbp-sframe/src/cipher.rs b/crates/gbp-sframe/src/cipher.rs index 8535dbe..6de4c02 100644 --- a/crates/gbp-sframe/src/cipher.rs +++ b/crates/gbp-sframe/src/cipher.rs @@ -1,122 +1,59 @@ use std::collections::HashMap; -use aes_gcm::aead::{Aead, KeyInit, Payload}; -use aes_gcm::{Aes128Gcm, Aes256Gcm}; +use sframe::frame::{EncryptedFrameView, MediaFrameView, MonotonicCounter, ReplayAttackProtection}; +use sframe::header::KeyId; +use sframe::key::{DecryptionKey, EncryptionKey}; use crate::error::SFrameError; use crate::header::SFrameHeader; -use crate::kdf::{CipherSuite, ParticipantKeys, derive_participant, make_nonce}; -use crate::replay::ReplayWindow; +use crate::kdf::CipherSuite; -// ─── Internal AEAD helper ──────────────────────────────────────────────────── - -enum AeadCipher { - Aes128(Aes128Gcm), - Aes256(Aes256Gcm), -} - -impl AeadCipher { - fn new(key: &[u8], suite: CipherSuite) -> Self { - match suite { - CipherSuite::Aes128Gcm => { - Self::Aes128(Aes128Gcm::new_from_slice(key).expect("key length matches suite")) - } - CipherSuite::Aes256Gcm => { - Self::Aes256(Aes256Gcm::new_from_slice(key).expect("key length matches suite")) - } - } - } - - fn encrypt( - &self, - nonce: &[u8; 12], - plaintext: &[u8], - aad: &[u8], - ) -> Result, SFrameError> { - let n = aes_gcm::Nonce::from_slice(nonce); - let payload = Payload { - msg: plaintext, - aad, - }; - match self { - Self::Aes128(c) => c.encrypt(n, payload).map_err(|_| SFrameError::Encrypt), - Self::Aes256(c) => c.encrypt(n, payload).map_err(|_| SFrameError::Encrypt), - } - } - - fn decrypt( - &self, - nonce: &[u8; 12], - ciphertext: &[u8], - aad: &[u8], - ) -> Result, SFrameError> { - let n = aes_gcm::Nonce::from_slice(nonce); - let payload = Payload { - msg: ciphertext, - aad, - }; - match self { - Self::Aes128(c) => c.decrypt(n, payload).map_err(|_| SFrameError::Decrypt), - Self::Aes256(c) => c.decrypt(n, payload).map_err(|_| SFrameError::Decrypt), - } - } -} +/// Replay-window tolerance in frames (gbp's historical fixed 1024-frame window). +const REPLAY_WINDOW: u64 = 1024; // ─── SFrameEncryptor ───────────────────────────────────────────────────────── /// Stateful per-sender SFrame encryptor. /// -/// Holds the derived key+salt for one `(epoch, leaf_index)` pair and an -/// internal counter that increments on every call to [`encrypt`]. +/// Holds the derived key for one `(epoch, leaf_index)` KID and an internal +/// counter that increments on every call to [`encrypt`](Self::encrypt). /// /// Obtain via [`crate::SFrameSession::encryptor`]. pub struct SFrameEncryptor { - cipher: AeadCipher, - base_nonce: [u8; 12], - kid: u64, - ctr: u64, + key: EncryptionKey, + counter: MonotonicCounter, + kid: KeyId, } impl SFrameEncryptor { - pub(crate) fn new(keys: ParticipantKeys, kid: u64, suite: CipherSuite) -> Self { + pub(crate) fn new(base_key: &[u8; 32], kid: u64, suite: CipherSuite) -> Self { + let key = EncryptionKey::derive_from(suite.to_sframe(), kid, base_key) + .expect("key derivation from a 32-byte base key never fails"); Self { - cipher: AeadCipher::new(&keys.key, suite), - base_nonce: keys.base_nonce, + key, + counter: MonotonicCounter::default(), kid, - ctr: 0, } } /// Encrypts `plaintext` and returns the complete SFrame payload: /// `header ‖ ciphertext ‖ GCM-tag`. /// - /// `extra_aad` is appended to the SFrame header to form the full AAD - /// (e.g. pass an RTP header or an empty slice). + /// `extra_aad` is bound into the AEAD tag (e.g. an RTP header) but is **not** + /// carried in the returned payload; the receiver supplies the same slice to + /// [`SFrameDecryptor::decrypt`]. pub fn encrypt(&mut self, plaintext: &[u8], extra_aad: &[u8]) -> Result, SFrameError> { - let header = SFrameHeader { - kid: self.kid, - ctr: self.ctr, - }; - let header_bytes = header.encode(); - - let mut aad = Vec::with_capacity(header_bytes.len() + extra_aad.len()); - aad.extend_from_slice(&header_bytes); - aad.extend_from_slice(extra_aad); - - let nonce = make_nonce(&self.base_nonce, self.ctr); - let ciphertext = self.cipher.encrypt(&nonce, plaintext, &aad)?; - - self.ctr = self.ctr.wrapping_add(1); + let frame = MediaFrameView::with_meta_data(&mut self.counter, plaintext, extra_aad); + let encrypted = frame.encrypt(&self.key).map_err(|_| SFrameError::Encrypt)?; - let mut out = Vec::with_capacity(header_bytes.len() + ciphertext.len()); - out.extend_from_slice(&header_bytes); - out.extend_from_slice(&ciphertext); - Ok(out) + // sframe serialises `meta_data ‖ header ‖ ciphertext`; strip the + // metadata prefix so `extra_aad` stays off the wire. + Ok(encrypted.as_ref()[extra_aad.len()..].to_vec()) } /// Current counter value (number of frames encrypted so far). pub fn counter(&self) -> u64 { - self.ctr + self.counter.current() } /// KID this encryptor was created for. @@ -128,24 +65,27 @@ impl SFrameEncryptor { // ─── SFrameDecryptor ───────────────────────────────────────────────────────── /// Per-sender decryption state maintained inside [`SFrameDecryptor`]. +/// +/// One [`ReplayAttackProtection`] per KID: sframe's validator keys off the +/// counter only (it ignores the KID), so a shared validator would mix +/// interleaved senders' counters and raise false rejections. struct SenderState { - cipher: AeadCipher, - base_nonce: [u8; 12], - window: ReplayWindow, + key: DecryptionKey, + replay: ReplayAttackProtection, } /// Multi-sender SFrame decryptor for one epoch. /// -/// Lazily derives per-sender key material from the epoch's base key as new -/// `KID`s are encountered. Maintains an independent replay window per sender. +/// Lazily derives per-sender key material and replay state from the epoch's +/// base key as new `KID`s are encountered. /// /// Obtain via [`crate::SFrameSession::decryptor`]. pub struct SFrameDecryptor { base_key: [u8; 32], epoch: u64, suite: CipherSuite, - /// Keyed by `leaf_index`. - senders: HashMap, + /// Keyed by KID. + senders: HashMap, } impl SFrameDecryptor { @@ -166,48 +106,36 @@ impl SFrameDecryptor { payload: &[u8], extra_aad: &[u8], ) -> Result<(Vec, u32), SFrameError> { - let (header, header_len) = SFrameHeader::decode(payload)?; + let view = EncryptedFrameView::try_with_meta_data(&payload, &extra_aad) + .map_err(|e| SFrameError::Header(e.to_string()))?; + + let kid = view.header().key_id(); + let ctr = view.header().counter(); - let frame_epoch = SFrameHeader::epoch_from_kid(header.kid); - if frame_epoch != self.epoch { - return Err(SFrameError::UnknownKid(header.kid)); + if SFrameHeader::epoch_from_kid(kid) != SFrameHeader::epoch_lsb(self.epoch) { + return Err(SFrameError::UnknownKid(kid)); } - let leaf = SFrameHeader::leaf_from_kid(header.kid); - - // Lazily derive key material for this sender. - let state = self.senders.entry(leaf).or_insert_with(|| { - let keys = derive_participant(&self.base_key, leaf, self.suite); - SenderState { - cipher: AeadCipher::new(&keys.key, self.suite), - base_nonce: keys.base_nonce, - window: ReplayWindow::new(), - } + let leaf = SFrameHeader::leaf_from_kid(kid); + + let suite = self.suite; + let base_key = self.base_key; + let state = self.senders.entry(kid).or_insert_with(|| SenderState { + key: DecryptionKey::derive_from(suite.to_sframe(), kid, base_key) + .expect("key derivation from a 32-byte base key never fails"), + replay: ReplayAttackProtection::with_tolerance(REPLAY_WINDOW), }); - // Replay check before decryption (fast path for replays). - state - .window - .check_and_mark(header.ctr) - .map_err(|_| SFrameError::Replay { - kid: header.kid, - ctr: header.ctr, - })?; - - let header_bytes = &payload[..header_len]; - let ciphertext = &payload[header_len..]; - - let mut aad = Vec::with_capacity(header_bytes.len() + extra_aad.len()); - aad.extend_from_slice(header_bytes); - aad.extend_from_slice(extra_aad); - - let nonce = make_nonce(&state.base_nonce, header.ctr); - let plaintext = state.cipher.decrypt(&nonce, ciphertext, &aad)?; + // Replay check before decryption (matches sframe's example flow). + let view = view + .validate(&state.replay) + .map_err(|_| SFrameError::Replay { kid, ctr })?; - Ok((plaintext, leaf)) + let media = view.decrypt(&state.key).map_err(|_| SFrameError::Decrypt)?; + Ok((media.payload().to_vec(), leaf)) } - /// Resets all per-sender replay windows (call on epoch change). + /// Drops all per-sender key + replay state (call on epoch change). pub fn reset(&mut self) { - self.senders.values_mut().for_each(|s| s.window.reset()); + self.senders.clear(); } } diff --git a/crates/gbp-sframe/src/header.rs b/crates/gbp-sframe/src/header.rs index b44ef90..3d0047d 100644 --- a/crates/gbp-sframe/src/header.rs +++ b/crates/gbp-sframe/src/header.rs @@ -1,132 +1,62 @@ -use crate::error::SFrameError; +//! SFrame Key ID scheme. +//! +//! The on-the-wire header (RFC 9605 §4.3) is produced and parsed entirely by +//! the [`sframe`] crate. This module only decides how gbp packs +//! `(epoch, leaf_index)` into the RFC's MLS Key ID, using sframe's own +//! [`MlsKeyId`] definition (RFC 9605 §5.2). -/// Decoded SFrame header fields. +use sframe::mls::{MlsKeyId, MlsKeyIdBitRange}; + +/// Bits of the MLS epoch encoded in the KID (its least-significant bits). +pub const EPOCH_BITS: u8 = 16; +/// Bits of the sender's member index encoded in the KID (up to 65 535 senders). +pub const INDEX_BITS: u8 = 16; + +/// Context ID component of the KID; `0` yields the shortest RFC Key ID. +const CONTEXT_ID: u64 = 0; + +fn bit_range() -> MlsKeyIdBitRange { + MlsKeyIdBitRange::new(EPOCH_BITS, INDEX_BITS) +} + +/// SFrame Key ID helper, backed by sframe's [`MlsKeyId`] (RFC 9605 §5.2). /// -/// Wire format (draft-ietf-sframe-enc §4.3): -/// ```text -/// ┌─┬──────────┬──────────┬───────────────┬───────────────┐ -/// │V│ K (3) │ C (4) │ KID (var) │ CTR (var) │ -/// └─┴──────────┴──────────┴───────────────┴───────────────┘ -/// ``` -/// * `V` (1 bit) — version, always `0` for SFrame v1. -/// * `K` (3 bits) — KID length in bytes minus one (0 → 1 byte, 7 → 8 bytes). -/// * `C` (4 bits) — CTR length in bytes minus one (0 → 1 byte, 15 → 16 bytes). -/// * `KID` — Key ID: `(epoch << 16) | leaf_index`. -/// * `CTR` — monotonic per-sender counter, big-endian. +/// Packs `(epoch_lsb, member_index)` into the Key ID that the `sframe` crate +/// carries in the RFC header, and unpacks it again on receipt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SFrameHeader { - /// Key ID: encodes both epoch and sender leaf index. - pub kid: u64, - /// Per-sender monotonic counter. - pub ctr: u64, -} +pub struct SFrameHeader; impl SFrameHeader { - /// Constructs the KID from an epoch and a leaf index. - /// - /// Uses the lower 16 bits of `leaf_index` (supports up to 65 535 senders - /// per epoch) and the full 48 available bits of `epoch`. + /// Builds the KID for `(epoch, leaf_index)`. #[inline] pub fn kid_from(epoch: u64, leaf_index: u32) -> u64 { - (epoch << 16) | (u64::from(leaf_index) & 0xFFFF) + MlsKeyId::new(CONTEXT_ID, epoch, u64::from(leaf_index), bit_range()).into() } - /// Extracts the epoch from a KID. + /// Extracts the epoch's low [`EPOCH_BITS`] bits from a KID. #[inline] pub fn epoch_from_kid(kid: u64) -> u64 { - kid >> 16 + MlsKeyId::from_key_id(kid, bit_range()).epoch_lsb() } - /// Extracts the leaf index from a KID. + /// Reduces a full epoch to the low [`EPOCH_BITS`] bits stored in a KID, so + /// it can be compared against [`epoch_from_kid`](Self::epoch_from_kid). #[inline] - pub fn leaf_from_kid(kid: u64) -> u32 { - (kid & 0xFFFF) as u32 - } - - /// Encodes the header into bytes. - /// - /// Layout: `[flags_byte] [kid_bytes...] [ctr_bytes...]` - /// where `flags_byte = 0 | ((kid_len-1) << 4) | (ctr_len-1)`. - pub fn encode(self) -> Vec { - let kid_bytes = encode_uint(self.kid); - let ctr_bytes = encode_uint(self.ctr); - - let k = (kid_bytes.len() as u8 - 1) & 0x07; // 3 bits - let c = (ctr_bytes.len() as u8 - 1) & 0x0F; // 4 bits - // V=0 (bit 7), K (bits 6-4), C (bits 3-0) - let flags = (k << 4) | c; - - let mut out = Vec::with_capacity(1 + kid_bytes.len() + ctr_bytes.len()); - out.push(flags); - out.extend_from_slice(&kid_bytes); - out.extend_from_slice(&ctr_bytes); - out + pub fn epoch_lsb(epoch: u64) -> u64 { + Self::epoch_from_kid(Self::kid_from(epoch, 0)) } - /// Decodes a header from the front of `data`. - /// - /// Returns the decoded header and the number of bytes consumed. - pub fn decode(data: &[u8]) -> Result<(Self, usize), SFrameError> { - if data.is_empty() { - return Err(SFrameError::Header("empty payload".into())); - } - let flags = data[0]; - // V must be 0. - if flags & 0x80 != 0 { - return Err(SFrameError::Header(format!( - "unsupported SFrame version (V bit set in flags {flags:#04x})" - ))); - } - let kid_len = ((flags >> 4) & 0x07) as usize + 1; // 1-8 bytes - let ctr_len = (flags & 0x0F) as usize + 1; // 1-16 bytes - let total = 1 + kid_len + ctr_len; - - if data.len() < total { - return Err(SFrameError::Header(format!( - "need {total} bytes for header but only {} available", - data.len() - ))); - } - - let kid = decode_uint_be(&data[1..1 + kid_len]); - let ctr = decode_uint_be(&data[1 + kid_len..total]); - Ok((Self { kid, ctr }, total)) + /// Extracts the sender's leaf/member index from a KID. + #[inline] + pub fn leaf_from_kid(kid: u64) -> u32 { + MlsKeyId::from_key_id(kid, bit_range()).member_index() as u32 } } -/// Encodes an integer as the minimal number of big-endian bytes (at least 1). -fn encode_uint(v: u64) -> Vec { - let needed = ((64 - v.leading_zeros() + 7) / 8) as usize; - let needed = needed.max(1); - v.to_be_bytes()[8 - needed..].to_vec() -} - -/// Decodes a big-endian byte slice into a u64. -fn decode_uint_be(data: &[u8]) -> u64 { - data.iter().fold(0u64, |acc, &b| (acc << 8) | u64::from(b)) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn encode_decode_roundtrip() { - for (kid, ctr) in [ - (0, 0), - (1, 1), - (0xFFFF, 0), - (0x0001_FFFF, 0xDEAD), - (u64::MAX >> 16, u64::from(u16::MAX)), - ] { - let hdr = SFrameHeader { kid, ctr }; - let encoded = hdr.encode(); - let (decoded, consumed) = SFrameHeader::decode(&encoded).unwrap(); - assert_eq!(decoded, hdr, "kid={kid:#x} ctr={ctr:#x}"); - assert_eq!(consumed, encoded.len()); - } - } - #[test] fn kid_round_trip() { let epoch = 42u64; diff --git a/crates/gbp-sframe/src/kdf.rs b/crates/gbp-sframe/src/kdf.rs index ccd447a..9a6c7ce 100644 --- a/crates/gbp-sframe/src/kdf.rs +++ b/crates/gbp-sframe/src/kdf.rs @@ -1,6 +1,4 @@ use gbp_mls::MlsContext; -use hkdf::Hkdf; -use sha2::Sha256; use crate::error::SFrameError; @@ -10,21 +8,13 @@ use crate::error::SFrameError; /// high-assurance deployments. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CipherSuite { - /// AES-128-GCM: 16-byte key, 12-byte nonce. + /// AES-128-GCM with SHA-256 key expansion (RFC 9605 `AES_128_GCM_SHA256_128`). Aes128Gcm, - /// AES-256-GCM: 32-byte key, 12-byte nonce. + /// AES-256-GCM with SHA-512 key expansion (RFC 9605 `AES_256_GCM_SHA512_128`). Aes256Gcm, } impl CipherSuite { - /// Key length in bytes. - pub(crate) fn key_len(self) -> usize { - match self { - Self::Aes128Gcm => 16, - Self::Aes256Gcm => 32, - } - } - /// Numeric discriminant used in the FFI (`0` = AES-128, `1` = AES-256). pub fn from_u8(v: u8) -> Option { match v { @@ -41,22 +31,22 @@ impl CipherSuite { Self::Aes256Gcm => 1, } } -} -/// Derived key material for one participant in one epoch. -pub(crate) struct ParticipantKeys { - /// AES key: 16 bytes for AES-128-GCM, 32 bytes for AES-256-GCM. - pub key: Vec, - /// 12-byte base nonce (XOR'd with the per-frame counter to produce each frame nonce). - pub base_nonce: [u8; 12], + /// Maps to the corresponding RFC 9605 suite in the `sframe` crate. + pub(crate) fn to_sframe(self) -> sframe::CipherSuite { + match self { + Self::Aes128Gcm => sframe::CipherSuite::AesGcm128Sha256, + Self::Aes256Gcm => sframe::CipherSuite::AesGcm256Sha512, + } + } } /// Derives the 32-byte SFrame base key from the MLS `ExportSecret`. /// -/// `label` is the application-defined export label -/// (e.g. `"gbp/sframe v1"`). +/// `label` is the application-defined export label (e.g. `"gbp/sframe v1"`). /// `epoch` is passed as an 8-byte big-endian context to bind the key to the -/// current MLS epoch. +/// current MLS epoch. Per-sender keys are then expanded from this base key by +/// the `sframe` crate (RFC 9605 §5.2), keyed by the frame's KID. pub fn derive_base_key(mls: &MlsContext, label: &str, epoch: u64) -> Result<[u8; 32], SFrameError> { let context = epoch.to_be_bytes(); let raw = mls @@ -66,51 +56,3 @@ pub fn derive_base_key(mls: &MlsContext, label: &str, epoch: u64) -> Result<[u8; out.copy_from_slice(&raw); Ok(out) } - -// Protocol-defined HKDF domain-separation labels (public constants, not secret values). -// HKDF-Expand takes an `info` parameter that is intentionally a well-known, fixed label; -// the *output* is the derived key material, which is cryptographically bound to `base_key`. -const HKDF_LABEL_KEY: &[u8] = b"gbp sframe key "; -const HKDF_LABEL_NONCE: &[u8] = b"gbp sframe salt "; - -/// Derives the encryption key and base nonce for participant `leaf_index`. -/// -/// Uses HKDF-Expand (SHA-256) over the epoch's `base_key` with -/// deterministic domain labels so every member can reproduce any sender's -/// key material. -pub(crate) fn derive_participant( - base_key: &[u8; 32], - leaf_index: u32, - suite: CipherSuite, -) -> ParticipantKeys { - // SAFETY: base_key is 32 bytes = SHA-256 HashLen, so from_prk never panics. - let hk = - Hkdf::::from_prk(base_key).expect("base_key is exactly SHA-256 HashLen (32 bytes)"); - - let leaf_be = leaf_index.to_be_bytes(); - - let mut label = HKDF_LABEL_KEY.to_vec(); - label.extend_from_slice(&leaf_be); - let mut key = vec![0u8; suite.key_len()]; - hk.expand(&label, &mut key) - .expect("key length is well within 255 * HashLen"); - - let mut label = HKDF_LABEL_NONCE.to_vec(); - label.extend_from_slice(&leaf_be); - let mut base_nonce: [u8; 12] = Default::default(); - hk.expand(&label, &mut base_nonce) - .expect("nonce length (12) is well within 255 * HashLen"); - - ParticipantKeys { key, base_nonce } -} - -/// Constructs the 12-byte per-frame nonce: -/// `participant_salt XOR (CTR_LE64 || 0x00_00_00_00)`. -pub(crate) fn make_nonce(salt: &[u8; 12], ctr: u64) -> [u8; 12] { - let mut nonce = *salt; - let ctr_le = ctr.to_le_bytes(); // 8 bytes little-endian - for i in 0..8 { - nonce[i] ^= ctr_le[i]; - } - nonce -} diff --git a/crates/gbp-sframe/src/lib.rs b/crates/gbp-sframe/src/lib.rs index 5002947..9bbfe78 100644 --- a/crates/gbp-sframe/src/lib.rs +++ b/crates/gbp-sframe/src/lib.rs @@ -24,9 +24,8 @@ //! After each MLS epoch change: //! //! 1. **Base key** — `MLS.ExportSecret(label, context=epoch_be8, length=32)`. -//! 2. **Per-sender key** — `HKDF-Expand(base_key, "gbp sframe key " ‖ leaf_be4, L)`. -//! 3. **Per-sender salt** — `HKDF-Expand(base_key, "gbp sframe salt " ‖ leaf_be4, 12)`. -//! 4. **Frame nonce** — `salt XOR (CTR_LE64 ‖ 0x00_00_00_00)`. +//! 2. **Per-sender key/salt/nonce** — expanded from the base key by the +//! [`sframe`] crate (RFC 9605 §5.2), keyed by the frame's KID. //! //! The `label` passed to [`SFrameSession::from_mls`] is application-defined //! (e.g. `"gbp/sframe v1"`); this lets different deployments use distinct @@ -59,12 +58,10 @@ pub mod cipher; /// Error type for SFrame operations. pub mod error; -/// SFrame header wire format. +/// SFrame Key ID scheme. pub mod header; /// Key derivation from MLS export secret. pub mod kdf; -/// Sliding-window replay protection. -pub mod replay; pub use cipher::{SFrameDecryptor, SFrameEncryptor}; pub use error::SFrameError; @@ -72,7 +69,6 @@ pub use header::SFrameHeader; pub use kdf::{CipherSuite, derive_base_key}; use gbp_mls::MlsContext; -use kdf::derive_participant; /// An SFrame session bound to one MLS epoch. /// @@ -132,8 +128,7 @@ impl SFrameSession { /// **not** share an encryptor across multiple goroutines/threads. pub fn encryptor(&self, leaf_index: u32) -> SFrameEncryptor { let kid = SFrameHeader::kid_from(self.epoch, leaf_index); - let keys = derive_participant(&self.base_key, leaf_index, self.suite); - SFrameEncryptor::new(keys, kid, self.suite) + SFrameEncryptor::new(&self.base_key, kid, self.suite) } /// Creates a receiver-side decryptor for this epoch. diff --git a/crates/gbp-sframe/src/replay.rs b/crates/gbp-sframe/src/replay.rs deleted file mode 100644 index 4e11b70..0000000 --- a/crates/gbp-sframe/src/replay.rs +++ /dev/null @@ -1,170 +0,0 @@ -/// Sliding-window replay-protection for a single `(KID, sender)` pair. -/// -/// Maintains a 1024-entry bit-window: `window[i]` is `1` iff counter -/// `base − i` has been received. `base` is the highest counter seen so far. -/// -/// Counters more than 1024 positions behind `base` are unconditionally -/// rejected as too old. Counters ahead of `base` advance the window. -pub struct ReplayWindow { - /// Highest counter seen. Undefined while `!initialized`. - base: u64, - /// 1024-bit window stored as 16 × u64 (LSB of word 0 = bit 0 = `base`). - window: [u64; 16], - initialized: bool, -} - -const WINDOW_SIZE: u64 = 1024; - -impl ReplayWindow { - /// Creates a new, empty replay window. - pub fn new() -> Self { - Self { - base: 0, - window: [0u64; 16], - initialized: false, - } - } - - /// Checks whether `ctr` is acceptable (not replayed, not too old) and - /// marks it as seen. - /// - /// Returns `Ok(())` on first acceptance; `Err(())` for replays or - /// overly-old counters. - pub fn check_and_mark(&mut self, ctr: u64) -> Result<(), ()> { - if !self.initialized { - self.base = ctr; - self.window[0] = 1; - self.initialized = true; - return Ok(()); - } - - if ctr > self.base { - let advance = ctr - self.base; - // Shift the existing bits "up" (toward higher positions) by - // `advance` to make room for the new base at bit 0. - shift_window_up(&mut self.window, advance as usize); - self.base = ctr; - // Mark bit 0 = new base = seen. - self.window[0] |= 1; - Ok(()) - } else { - let behind = self.base - ctr; - if behind >= WINDOW_SIZE { - return Err(()); // too old - } - let word = (behind / 64) as usize; - let bit = behind % 64; - if self.window[word] & (1u64 << bit) != 0 { - return Err(()); // replay - } - self.window[word] |= 1u64 << bit; - Ok(()) - } - } - - /// Resets the window (call on epoch change). - pub fn reset(&mut self) { - self.initialized = false; - self.window = [0u64; 16]; - } -} - -impl Default for ReplayWindow { - fn default() -> Self { - Self::new() - } -} - -/// Shifts the 1024-bit packed array "up" by `by` bit positions in place. -/// -/// "Up" means: old bit at position `n` moves to position `n + by`. -/// Positions `0..by` are cleared (they represent new, unseen counters -/// between old `base` and the new `base`). -/// -/// Iterates from high words to low so the in-place update is safe even when -/// the word shift is zero. -fn shift_window_up(window: &mut [u64; 16], by: usize) { - if by >= WINDOW_SIZE as usize { - *window = [0; 16]; - return; - } - let word_shift = by / 64; - let bit_shift = by % 64; - - // Process from the highest word down so we read unmodified source values. - for i in (0..16).rev() { - if i < word_shift { - window[i] = 0; - continue; - } - let src = i - word_shift; - if bit_shift == 0 { - window[i] = window[src]; - } else { - let lo = window[src]; - // `src − 1` supplies the bits that spill into the lower end of - // the new word when the shift crosses a 64-bit boundary. - let hi = if src > 0 { window[src - 1] } else { 0 }; - // Old bits in lo move UP (to higher positions) within new word i. - // Old bits in hi move UP too but overflow into lo-end of new word. - window[i] = (lo << bit_shift) | (hi >> (64 - bit_shift)); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fresh_window_accepts_first() { - let mut w = ReplayWindow::new(); - assert!(w.check_and_mark(0).is_ok()); - } - - #[test] - fn replay_is_rejected() { - let mut w = ReplayWindow::new(); - w.check_and_mark(10).unwrap(); - assert!(w.check_and_mark(10).is_err()); - } - - #[test] - fn in_order_sequence() { - let mut w = ReplayWindow::new(); - for i in 0..100u64 { - assert!(w.check_and_mark(i).is_ok(), "ctr={i} should be accepted"); - } - } - - #[test] - fn out_of_order_within_window() { - let mut w = ReplayWindow::new(); - // Accept 0, then 1023 (advance window), then try 0 again (replay). - w.check_and_mark(0).unwrap(); - w.check_and_mark(1023).unwrap(); - assert!( - w.check_and_mark(0).is_err(), - "0 should be detected as replay" - ); - // 500 is inside the window and not yet seen. - assert!(w.check_and_mark(500).is_ok()); - } - - #[test] - fn too_old_is_rejected() { - let mut w = ReplayWindow::new(); - w.check_and_mark(2000).unwrap(); - assert!(w.check_and_mark(0).is_err(), "0 is 2000 behind and too old"); - } - - #[test] - fn window_advance_across_word_boundary() { - let mut w = ReplayWindow::new(); - w.check_and_mark(63).unwrap(); - w.check_and_mark(127).unwrap(); // crosses 64-bit word boundary - assert!(w.check_and_mark(63).is_err()); - assert!(w.check_and_mark(127).is_err()); - assert!(w.check_and_mark(100).is_ok()); - } -}