//! Key derivation from the token secret issued at login. //! //! ```text //! K_up = HKDF-Expand(token_key, "otp/1/up", 32) device -> server //! K_down = HKDF-Expand(token_key, "otp/1/down", 32) server -> device //! K_rev = HKDF-Expand(master, "otp/1/revoke" || token_id, 32) server -> device //! ``` //! //! Expand only, no extract: `token_key` is already 32 uniformly random bytes //! from the server's CSPRNG, so there is no entropy to condition. Two //! directions means two keys, so a captured uplink datagram can never be //! replayed back as a downlink one — which is also why the nonce space of the //! two directions may overlap freely. use hkdf::Hkdf; use sha2::Sha256; use crate::msg::Direction; pub const KEY_LEN: usize = 32; /// A 32-byte symmetric key: either the token secret or one of its two /// derivatives. pub type Key = [u8; KEY_LEN]; /// Derive the directional key for `dir`. #[must_use] pub fn derive(token_key: &Key, dir: Direction) -> Key { let hk = Hkdf::::from_prk(token_key).expect("32-byte PRK is valid for HKDF-SHA256"); let mut out = [0u8; KEY_LEN]; hk.expand(dir.info(), &mut out) .expect("32 bytes is well under HKDF-SHA256's output limit"); out } /// The key that seals a [`crate::Revoked`] notice for `token_id`. /// /// Derived from a server master key and the id, *not* from the token key. That /// is the point: `K_up` and `K_down` both die with the token's row, and the /// moment the server most needs to speak is exactly when that row is gone. This /// key the server can recompute for any id, including one it has never issued. /// /// Per-id rather than one shared server key, so a device that learns its own /// `K_rev` still cannot forge a notice for anyone else. /// /// The master is a deployment secret, so rotating it invalidates every `K_rev` /// already handed out. Devices that logged in beforehand then fall back to /// silence, which is the pre-existing behaviour, not a new failure. #[must_use] pub fn revocation_key(master: &Key, token_id: u64) -> Key { let hk = Hkdf::::from_prk(master).expect("32-byte PRK is valid for HKDF-SHA256"); let mut info = [0u8; 12 + 8]; info[..12].copy_from_slice(b"otp/1/revoke"); info[12..].copy_from_slice(&token_id.to_be_bytes()); let mut out = [0u8; KEY_LEN]; hk.expand(&info, &mut out) .expect("32 bytes is well under HKDF-SHA256's output limit"); out } /// Both directional keys at once, in the order the server caches them. #[must_use] pub fn derive_both(token_key: &Key) -> (Key, Key) { ( derive(token_key, Direction::Up), derive(token_key, Direction::Down), ) } #[cfg(test)] mod tests { use super::*; #[test] fn directions_are_independent() { let (up, down) = derive_both(&[0x42; KEY_LEN]); assert_ne!(up, down); assert_ne!(up, [0x42; KEY_LEN]); } #[test] fn derivation_is_deterministic() { assert_eq!( derive(&[1; KEY_LEN], Direction::Up), derive(&[1; KEY_LEN], Direction::Up) ); } #[test] fn revocation_keys_differ_per_token_id() { let master = [0x11; KEY_LEN]; let a = revocation_key(&master, 1); let b = revocation_key(&master, 2); assert_ne!(a, b, "one device could forge a notice for another"); assert_eq!(a, revocation_key(&master, 1), "must be recomputable"); } /// The whole point of the separate master: `K_rev` must not be derivable /// from anything that dies with the token row. #[test] fn a_revocation_key_is_independent_of_the_token_key() { let key = [0x42; KEY_LEN]; let (up, down) = derive_both(&key); let rev = revocation_key(&key, 7); assert_ne!(rev, up); assert_ne!(rev, down); assert_ne!(rev, key); } #[test] fn a_one_bit_token_change_changes_the_whole_key() { let a = derive(&[0; KEY_LEN], Direction::Up); let mut tk = [0u8; KEY_LEN]; tk[31] = 1; let b = derive(&tk, Direction::Up); assert_ne!(a, b); let differing = a.iter().zip(&b).filter(|(x, y)| x != y).count(); assert!( differing > KEY_LEN / 2, "expected avalanche, only {differing} bytes differ" ); } }