Some checks failed
CI / lint (push) Successful in 1m37s
CI / test-python (push) Successful in 1m49s
CI / test-zig (push) Successful in 1m39s
CI / test-wasm (push) Successful in 1m54s
CI / test (push) Successful in 14m44s
CI / miri (push) Successful in 14m18s
CI / build (push) Successful in 1m9s
CI / fuzz-regression (push) Successful in 9m9s
CI / publish (push) Failing after 1m10s
CI / publish-python (push) Failing after 1m46s
CI / publish-wasm (push) Has been cancelled
Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
691 lines
27 KiB
Rust
691 lines
27 KiB
Rust
//! E2EE voice call key derivation.
|
||
//!
|
||
//! Derives call encryption key material for encrypted voice calls from the ratchet root
|
||
//! key and an ephemeral X-Wing KEM shared secret exchanged during call
|
||
//! signaling. The ephemeral KEM provides forward secrecy independent of
|
||
//! the ratchet — if the root key is later compromised (before the next KEM
|
||
//! ratchet step), the call content remains confidential.
|
||
//!
|
||
//! ## Key Derivation
|
||
//!
|
||
//! ```text
|
||
//! HKDF(salt=rk, ikm=kem_ss ‖ call_id, info="lo-call-v1" ‖ fp_lo ‖ fp_hi, L=96)
|
||
//! → key_a (32) | key_b (32) | chain_key (32)
|
||
//! ```
|
||
//!
|
||
//! ## Role Assignment
|
||
//!
|
||
//! The party with the lexicographically lower identity fingerprint uses
|
||
//! `key_a` as their send key and `key_b` as their recv key; the other
|
||
//! party reverses the assignment.
|
||
//!
|
||
//! ## Intra-Call Rekeying
|
||
//!
|
||
//! `CallKeys::advance()` ratchets the internal chain key forward:
|
||
//!
|
||
//! ```text
|
||
//! key_a' = HMAC-SHA3-256(chain_key, 0x04)
|
||
//! key_b' = HMAC-SHA3-256(chain_key, 0x05)
|
||
//! chain_key' = HMAC-SHA3-256(chain_key, 0x06)
|
||
//! ```
|
||
//!
|
||
//! The old chain key and call encryption keys are zeroized. This provides forward
|
||
//! secrecy within the call — compromise of a later call encryption key does not
|
||
//! reveal earlier media segments.
|
||
|
||
use crate::constants;
|
||
use crate::error::{Error, Result};
|
||
use crate::primitives::{hkdf, hmac};
|
||
use subtle::ConstantTimeEq;
|
||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||
|
||
/// Maximum number of `advance()` calls before the chain is exhausted.
|
||
/// 2^24 (16,777,216) steps — far beyond any realistic call duration even with
|
||
/// aggressive per-second rekeying.
|
||
const MAX_CALL_ADVANCE: u32 = 1 << 24;
|
||
|
||
/// Call encryption key state for an active E2EE voice/video call.
|
||
///
|
||
/// Holds the current send key, receive key, and chain key for intra-call
|
||
/// rekeying (§6.12). Role assignment (which key is send vs recv) is
|
||
/// determined by fingerprint ordering at construction time.
|
||
///
|
||
/// ## Lifecycle
|
||
///
|
||
/// 1. Obtain via `derive_call_keys` after the ephemeral KEM exchange.
|
||
/// 2. Read `send_key()` and `recv_key()` to configure the call encryption session.
|
||
/// 3. Periodically call `advance()` to rekey — `send_key()` and `recv_key()`
|
||
/// then return the new keys.
|
||
///
|
||
/// # Thread Safety
|
||
///
|
||
/// `CallKeys` auto-derives `Send + Sync` but is not designed for concurrent
|
||
/// access. `advance` requires `&mut self`. The CAPI layer adds a runtime
|
||
/// reentrancy guard for FFI callers.
|
||
#[derive(Zeroize, ZeroizeOnDrop)]
|
||
pub struct CallKeys {
|
||
/// Current send key (32 bytes).
|
||
send_key: [u8; 32],
|
||
/// Current recv key (32 bytes).
|
||
recv_key: [u8; 32],
|
||
/// Chain key for intra-call rekeying.
|
||
chain_key: [u8; 32],
|
||
/// `true` if the local party has the lexicographically lower fingerprint,
|
||
/// determining which HMAC output becomes the send vs recv key on each
|
||
/// `advance()` call.
|
||
#[zeroize(skip)]
|
||
lower_role: bool,
|
||
/// Number of `advance()` calls so far. Monotonically increasing;
|
||
/// `advance()` returns `ChainExhausted` at `MAX_CALL_ADVANCE`.
|
||
#[zeroize(skip)]
|
||
step_count: u32,
|
||
}
|
||
|
||
impl CallKeys {
|
||
/// Return the current send key (32 bytes).
|
||
pub fn send_key(&self) -> &[u8; 32] {
|
||
&self.send_key
|
||
}
|
||
|
||
/// Return the current recv key (32 bytes).
|
||
pub fn recv_key(&self) -> &[u8; 32] {
|
||
&self.recv_key
|
||
}
|
||
|
||
/// Return the current chain key bytes (test-utils only).
|
||
#[cfg(all(feature = "test-utils", debug_assertions))]
|
||
#[deprecated(note = "test-utils only — do not call in production code")]
|
||
pub fn chain_key_bytes(&self) -> &[u8; 32] {
|
||
&self.chain_key
|
||
}
|
||
|
||
/// Advance the call chain, replacing the current call encryption keys with fresh
|
||
/// key material.
|
||
///
|
||
/// Derives two new call encryption keys and a new chain key from the current chain
|
||
/// key via HMAC-SHA3-256. The old chain key and call encryption keys are zeroized
|
||
/// before replacement.
|
||
///
|
||
/// After this call, [`send_key`](Self::send_key) and
|
||
/// [`recv_key`](Self::recv_key) return the new keys. Role assignment
|
||
/// (which HMAC output is send vs recv) is preserved from the original
|
||
/// [`derive_call_keys`] call.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `ChainExhausted` after `MAX_CALL_ADVANCE` (2^24) calls. The
|
||
/// caller must establish a new call with a fresh KEM exchange rather than
|
||
/// continuing to rekey the exhausted chain.
|
||
pub fn advance(&mut self) -> Result<()> {
|
||
if self.step_count >= MAX_CALL_ADVANCE {
|
||
// Zeroize all key material on exhaustion — prevents callers that
|
||
// ignore the error from continuing with stale keys.
|
||
self.send_key.zeroize();
|
||
self.recv_key.zeroize();
|
||
self.chain_key.zeroize();
|
||
return Err(Error::ChainExhausted);
|
||
}
|
||
let mut key_a = hmac::hmac_sha3_256(&self.chain_key, constants::CALL_KEY_A_BYTE);
|
||
let mut key_b = hmac::hmac_sha3_256(&self.chain_key, constants::CALL_KEY_B_BYTE);
|
||
let mut next_chain = hmac::hmac_sha3_256(&self.chain_key, constants::CALL_CHAIN_ADV_BYTE);
|
||
|
||
// [u8; 32] is Copy — field assignment copies new values without
|
||
// dropping the old ones, so explicit zeroization prevents the
|
||
// previous keys from lingering on the stack.
|
||
self.chain_key.zeroize();
|
||
self.send_key.zeroize();
|
||
self.recv_key.zeroize();
|
||
|
||
self.chain_key = next_chain;
|
||
if self.lower_role {
|
||
self.send_key = key_a;
|
||
self.recv_key = key_b;
|
||
} else {
|
||
self.send_key = key_b;
|
||
self.recv_key = key_a;
|
||
}
|
||
|
||
// [u8; 32] is Copy — all assignments above created bitwise copies.
|
||
// Zeroize the stack originals.
|
||
key_a.zeroize();
|
||
key_b.zeroize();
|
||
next_chain.zeroize();
|
||
|
||
// Cannot overflow: MAX_CALL_ADVANCE (2^24) << u32::MAX, and the
|
||
// >= MAX_CALL_ADVANCE guard above bounds step_count before this point.
|
||
self.step_count += 1;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Derive call keys from the ratchet root key, an ephemeral KEM shared
|
||
/// secret, and a random call identifier.
|
||
///
|
||
/// Both parties must call this function with identical `root_key`, `kem_ss`,
|
||
/// and `call_id` values. The `local_fp`/`remote_fp` parameters determine
|
||
/// role assignment (which half becomes the send vs recv key) and are
|
||
/// naturally swapped between the two parties.
|
||
///
|
||
/// ## Protocol
|
||
///
|
||
/// 1. Call initiator (Alice) generates `call_id` (16 random bytes) and an
|
||
/// ephemeral X-Wing keypair `(ek_pub, ek_sk)`. Sends `call_id` + `ek_pub`
|
||
/// in a `CallOffer` message (encrypted via the ratchet).
|
||
/// 2. Responder (Bob) encapsulates to Alice's ephemeral public key:
|
||
/// `(ct, kem_ss) = XWing.Encaps(ek_pub)`. Sends `CallAnswer { ct }`
|
||
/// (encrypted via the ratchet).
|
||
/// 3. Alice decapsulates: `kem_ss = XWing.Decaps(ek_sk, ct)`. Zeroizes
|
||
/// `ek_sk` immediately.
|
||
/// 4. Both parties call this function with the same inputs to derive matching
|
||
/// call encryption keys.
|
||
///
|
||
/// ## Role Assignment
|
||
///
|
||
/// The party with the lexicographically lower identity fingerprint uses
|
||
/// `key_a` (first HKDF output half) as their send key and `key_b` (second
|
||
/// half) as their recv key. The other party reverses the assignment. Both
|
||
/// parties arrive at the correct assignment because `local_fp`/`remote_fp`
|
||
/// are swapped between them.
|
||
///
|
||
/// # Security
|
||
///
|
||
/// The ephemeral KEM exchange binds call security to a fresh shared secret
|
||
/// independent of the ratchet state. Even if `root_key` is later compromised
|
||
/// (before the next ratchet KEM step), the call remains confidential — the
|
||
/// ephemeral KEM secret is zeroized after key derivation.
|
||
///
|
||
/// `root_key` in the HKDF salt provides defense-in-depth: if the ephemeral
|
||
/// KEM is broken (e.g., by a future quantum computer), the root key's
|
||
/// accumulated post-quantum security still protects the call.
|
||
///
|
||
/// ## `call_id` Uniqueness
|
||
///
|
||
/// `call_id` (16 random bytes, generated by the call initiator) is
|
||
/// concatenated with `kem_ss` to form the HKDF IKM. Because a fresh
|
||
/// ephemeral KEM exchange already guarantees a unique `kem_ss` per call,
|
||
/// `call_id` is not the primary domain-separation mechanism — it provides
|
||
/// defense-in-depth against KEM randomness failure. The caller (application
|
||
/// layer) is responsible for generating `call_id` via a CSPRNG and must
|
||
/// never reuse a `call_id` for a different call under the same root key.
|
||
/// `call_id` is not bound into the HKDF info label because it is already
|
||
/// mixed into the IKM; binding it to both would be redundant.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `Err(InvalidData)` if:
|
||
/// - `root_key` is all-zeros (indicates uninitialized ratchet state)
|
||
/// - `kem_ss` is all-zeros (indicates KEM failure or uninitialized buffer)
|
||
/// - `call_id` is all-zeros (indicates uninitialized buffer)
|
||
/// - `local_fp == remote_fp` (equal fingerprints collapse send/recv role separation)
|
||
pub fn derive_call_keys(
|
||
root_key: &[u8; 32],
|
||
kem_ss: &[u8; 32],
|
||
call_id: &[u8; 16],
|
||
local_fp: &[u8; 32],
|
||
remote_fp: &[u8; 32],
|
||
) -> Result<CallKeys> {
|
||
// All-zeros root_key indicates an uninitialized or reset ratchet.
|
||
// Call keys derived from a zero root key provide no binding to the
|
||
// ratchet session — an attacker who knows kem_ss could derive them.
|
||
// Constant-time: root_key is secret material.
|
||
if bool::from(root_key.ct_eq(&[0u8; 32])) {
|
||
return Err(Error::InvalidData);
|
||
}
|
||
// All-zeros kem_ss indicates a KEM failure or uninitialized buffer.
|
||
// Ephemeral forward secrecy would be entirely lost — the call keys would
|
||
// depend only on root_key and call_id, both potentially attacker-known.
|
||
// Constant-time: kem_ss is secret material (X-Wing shared secret).
|
||
if bool::from(kem_ss.ct_eq(&[0u8; 32])) {
|
||
return Err(Error::InvalidData);
|
||
}
|
||
// All-zeros call_id indicates an uninitialized buffer from the caller.
|
||
// call_id is mixed into the IKM for domain separation — a zero value
|
||
// still produces unique keys (from kem_ss), but indicates a programming
|
||
// error rather than a legitimate random identifier.
|
||
if call_id == &[0u8; 16] {
|
||
return Err(Error::InvalidData);
|
||
}
|
||
// Equal fingerprints give both parties the same role (lower_role = false
|
||
// for both since `<` is strict), silently collapsing send/recv separation.
|
||
if local_fp == remote_fp {
|
||
return Err(Error::InvalidData);
|
||
}
|
||
|
||
// Concatenate kem_ss ‖ call_id as HKDF input keying material.
|
||
// Both components are fixed-size (&[u8; 32] and &[u8; 16]), so the
|
||
// concatenation is unambiguous — no length-prefix needed.
|
||
let mut ikm = [0u8; 48];
|
||
ikm[..32].copy_from_slice(kem_ss);
|
||
ikm[32..].copy_from_slice(call_id);
|
||
|
||
// HKDF info: label ‖ lower_fp ‖ higher_fp. Fingerprints are sorted into
|
||
// canonical (lower-first) order so both parties produce identical info
|
||
// regardless of which is local vs remote. This binds session identity
|
||
// into the key derivation — defense-in-depth against KEX-level collisions
|
||
// producing identical root_key for different identity pairs.
|
||
let (fp_lo, fp_hi) = if local_fp < remote_fp {
|
||
(local_fp, remote_fp)
|
||
} else {
|
||
(remote_fp, local_fp)
|
||
};
|
||
let mut info = [0u8; 10 + 32 + 32]; // CALL_HKDF_INFO (10) + 2 × fingerprint (32)
|
||
info[..10].copy_from_slice(constants::CALL_HKDF_INFO);
|
||
info[10..42].copy_from_slice(fp_lo);
|
||
info[42..74].copy_from_slice(fp_hi);
|
||
|
||
// HKDF output: 96 bytes → key_a (32) | key_b (32) | chain_key (32).
|
||
let mut output = [0u8; 96];
|
||
// Output size (96) is compile-time constant — InvalidLength structurally unreachable.
|
||
hkdf::hkdf_sha3_256(root_key, &ikm, &info, &mut output)?;
|
||
|
||
// IKM contains kem_ss (secret) — zeroize immediately after HKDF.
|
||
ikm.zeroize();
|
||
|
||
// Role assignment: lexicographically lower fingerprint gets key_a as
|
||
// send key. Both parties compute the same assignment because they swap
|
||
// local_fp and remote_fp. Variable-time comparison is acceptable —
|
||
// fingerprints are public identity material, not secret.
|
||
let lower_role = local_fp < remote_fp;
|
||
let (send_off, recv_off) = if lower_role { (0, 32) } else { (32, 0) };
|
||
|
||
let mut send_key = [0u8; 32];
|
||
let mut recv_key = [0u8; 32];
|
||
let mut chain_key = [0u8; 32];
|
||
send_key.copy_from_slice(&output[send_off..send_off + 32]);
|
||
recv_key.copy_from_slice(&output[recv_off..recv_off + 32]);
|
||
chain_key.copy_from_slice(&output[64..96]);
|
||
|
||
// HKDF output contains all derived key material — zeroize before
|
||
// leaving scope.
|
||
output.zeroize();
|
||
|
||
let keys = CallKeys {
|
||
send_key,
|
||
recv_key,
|
||
chain_key,
|
||
lower_role,
|
||
step_count: 0,
|
||
};
|
||
|
||
// [u8; 32] is Copy — struct initialization created bitwise copies;
|
||
// zeroize the stack originals before returning.
|
||
send_key.zeroize();
|
||
recv_key.zeroize();
|
||
chain_key.zeroize();
|
||
|
||
Ok(keys)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::primitives::{hkdf, hmac};
|
||
use std::collections::HashSet;
|
||
|
||
// Fixed test inputs.
|
||
const RK: [u8; 32] = [0x01u8; 32];
|
||
const SS: [u8; 32] = [0x02u8; 32];
|
||
const CALL_ID: [u8; 16] = [0x03u8; 16];
|
||
const FP_LO: [u8; 32] = [0x00u8; 32]; // lexicographically lower
|
||
const FP_HI: [u8; 32] = [0xFFu8; 32]; // lexicographically higher
|
||
|
||
/// Independently compute the HKDF output for known inputs.
|
||
///
|
||
/// Deliberately uses literal `b"lo-call-v1"` instead of `constants::CALL_HKDF_INFO`
|
||
/// so that a constant change is caught by `derive_hkdf_kat`.
|
||
fn reference_hkdf(rk: &[u8; 32], ss: &[u8; 32], call_id: &[u8; 16]) -> [u8; 96] {
|
||
let mut ikm = [0u8; 48];
|
||
ikm[..32].copy_from_slice(ss);
|
||
ikm[32..].copy_from_slice(call_id);
|
||
// Info: label ‖ lower_fp ‖ higher_fp (canonical order).
|
||
let mut info = [0u8; 10 + 32 + 32];
|
||
info[..10].copy_from_slice(b"lo-call-v1");
|
||
info[10..42].copy_from_slice(&FP_LO); // FP_LO < FP_HI
|
||
info[42..74].copy_from_slice(&FP_HI);
|
||
let mut output = [0u8; 96];
|
||
hkdf::hkdf_sha3_256(rk, &ikm, &info, &mut output).unwrap();
|
||
output
|
||
}
|
||
|
||
#[test]
|
||
fn derive_round_trip_matching() {
|
||
let alice = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
let bob = derive_call_keys(&RK, &SS, &CALL_ID, &FP_HI, &FP_LO).unwrap();
|
||
assert_eq!(alice.send_key(), bob.recv_key());
|
||
assert_eq!(alice.recv_key(), bob.send_key());
|
||
}
|
||
|
||
#[test]
|
||
fn derive_hkdf_kat() {
|
||
let output = reference_hkdf(&RK, &SS, &CALL_ID);
|
||
let key_a = &output[..32];
|
||
let key_b = &output[32..64];
|
||
// FP_LO < FP_HI → lower_role=true → send=key_a, recv=key_b
|
||
let keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
assert_eq!(keys.send_key().as_slice(), key_a);
|
||
assert_eq!(keys.recv_key().as_slice(), key_b);
|
||
}
|
||
|
||
#[test]
|
||
fn advance_hmac_kat() {
|
||
let output = reference_hkdf(&RK, &SS, &CALL_ID);
|
||
let chain_key = &output[64..96];
|
||
// First advance: chain_key → HMAC(ck, 0x04) / HMAC(ck, 0x05).
|
||
let expected_a = hmac::hmac_sha3_256(chain_key, constants::CALL_KEY_A_BYTE);
|
||
let expected_b = hmac::hmac_sha3_256(chain_key, constants::CALL_KEY_B_BYTE);
|
||
// Derive expected next chain key via advance byte, then verify the second advance.
|
||
let expected_chain2 = hmac::hmac_sha3_256(chain_key, constants::CALL_CHAIN_ADV_BYTE);
|
||
let expected_a2 = hmac::hmac_sha3_256(&expected_chain2, constants::CALL_KEY_A_BYTE);
|
||
let expected_b2 = hmac::hmac_sha3_256(&expected_chain2, constants::CALL_KEY_B_BYTE);
|
||
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
keys.advance().unwrap();
|
||
// lower_role=true → send=key_a, recv=key_b
|
||
assert_eq!(*keys.send_key(), expected_a);
|
||
assert_eq!(*keys.recv_key(), expected_b);
|
||
// Second advance confirms CALL_CHAIN_ADV_BYTE drives the chain: the new
|
||
// keys must match HMAC(expected_chain2, CALL_KEY_A/B_BYTE).
|
||
keys.advance().unwrap();
|
||
assert_eq!(*keys.send_key(), expected_a2);
|
||
assert_eq!(*keys.recv_key(), expected_b2);
|
||
}
|
||
|
||
#[test]
|
||
fn derive_different_call_ids() {
|
||
let k1 = derive_call_keys(&RK, &SS, &[0x01u8; 16], &FP_LO, &FP_HI).unwrap();
|
||
let k2 = derive_call_keys(&RK, &SS, &[0x02u8; 16], &FP_LO, &FP_HI).unwrap();
|
||
assert_ne!(k1.send_key(), k2.send_key());
|
||
}
|
||
|
||
#[test]
|
||
fn derive_different_kem_ss() {
|
||
let k1 = derive_call_keys(&RK, &[0xAAu8; 32], &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
let k2 = derive_call_keys(&RK, &[0xBBu8; 32], &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
assert_ne!(k1.send_key(), k2.send_key());
|
||
}
|
||
|
||
#[test]
|
||
fn derive_different_root_keys() {
|
||
let k1 = derive_call_keys(&[0xAAu8; 32], &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
let k2 = derive_call_keys(&[0xBBu8; 32], &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
assert_ne!(k1.send_key(), k2.send_key());
|
||
}
|
||
|
||
#[test]
|
||
fn role_assignment_lower_fp_is_send() {
|
||
let output = reference_hkdf(&RK, &SS, &CALL_ID);
|
||
let key_a = &output[..32];
|
||
let key_b = &output[32..64];
|
||
// Lower fp → send=key_a
|
||
let lower = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
assert_eq!(lower.send_key().as_slice(), key_a);
|
||
assert_eq!(lower.recv_key().as_slice(), key_b);
|
||
// Higher fp → send=key_b
|
||
let higher = derive_call_keys(&RK, &SS, &CALL_ID, &FP_HI, &FP_LO).unwrap();
|
||
assert_eq!(higher.send_key().as_slice(), key_b);
|
||
assert_eq!(higher.recv_key().as_slice(), key_a);
|
||
}
|
||
|
||
#[test]
|
||
fn advance_changes_keys() {
|
||
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
let send_before = *keys.send_key();
|
||
let recv_before = *keys.recv_key();
|
||
keys.advance().unwrap();
|
||
assert_ne!(*keys.send_key(), send_before);
|
||
assert_ne!(*keys.recv_key(), recv_before);
|
||
}
|
||
|
||
#[test]
|
||
fn advance_preserves_role() {
|
||
let mut alice = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
let mut bob = derive_call_keys(&RK, &SS, &CALL_ID, &FP_HI, &FP_LO).unwrap();
|
||
alice.advance().unwrap();
|
||
bob.advance().unwrap();
|
||
assert_eq!(alice.send_key(), bob.recv_key());
|
||
assert_eq!(alice.recv_key(), bob.send_key());
|
||
}
|
||
|
||
#[test]
|
||
fn advance_produces_distinct_keys() {
|
||
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
keys.advance().unwrap();
|
||
let after_first = *keys.send_key();
|
||
keys.advance().unwrap();
|
||
let after_second = *keys.send_key();
|
||
assert_ne!(after_first, after_second);
|
||
}
|
||
|
||
#[test]
|
||
fn advance_keys_independent_of_chain_key() {
|
||
// Intra-call forward secrecy invariant: knowing the exposed message keys
|
||
// (send_key, recv_key) must not reveal the chain key that generates all
|
||
// future keys. Verified by asserting send_key != chain_key and
|
||
// recv_key != chain_key after each advance step.
|
||
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
for _ in 0..10 {
|
||
keys.advance().unwrap();
|
||
#[allow(deprecated)]
|
||
let ck = *keys.chain_key_bytes();
|
||
assert_ne!(*keys.send_key(), ck, "send_key == chain_key after advance");
|
||
assert_ne!(*keys.recv_key(), ck, "recv_key == chain_key after advance");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn advance_multiple_steps() {
|
||
// Inserts send_key before each advance: collects the initial key plus
|
||
// keys after advances 1-99 (100 values across 99 steps), all unique.
|
||
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
let mut seen = HashSet::new();
|
||
for _ in 0..100 {
|
||
seen.insert(*keys.send_key());
|
||
keys.advance().unwrap();
|
||
}
|
||
assert_eq!(seen.len(), 100);
|
||
}
|
||
|
||
#[test]
|
||
fn send_recv_keys_differ() {
|
||
let keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
assert_ne!(keys.send_key(), keys.recv_key());
|
||
}
|
||
|
||
#[test]
|
||
fn keys_are_32_bytes() {
|
||
let keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
assert!(keys.send_key().iter().any(|&b| b != 0));
|
||
assert!(keys.recv_key().iter().any(|&b| b != 0));
|
||
}
|
||
|
||
#[test]
|
||
fn zero_kem_ss_rejected() {
|
||
assert!(matches!(
|
||
derive_call_keys(&RK, &[0u8; 32], &CALL_ID, &FP_LO, &FP_HI),
|
||
Err(crate::error::Error::InvalidData)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn zero_root_key_rejected() {
|
||
assert!(matches!(
|
||
derive_call_keys(&[0u8; 32], &SS, &CALL_ID, &FP_LO, &FP_HI),
|
||
Err(crate::error::Error::InvalidData)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn zero_call_id_rejected() {
|
||
assert!(matches!(
|
||
derive_call_keys(&RK, &SS, &[0u8; 16], &FP_LO, &FP_HI),
|
||
Err(crate::error::Error::InvalidData)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn advance_exhaustion_returns_chain_exhausted() {
|
||
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
// Simulate being at the limit by advancing MAX_CALL_ADVANCE - 1 times
|
||
// would be too slow. Instead, directly set step_count near the limit.
|
||
keys.step_count = MAX_CALL_ADVANCE - 1;
|
||
// One more advance should succeed (step_count == MAX_CALL_ADVANCE - 1 < MAX_CALL_ADVANCE).
|
||
assert!(keys.advance().is_ok());
|
||
// Now step_count == MAX_CALL_ADVANCE, next advance must fail.
|
||
assert!(matches!(keys.advance(), Err(Error::ChainExhausted)));
|
||
}
|
||
|
||
#[test]
|
||
fn advance_exhaustion_zeroizes_keys() {
|
||
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
|
||
keys.step_count = MAX_CALL_ADVANCE - 1;
|
||
keys.advance().unwrap();
|
||
// Exhaust the chain.
|
||
assert!(matches!(keys.advance(), Err(Error::ChainExhausted)));
|
||
// All keys must be zeroed after exhaustion.
|
||
assert_eq!(
|
||
*keys.send_key(),
|
||
[0u8; 32],
|
||
"send_key not zeroed after ChainExhausted"
|
||
);
|
||
assert_eq!(
|
||
*keys.recv_key(),
|
||
[0u8; 32],
|
||
"recv_key not zeroed after ChainExhausted"
|
||
);
|
||
#[allow(deprecated)]
|
||
{
|
||
assert_eq!(
|
||
*keys.chain_key_bytes(),
|
||
[0u8; 32],
|
||
"chain_key not zeroed after ChainExhausted"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn equal_fingerprints_rejected() {
|
||
assert!(matches!(
|
||
derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_LO),
|
||
Err(crate::error::Error::InvalidData)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn kdf_call_spec_kat() {
|
||
// Specification.md Appendix F.15 — KDF_Call reference vector.
|
||
use hex_literal::hex;
|
||
|
||
let root_key: [u8; 32] = [0xAAu8; 32];
|
||
let kem_ss: [u8; 32] = [0xBBu8; 32];
|
||
let call_id: [u8; 16] = [0xCCu8; 16];
|
||
let local_fp: [u8; 32] = [0x11u8; 32]; // < 0x22 → lower_role
|
||
let remote_fp: [u8; 32] = [0x22u8; 32];
|
||
|
||
let expected_key_a =
|
||
hex!("ed75d812373c9b3bf6bddd394a631950520503f103b492fb908621eb712b5970");
|
||
let expected_key_b =
|
||
hex!("c3e5171534e0d1f922ea4ebf318357b990eafb0fff45d8cf430639a1fe2bb1e4");
|
||
let expected_chain_key =
|
||
hex!("1427dde311aaa195b116cc98c870753179297981446d3b53e00a4a92a0d34aeb");
|
||
|
||
let keys = derive_call_keys(&root_key, &kem_ss, &call_id, &local_fp, &remote_fp).unwrap();
|
||
// local_fp < remote_fp → lower_role=true → send=key_a, recv=key_b.
|
||
assert_eq!(
|
||
keys.send_key().as_slice(),
|
||
&expected_key_a,
|
||
"send_key (key_a) does not match F.15 vector"
|
||
);
|
||
assert_eq!(
|
||
keys.recv_key().as_slice(),
|
||
&expected_key_b,
|
||
"recv_key (key_b) does not match F.15 vector"
|
||
);
|
||
#[allow(deprecated)]
|
||
{
|
||
assert_eq!(
|
||
keys.chain_key_bytes().as_slice(),
|
||
&expected_chain_key,
|
||
"chain_key does not match F.15 vector"
|
||
);
|
||
}
|
||
|
||
// Reversed-order coverage: swap fingerprints → same HKDF output,
|
||
// reversed role assignment.
|
||
let keys_rev =
|
||
derive_call_keys(&root_key, &kem_ss, &call_id, &remote_fp, &local_fp).unwrap();
|
||
assert_eq!(
|
||
keys_rev.send_key().as_slice(),
|
||
&expected_key_b,
|
||
"reversed: send_key should be key_b"
|
||
);
|
||
assert_eq!(
|
||
keys_rev.recv_key().as_slice(),
|
||
&expected_key_a,
|
||
"reversed: recv_key should be key_a"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn advance_call_chain_spec_kat() {
|
||
// Specification.md Appendix F.16 — AdvanceCallChain reference vector.
|
||
// Uses chain_key from F.15 as starting point.
|
||
use hex_literal::hex;
|
||
|
||
let root_key: [u8; 32] = [0xAAu8; 32];
|
||
let kem_ss: [u8; 32] = [0xBBu8; 32];
|
||
let call_id: [u8; 16] = [0xCCu8; 16];
|
||
let local_fp: [u8; 32] = [0x11u8; 32];
|
||
let remote_fp: [u8; 32] = [0x22u8; 32];
|
||
|
||
let expected_key_a_prime =
|
||
hex!("9cf3129c6bb7ad86cb12ffc534517a4c06a472fbcddbe295a501c79aa49800e1");
|
||
let expected_key_b_prime =
|
||
hex!("f24cd7822fd611159a6e6d809c6ac148fd7b9bad65d8b4f85745869634b2dd1e");
|
||
let expected_chain_key_prime =
|
||
hex!("d3ae610c39cd9f7f8dce990b5c91634092ad0621fc01b44b24b2cb9f3638d0f2");
|
||
|
||
let mut keys =
|
||
derive_call_keys(&root_key, &kem_ss, &call_id, &local_fp, &remote_fp).unwrap();
|
||
keys.advance().unwrap();
|
||
|
||
// lower_role=true → send=key_a', recv=key_b'.
|
||
assert_eq!(
|
||
keys.send_key().as_slice(),
|
||
&expected_key_a_prime,
|
||
"send_key (key_a') does not match F.16 vector"
|
||
);
|
||
assert_eq!(
|
||
keys.recv_key().as_slice(),
|
||
&expected_key_b_prime,
|
||
"recv_key (key_b') does not match F.16 vector"
|
||
);
|
||
#[allow(deprecated)]
|
||
{
|
||
assert_eq!(
|
||
keys.chain_key_bytes().as_slice(),
|
||
&expected_chain_key_prime,
|
||
"chain_key' does not match F.16 vector"
|
||
);
|
||
}
|
||
}
|
||
|
||
proptest::proptest! {
|
||
#[test]
|
||
fn proptest_derive_round_trip(
|
||
rk in proptest::array::uniform32(proptest::prelude::any::<u8>()),
|
||
ss in proptest::array::uniform32(proptest::prelude::any::<u8>()),
|
||
call_id in proptest::array::uniform16(proptest::prelude::any::<u8>()),
|
||
fp_a in proptest::array::uniform32(proptest::prelude::any::<u8>()),
|
||
fp_b in proptest::array::uniform32(proptest::prelude::any::<u8>()),
|
||
) {
|
||
// Filter out inputs rejected by derive_call_keys validation.
|
||
proptest::prop_assume!(fp_a != fp_b);
|
||
proptest::prop_assume!(rk != [0u8; 32]);
|
||
proptest::prop_assume!(ss != [0u8; 32]);
|
||
proptest::prop_assume!(call_id != [0u8; 16]);
|
||
let alice = derive_call_keys(&rk, &ss, &call_id, &fp_a, &fp_b).unwrap();
|
||
let bob = derive_call_keys(&rk, &ss, &call_id, &fp_b, &fp_a).unwrap();
|
||
proptest::prop_assert_eq!(alice.send_key(), bob.recv_key());
|
||
proptest::prop_assert_eq!(alice.recv_key(), bob.send_key());
|
||
}
|
||
}
|
||
}
|