initial commit
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>
This commit is contained in:
Kamal Tufekcic 2026-04-02 23:48:10 +03:00
commit 1d99048c95
No known key found for this signature in database
165830 changed files with 79062 additions and 0 deletions

691
soliton/src/call.rs Normal file
View file

@ -0,0 +1,691 @@
//! 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());
}
}
}