keys.rs
⎇
Raw
1//! Wrapping of token secrets at rest.
2//!
3//! Every `token_key` is stored encrypted with a server key, with the `token_id`
4//! as additional data. A stolen `.db` therefore yields no working keys, and a
5//! wrapped blob cannot be moved from one token row to another.
6//!
7//! The server refuses to start without a key, and accepts an old one for a
8//! one-shot rotation.
9
10use anyhow::{Context, Result, bail};
11use chacha20poly1305::aead::{Aead, KeyInit, Payload};
12use chacha20poly1305::{ChaCha20Poly1305, Nonce};
13use hkdf::Hkdf;
14use rand::TryRngCore;
15use rand::rngs::OsRng;
16use sha2::Sha256;
17
18const KEY_LEN: usize = 32;
19const NONCE_LEN: usize = 12;
20
21/// HKDF label separating the revocation master from the wrapping key. They come
22/// from the same secret and must never be the same value.
23const REVOCATION_MASTER_INFO: &[u8] = b"otp/1/revoke-master";
24
25/// The server's key-wrapping key(s).
26pub struct KeyVault {
27 current: ChaCha20Poly1305,
28 /// Accepted for unwrapping only, so a rotation can re-wrap lazily.
29 previous: Option<ChaCha20Poly1305>,
30 /// Master for per-token revocation keys. Derived from the same secret rather
31 /// than configured separately: one required environment variable is enough,
32 /// and an operator who has to manage two will eventually lose one.
33 ///
34 /// The consequence is that rotating `OT_SECRET_KEY` also invalidates every
35 /// `K_rev` already issued. Devices that logged in beforehand stop being able
36 /// to verify a revocation notice and fall back to silence until their next
37 /// login — the behaviour they would have had anyway without this mechanism.
38 revocation_master: otproto::Key,
39}
40
41impl KeyVault {
42 /// Reads `OT_SECRET_KEY` (base64, 32 bytes) or the file it names, plus the
43 /// optional `OT_SECRET_KEY_OLD`.
44 pub fn from_env() -> Result<Self> {
45 let current = load("OT_SECRET_KEY")?.ok_or_else(|| {
46 anyhow::anyhow!(
47 "OT_SECRET_KEY is not set. It must be 32 random bytes, base64-encoded, or the path \
48 to a 0600 file containing them. Generate one with:\n \
49 head -c32 /dev/urandom | base64\n\
50 Without it, token keys would sit in the database in the clear."
51 )
52 })?;
53 let previous = load("OT_SECRET_KEY_OLD")?;
54 Ok(Self {
55 revocation_master: derive_revocation_master(&current),
56 current: ChaCha20Poly1305::new((&current).into()),
57 previous: previous.map(|k| ChaCha20Poly1305::new((&k).into())),
58 })
59 }
60
61 #[cfg(test)]
62 pub fn for_test(key: [u8; KEY_LEN]) -> Self {
63 Self {
64 revocation_master: derive_revocation_master(&key),
65 current: ChaCha20Poly1305::new((&key).into()),
66 previous: None,
67 }
68 }
69
70 /// The master from which every `K_rev` is derived.
71 ///
72 /// Handed to [`crate::ingest::Ingest`] so the hot path can seal a notice for
73 /// a `token_id` it has never seen, without a database round trip and without
74 /// storing anything per token.
75 pub fn revocation_master(&self) -> otproto::Key {
76 self.revocation_master
77 }
78
79 /// The key sealing revocation notices for one token. Also what the login
80 /// response hands the device.
81 pub fn revocation_key(&self, token_id: u64) -> otproto::Key {
82 otproto::revocation_key(&self.revocation_master, token_id)
83 }
84
85 /// `nonce || ciphertext || tag`, with the token id as AAD.
86 pub fn wrap(&self, token_id: u64, token_key: &[u8; KEY_LEN]) -> Result<Vec<u8>> {
87 let mut nonce = [0u8; NONCE_LEN];
88 OsRng.try_fill_bytes(&mut nonce).context("OS RNG failed")?;
89 let sealed = self
90 .current
91 .encrypt(
92 Nonce::from_slice(&nonce),
93 Payload {
94 msg: token_key,
95 aad: &token_id.to_be_bytes(),
96 },
97 )
98 .map_err(|_| anyhow::anyhow!("wrapping token key failed"))?;
99 let mut out = Vec::with_capacity(NONCE_LEN + sealed.len());
100 out.extend_from_slice(&nonce);
101 out.extend_from_slice(&sealed);
102 Ok(out)
103 }
104
105 pub fn unwrap(&self, token_id: u64, blob: &[u8]) -> Result<[u8; KEY_LEN]> {
106 if blob.len() < NONCE_LEN + 16 {
107 bail!(
108 "wrapped key for token {token_id} is truncated ({} bytes)",
109 blob.len()
110 );
111 }
112 let (nonce, sealed) = blob.split_at(NONCE_LEN);
113 let aad = token_id.to_be_bytes();
114
115 for cipher in [Some(&self.current), self.previous.as_ref()]
116 .into_iter()
117 .flatten()
118 {
119 if let Ok(plain) = cipher.decrypt(
120 Nonce::from_slice(nonce),
121 Payload {
122 msg: sealed,
123 aad: &aad,
124 },
125 ) {
126 return plain.try_into().map_err(|v: Vec<u8>| {
127 anyhow::anyhow!("token key is {} bytes, want {KEY_LEN}", v.len())
128 });
129 }
130 }
131 bail!(
132 "cannot unwrap the key for token {token_id}: neither OT_SECRET_KEY nor \
133 OT_SECRET_KEY_OLD decrypts it"
134 )
135 }
136}
137
138fn load(var: &str) -> Result<Option<[u8; KEY_LEN]>> {
139 let Ok(raw) = std::env::var(var) else {
140 return Ok(None);
141 };
142 if raw.is_empty() {
143 return Ok(None);
144 }
145
146 // A path is more likely than base64 to contain a '/', so decide on whether
147 // the value names an existing file rather than on its shape.
148 let text = if std::path::Path::new(&raw).is_file() {
149 std::fs::read_to_string(&raw).with_context(|| format!("{var}: reading {raw}"))?
150 } else {
151 raw
152 };
153
154 use base64::Engine as _;
155 let bytes = base64::engine::general_purpose::STANDARD
156 .decode(text.trim())
157 .with_context(|| format!("{var} is not valid base64"))?;
158 if bytes.len() != KEY_LEN {
159 bail!("{var} decodes to {} bytes, want {KEY_LEN}", bytes.len());
160 }
161 Ok(Some(bytes.try_into().expect("length checked")))
162}
163
164/// 32 fresh random bytes for a new token secret.
165pub fn random_token_key() -> Result<[u8; KEY_LEN]> {
166 let mut k = [0u8; KEY_LEN];
167 OsRng.try_fill_bytes(&mut k).context("OS RNG failed")?;
168 Ok(k)
169}
170
171/// A random, non-zero `token_id`.
172///
173/// Random rather than sequential because the id travels in cleartext in every
174/// datagram header: a guessable one would let an attacker enumerate which tokens
175/// exist by watching for the absence of a reply.
176pub fn random_token_id() -> Result<u64> {
177 loop {
178 let mut b = [0u8; 8];
179 OsRng.try_fill_bytes(&mut b).context("OS RNG failed")?;
180 let id = u64::from_be_bytes(b);
181 // 0 is reserved as "unset" in a few places; rejecting it costs nothing.
182 if id != 0 {
183 return Ok(id);
184 }
185 }
186}
187
188fn derive_revocation_master(secret: &[u8; KEY_LEN]) -> otproto::Key {
189 let hk = Hkdf::<Sha256>::from_prk(secret).expect("32-byte PRK is valid for HKDF-SHA256");
190 let mut out = [0u8; KEY_LEN];
191 hk.expand(REVOCATION_MASTER_INFO, &mut out)
192 .expect("32 bytes is well under HKDF-SHA256's output limit");
193 out
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn wrap_then_unwrap_round_trips() {
202 let vault = KeyVault::for_test([7; KEY_LEN]);
203 let key = [0x42; KEY_LEN];
204 let blob = vault.wrap(99, &key).expect("wrap");
205 assert_eq!(vault.unwrap(99, &blob).expect("unwrap"), key);
206 }
207
208 #[test]
209 fn a_blob_cannot_be_moved_to_another_token() {
210 // The token id is AAD, so a row-swap in the database is detected rather
211 // than silently cloning a credential onto another token.
212 let vault = KeyVault::for_test([7; KEY_LEN]);
213 let blob = vault.wrap(99, &[0x42; KEY_LEN]).expect("wrap");
214 assert!(vault.unwrap(100, &blob).is_err());
215 }
216
217 #[test]
218 fn a_tampered_blob_is_rejected() {
219 let vault = KeyVault::for_test([7; KEY_LEN]);
220 let mut blob = vault.wrap(1, &[1; KEY_LEN]).expect("wrap");
221 let last = blob.len() - 1;
222 blob[last] ^= 1;
223 assert!(vault.unwrap(1, &blob).is_err());
224 }
225
226 #[test]
227 fn a_wrong_server_key_cannot_unwrap() {
228 let blob = KeyVault::for_test([7; KEY_LEN])
229 .wrap(1, &[1; KEY_LEN])
230 .expect("wrap");
231 assert!(KeyVault::for_test([8; KEY_LEN]).unwrap(1, &blob).is_err());
232 }
233
234 #[test]
235 fn truncated_blobs_fail_with_a_clear_error() {
236 let vault = KeyVault::for_test([7; KEY_LEN]);
237 assert!(vault.unwrap(1, &[]).is_err());
238 assert!(vault.unwrap(1, &[0; NONCE_LEN]).is_err());
239 }
240
241 #[test]
242 fn wrapping_is_randomised() {
243 // Same key, same token, different ciphertext: the nonce is fresh each
244 // time, so the database never reveals that two tokens share a secret.
245 let vault = KeyVault::for_test([7; KEY_LEN]);
246 let a = vault.wrap(1, &[1; KEY_LEN]).expect("wrap");
247 let b = vault.wrap(1, &[1; KEY_LEN]).expect("wrap");
248 assert_ne!(a, b);
249 }
250
251 #[test]
252 fn token_ids_are_never_zero() {
253 for _ in 0..100 {
254 assert_ne!(random_token_id().expect("id"), 0);
255 }
256 }
257}
258