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()); - } -}