Skip to main content

rainier_crypt/
key.rs

1//! Keys and the [`KeyRing`] that holds them.
2//!
3//! A single key is a deployment that can never rotate. Everything here is
4//! built around the assumption that keys *will* change: the ring has one
5//! current key for encrypting and any number of retired keys for decrypting,
6//! and every payload records which key produced it.
7
8use base64::engine::general_purpose::STANDARD as BASE64;
9use base64::Engine as _;
10use rainier_support::{Error, Result};
11use sha2::{Digest, Sha256};
12
13/// The length of every key, in bytes. 256 bits, for both XChaCha20-Poly1305
14/// and HMAC-SHA256.
15pub const KEY_LEN: usize = 32;
16
17/// One symmetric key, plus the short identifier that travels with anything it
18/// produced.
19#[derive(Clone)]
20pub struct Key {
21    id: String,
22    bytes: [u8; KEY_LEN],
23}
24
25impl Key {
26    /// A key from exactly [`KEY_LEN`] bytes.
27    pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
28        Self { id: fingerprint(&bytes), bytes }
29    }
30
31    /// A key from a base64 string, with or without a `base64:` prefix.
32    ///
33    /// Accepting the prefix means a key generated for a PHP application
34    /// can be pasted straight in, which matters when the two are being run
35    /// side by side during a migration.
36    pub fn from_base64(encoded: &str) -> Result<Self> {
37        let trimmed = encoded.trim();
38        let payload = trimmed.strip_prefix("base64:").unwrap_or(trimmed);
39
40        let decoded = BASE64
41            .decode(payload)
42            .map_err(|_| Error::internal("the application key is not valid base64"))?;
43
44        let bytes: [u8; KEY_LEN] = decoded.as_slice().try_into().map_err(|_| {
45            Error::internal(format!(
46                "the application key must be {KEY_LEN} bytes, but decoded to {}",
47                decoded.len()
48            ))
49        })?;
50
51        Ok(Self::from_bytes(bytes))
52    }
53
54    /// A fresh key from the OS CSPRNG. What `key:generate` calls.
55    pub fn generate() -> Self {
56        use rand::RngCore;
57
58        let mut bytes = [0u8; KEY_LEN];
59        rand::rngs::OsRng.fill_bytes(&mut bytes);
60        Self::from_bytes(bytes)
61    }
62
63    /// The key's short identifier, as it appears in a payload.
64    ///
65    /// Derived from the key rather than configured alongside it, so it cannot
66    /// be set inconsistently across deployments and cannot be forgotten when a
67    /// key is added.
68    pub fn id(&self) -> &str {
69        &self.id
70    }
71
72    /// The raw key material.
73    pub fn bytes(&self) -> &[u8; KEY_LEN] {
74        &self.bytes
75    }
76
77    /// The key as `base64:…`, ready to paste into a `.env`.
78    pub fn to_base64(&self) -> String {
79        format!("base64:{}", BASE64.encode(self.bytes))
80    }
81}
82
83/// Deliberately opaque: a key that lands in a log line or a panic message is a
84/// key that has to be rotated.
85impl std::fmt::Debug for Key {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("Key").field("id", &self.id).field("bytes", &"<redacted>").finish()
88    }
89}
90
91/// A hash of the key, truncated. Eight hex characters is enough to tell a
92/// handful of keys apart and far too little to attack the key with.
93fn fingerprint(bytes: &[u8; KEY_LEN]) -> String {
94    let mut hasher = Sha256::new();
95    hasher.update(b"rainier-key-id\0");
96    hasher.update(bytes);
97    let digest = hasher.finalize();
98    digest.iter().take(4).map(|byte| format!("{byte:02x}")).collect()
99}
100
101/// The current key, plus any retired ones still needed to read old payloads.
102///
103/// Rotation is the whole point. Adding a key to the front of the ring means
104/// everything written from then on uses it, while everything already written
105/// still decrypts — so a rotation is a deploy, not a migration.
106#[derive(Clone, Debug)]
107pub struct KeyRing {
108    keys: Vec<Key>,
109}
110
111impl KeyRing {
112    /// A ring with one key.
113    pub fn new(current: Key) -> Self {
114        Self { keys: vec![current] }
115    }
116
117    /// Add a retired key, usable for decryption only.
118    ///
119    /// Order among retired keys does not matter; lookup is by id.
120    pub fn with_previous(mut self, key: Key) -> Self {
121        self.keys.push(key);
122        self
123    }
124
125    /// Build from an application key and any number of retired ones.
126    pub fn from_base64(current: &str, previous: &[String]) -> Result<Self> {
127        let mut ring = Self::new(Key::from_base64(current)?);
128        for encoded in previous {
129            if encoded.trim().is_empty() {
130                continue;
131            }
132            ring = ring.with_previous(Key::from_base64(encoded)?);
133        }
134        Ok(ring)
135    }
136
137    /// The key new payloads are written with.
138    pub fn current(&self) -> &Key {
139        // The constructor requires one, and nothing can empty the ring.
140        &self.keys[0]
141    }
142
143    /// The key with this id, if the ring still holds it.
144    pub fn find(&self, id: &str) -> Option<&Key> {
145        self.keys.iter().find(|key| key.id() == id)
146    }
147
148    /// How many keys the ring holds, current included.
149    pub fn len(&self) -> usize {
150        self.keys.len()
151    }
152
153    /// Always `false` — a ring is constructed with a current key.
154    pub fn is_empty(&self) -> bool {
155        false
156    }
157
158    /// Every key, current first.
159    ///
160    /// For a payload format that carries no key id and can only be decrypted
161    /// by trying — the PHP envelope, for one. Rainier's own format names its
162    /// key, so it uses [`find`](Self::find) instead.
163    pub fn all(&self) -> impl Iterator<Item = &Key> {
164        self.keys.iter()
165    }
166
167    /// Every key id, current first. For a diagnostic command.
168    pub fn ids(&self) -> Vec<&str> {
169        self.keys.iter().map(Key::id).collect()
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn a_generated_key_round_trips_through_base64() {
179        let key = Key::generate();
180        let restored = Key::from_base64(&key.to_base64()).unwrap();
181
182        assert_eq!(restored.bytes(), key.bytes());
183        assert_eq!(restored.id(), key.id());
184    }
185
186    #[test]
187    fn the_base64_prefix_is_accepted_and_optional() {
188        let key = Key::generate();
189        let raw = BASE64.encode(key.bytes());
190
191        assert_eq!(Key::from_base64(&raw).unwrap().bytes(), key.bytes());
192        assert_eq!(Key::from_base64(&format!("base64:{raw}")).unwrap().bytes(), key.bytes());
193    }
194
195    #[test]
196    fn a_key_of_the_wrong_length_is_rejected_with_its_length() {
197        let short = BASE64.encode([0u8; 16]);
198        let err = Key::from_base64(&short).unwrap_err();
199
200        assert!(err.message().contains("32"), "{}", err.message());
201        assert!(err.message().contains("16"), "{}", err.message());
202    }
203
204    #[test]
205    fn rubbish_is_rejected_as_base64() {
206        assert!(Key::from_base64("not base64 at all!!").is_err());
207    }
208
209    #[test]
210    fn the_id_is_derived_from_the_key_and_is_stable() {
211        let key = Key::from_bytes([7u8; KEY_LEN]);
212        let same = Key::from_bytes([7u8; KEY_LEN]);
213        let other = Key::from_bytes([8u8; KEY_LEN]);
214
215        assert_eq!(key.id(), same.id());
216        assert_ne!(key.id(), other.id());
217        assert_eq!(key.id().len(), 8);
218    }
219
220    #[test]
221    fn debug_does_not_disclose_the_key() {
222        let key = Key::generate();
223        let rendered = format!("{key:?}");
224
225        assert!(rendered.contains("redacted"));
226        assert!(!rendered.contains(&BASE64.encode(key.bytes())), "{rendered}");
227        // The id is a hash of the key, so disclosing it is fine and useful.
228        assert!(rendered.contains(key.id()), "{rendered}");
229    }
230
231    #[test]
232    fn a_ring_finds_current_and_retired_keys_by_id() {
233        let current = Key::generate();
234        let old = Key::generate();
235        let ring = KeyRing::new(current.clone()).with_previous(old.clone());
236
237        assert_eq!(ring.current().id(), current.id());
238        assert_eq!(ring.find(old.id()).unwrap().bytes(), old.bytes());
239        assert!(ring.find("00000000").is_none());
240        assert_eq!(ring.len(), 2);
241    }
242
243    #[test]
244    fn empty_previous_entries_are_skipped() {
245        let key = Key::generate().to_base64();
246        let ring = KeyRing::from_base64(&key, &[String::new(), "   ".to_string()]).unwrap();
247
248        assert_eq!(ring.len(), 1, "blank entries in a comma-separated list are not keys");
249    }
250}