kdf.rs
⎇
Raw
1//! Key derivation from the token secret issued at login.
2//!
3//! ```text
4//! K_up = HKDF-Expand(token_key, "otp/1/up", 32) device -> server
5//! K_down = HKDF-Expand(token_key, "otp/1/down", 32) server -> device
6//! K_rev = HKDF-Expand(master, "otp/1/revoke" || token_id, 32) server -> device
7//! ```
8//!
9//! Expand only, no extract: `token_key` is already 32 uniformly random bytes
10//! from the server's CSPRNG, so there is no entropy to condition. Two
11//! directions means two keys, so a captured uplink datagram can never be
12//! replayed back as a downlink one — which is also why the nonce space of the
13//! two directions may overlap freely.
14
15use hkdf::Hkdf;
16use sha2::Sha256;
17
18use crate::msg::Direction;
19
20pub const KEY_LEN: usize = 32;
21
22/// A 32-byte symmetric key: either the token secret or one of its two
23/// derivatives.
24pub type Key = [u8; KEY_LEN];
25
26/// Derive the directional key for `dir`.
27#[must_use]
28pub fn derive(token_key: &Key, dir: Direction) -> Key {
29 let hk = Hkdf::<Sha256>::from_prk(token_key).expect("32-byte PRK is valid for HKDF-SHA256");
30 let mut out = [0u8; KEY_LEN];
31 hk.expand(dir.info(), &mut out)
32 .expect("32 bytes is well under HKDF-SHA256's output limit");
33 out
34}
35
36/// The key that seals a [`crate::Revoked`] notice for `token_id`.
37///
38/// Derived from a server master key and the id, *not* from the token key. That
39/// is the point: `K_up` and `K_down` both die with the token's row, and the
40/// moment the server most needs to speak is exactly when that row is gone. This
41/// key the server can recompute for any id, including one it has never issued.
42///
43/// Per-id rather than one shared server key, so a device that learns its own
44/// `K_rev` still cannot forge a notice for anyone else.
45///
46/// The master is a deployment secret, so rotating it invalidates every `K_rev`
47/// already handed out. Devices that logged in beforehand then fall back to
48/// silence, which is the pre-existing behaviour, not a new failure.
49#[must_use]
50pub fn revocation_key(master: &Key, token_id: u64) -> Key {
51 let hk = Hkdf::<Sha256>::from_prk(master).expect("32-byte PRK is valid for HKDF-SHA256");
52 let mut info = [0u8; 12 + 8];
53 info[..12].copy_from_slice(b"otp/1/revoke");
54 info[12..].copy_from_slice(&token_id.to_be_bytes());
55 let mut out = [0u8; KEY_LEN];
56 hk.expand(&info, &mut out)
57 .expect("32 bytes is well under HKDF-SHA256's output limit");
58 out
59}
60
61/// Both directional keys at once, in the order the server caches them.
62#[must_use]
63pub fn derive_both(token_key: &Key) -> (Key, Key) {
64 (
65 derive(token_key, Direction::Up),
66 derive(token_key, Direction::Down),
67 )
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 #[test]
75 fn directions_are_independent() {
76 let (up, down) = derive_both(&[0x42; KEY_LEN]);
77 assert_ne!(up, down);
78 assert_ne!(up, [0x42; KEY_LEN]);
79 }
80
81 #[test]
82 fn derivation_is_deterministic() {
83 assert_eq!(
84 derive(&[1; KEY_LEN], Direction::Up),
85 derive(&[1; KEY_LEN], Direction::Up)
86 );
87 }
88
89 #[test]
90 fn revocation_keys_differ_per_token_id() {
91 let master = [0x11; KEY_LEN];
92 let a = revocation_key(&master, 1);
93 let b = revocation_key(&master, 2);
94 assert_ne!(a, b, "one device could forge a notice for another");
95 assert_eq!(a, revocation_key(&master, 1), "must be recomputable");
96 }
97
98 /// The whole point of the separate master: `K_rev` must not be derivable
99 /// from anything that dies with the token row.
100 #[test]
101 fn a_revocation_key_is_independent_of_the_token_key() {
102 let key = [0x42; KEY_LEN];
103 let (up, down) = derive_both(&key);
104 let rev = revocation_key(&key, 7);
105 assert_ne!(rev, up);
106 assert_ne!(rev, down);
107 assert_ne!(rev, key);
108 }
109
110 #[test]
111 fn a_one_bit_token_change_changes_the_whole_key() {
112 let a = derive(&[0; KEY_LEN], Direction::Up);
113 let mut tk = [0u8; KEY_LEN];
114 tk[31] = 1;
115 let b = derive(&tk, Direction::Up);
116 assert_ne!(a, b);
117 let differing = a.iter().zip(&b).filter(|(x, y)| x != y).count();
118 assert!(
119 differing > KEY_LEN / 2,
120 "expected avalanche, only {differing} bytes differ"
121 );
122 }
123}
124