evault_core/crypto/master_key.rs
1//! [`MasterKey`] — the 256-bit symmetric key used to unlock the encrypted
2//! metadata store.
3//!
4//! The key is generated with the OS CSPRNG ([`rand::rngs::SysRng`]) and
5//! stored in the OS keyring under a well-known service/account pair. It is
6//! never written to disk in plaintext.
7
8use std::fmt;
9
10use rand::rngs::SysRng;
11use rand::TryRng;
12use secrecy::{ExposeSecret, SecretBox};
13use zeroize::Zeroize;
14
15use crate::error::SecretError;
16
17/// Length of [`MasterKey`] material in bytes (256 bits).
18pub const MASTER_KEY_LEN: usize = 32;
19
20/// A 256-bit symmetric key that wipes its contents on drop.
21///
22/// Used as the `SQLCipher` key for the metadata store. The constructor only
23/// exposes generated keys and rehydration from existing bytes; the bytes
24/// themselves never leak through `Display`, `Debug`, or `Serialize` impls.
25///
26/// # Examples
27/// ```
28/// use evault_core::crypto::{ExposeSecret, MasterKey, MASTER_KEY_LEN};
29/// let k = MasterKey::generate().expect("OS RNG should be available");
30/// assert_eq!(k.bytes().expose_secret().len(), MASTER_KEY_LEN);
31/// ```
32pub struct MasterKey {
33 inner: SecretBox<[u8; MASTER_KEY_LEN]>,
34}
35
36// `Debug` is written by hand so that future fields cannot accidentally leak
37// through a derived implementation. The inner secret box also redacts itself,
38// but defense in depth: only the type name is printed.
39impl fmt::Debug for MasterKey {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 f.debug_struct("MasterKey").finish_non_exhaustive()
42 }
43}
44
45impl MasterKey {
46 /// Generate a new key using the operating system's secure RNG.
47 ///
48 /// # Errors
49 /// Returns [`SecretError::Backend`] if the OS RNG is unavailable, which
50 /// is essentially fatal — every supported platform exposes one and
51 /// failures here generally indicate a sandbox restriction or a hardware
52 /// fault.
53 pub fn generate() -> Result<Self, SecretError> {
54 let mut bytes = [0_u8; MASTER_KEY_LEN];
55 SysRng
56 .try_fill_bytes(&mut bytes)
57 .map_err(|e| SecretError::Backend(format!("OS RNG: {e}")))?;
58 Ok(Self::from_bytes(bytes))
59 }
60
61 /// Wrap pre-existing bytes (e.g. fetched from the OS keyring).
62 ///
63 /// Prefer [`Self::generate`] for new keys.
64 #[must_use]
65 pub fn from_bytes(bytes: [u8; MASTER_KEY_LEN]) -> Self {
66 Self {
67 inner: SecretBox::new(Box::new(bytes)),
68 }
69 }
70
71 /// Borrow the key bytes through a [`SecretBox`].
72 ///
73 /// Use [`ExposeSecret::expose_secret`] (re-exported as
74 /// [`crate::crypto::ExposeSecret`]) to obtain the raw byte slice when
75 /// passing it to the encryption layer.
76 #[must_use]
77 pub const fn bytes(&self) -> &SecretBox<[u8; MASTER_KEY_LEN]> {
78 &self.inner
79 }
80
81 /// Encode the key as lowercase hex.
82 ///
83 /// `SQLCipher` accepts hex keys in the form `PRAGMA key = "x'<hex>'"`.
84 /// The returned [`SecretString`](crate::crypto::SecretString) is itself
85 /// redacted in `Debug` output and zeroized on drop.
86 ///
87 /// The staging buffer used to build the hex representation is explicitly
88 /// zeroized before this function returns so that the plaintext hex form
89 /// of the key cannot linger in heap memory.
90 ///
91 /// # Panics
92 /// This function never panics in practice: it constructs the hex
93 /// representation from a fixed ASCII alphabet, so the
94 /// [`std::str::from_utf8`] check on the staging buffer always succeeds.
95 /// The `expect` call exists only to encode the invariant structurally;
96 /// clippy's `expect_used` lint is allowed locally.
97 #[must_use]
98 pub fn to_hex_secret(&self) -> crate::crypto::SecretString {
99 const HEX: &[u8; 16] = b"0123456789abcdef";
100 let bytes = self.inner.expose_secret();
101 let mut buf: Vec<u8> = vec![0_u8; MASTER_KEY_LEN * 2];
102 for (i, b) in bytes.iter().enumerate() {
103 buf[2 * i] = HEX[(b >> 4) as usize];
104 buf[2 * i + 1] = HEX[(b & 0x0F) as usize];
105 }
106 // `buf` contains only bytes from `HEX`, which are all valid ASCII and
107 // therefore valid UTF-8. Build a fresh `Box<str>` (a separate heap
108 // allocation) and then wipe `buf` before it goes out of scope.
109 let boxed: Box<str> = {
110 #[allow(clippy::expect_used)]
111 let hex_str: &str = std::str::from_utf8(&buf).expect("hex characters are always ASCII");
112 Box::<str>::from(hex_str)
113 };
114 buf.zeroize();
115 crate::crypto::SecretString::from(boxed)
116 }
117
118 /// Compare two master keys in constant time.
119 ///
120 /// `[u8; N]` uses bytewise `memcmp` which is allowed by the compiler to
121 /// short-circuit on the first mismatching byte — a textbook timing side
122 /// channel. This helper folds an OR-accumulator over every byte so that
123 /// the running time depends only on the key length, not on the data.
124 ///
125 /// Callers that need to compare key material **must** use this method
126 /// instead of `==` on the bytes returned by `bytes().expose_secret()`.
127 ///
128 /// # Examples
129 /// ```
130 /// use evault_core::crypto::{MasterKey, MASTER_KEY_LEN};
131 ///
132 /// let a = MasterKey::from_bytes([0xAB; MASTER_KEY_LEN]);
133 /// let b = MasterKey::from_bytes([0xAB; MASTER_KEY_LEN]);
134 /// let c = MasterKey::from_bytes([0xCD; MASTER_KEY_LEN]);
135 /// assert!(a.ct_eq(&b));
136 /// assert!(!a.ct_eq(&c));
137 /// ```
138 #[must_use]
139 pub fn ct_eq(&self, other: &Self) -> bool {
140 let a = self.inner.expose_secret();
141 let b = other.inner.expose_secret();
142 // OR-accumulate every byte difference. `iter().zip()` avoids any
143 // bounds-check panic edge; no `if`, no `break` — LLVM cannot legally
144 // short-circuit. `black_box` prevents future optimiser passes from
145 // defeating the loop.
146 let mut diff: u8 = 0;
147 for (x, y) in a.iter().zip(b.iter()) {
148 diff |= x ^ y;
149 }
150 std::hint::black_box(diff) == 0
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn generate_produces_correct_length() {
160 let k = MasterKey::generate().expect("OS RNG");
161 assert_eq!(k.bytes().expose_secret().len(), MASTER_KEY_LEN);
162 }
163
164 #[test]
165 fn two_generated_keys_are_distinct() {
166 let a = MasterKey::generate().expect("OS RNG");
167 let b = MasterKey::generate().expect("OS RNG");
168 assert_ne!(a.bytes().expose_secret(), b.bytes().expose_secret());
169 }
170
171 #[test]
172 fn from_bytes_roundtrips() {
173 let bytes = [0xA5_u8; MASTER_KEY_LEN];
174 let k = MasterKey::from_bytes(bytes);
175 assert_eq!(k.bytes().expose_secret(), &bytes);
176 }
177
178 #[test]
179 fn to_hex_secret_has_double_length() {
180 let bytes = [0xAB_u8; MASTER_KEY_LEN];
181 let k = MasterKey::from_bytes(bytes);
182 let hex = k.to_hex_secret();
183 assert_eq!(hex.expose_secret().len(), MASTER_KEY_LEN * 2);
184 assert_eq!(hex.expose_secret(), &"ab".repeat(MASTER_KEY_LEN));
185 }
186
187 #[test]
188 fn to_hex_secret_uses_lowercase_alphabet() {
189 let bytes = [0xDE_u8; MASTER_KEY_LEN];
190 let hex = MasterKey::from_bytes(bytes).to_hex_secret();
191 assert!(hex
192 .expose_secret()
193 .chars()
194 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
195 }
196
197 #[test]
198 fn debug_redacts_key_bytes() {
199 let k = MasterKey::from_bytes([0xCD_u8; MASTER_KEY_LEN]);
200 let dbg = format!("{k:?}");
201 assert!(!dbg.contains("cd"));
202 assert!(!dbg.contains("CD"));
203 // Should print only the struct name and a `..` marker thanks to
204 // `finish_non_exhaustive`.
205 assert!(dbg.contains("MasterKey"));
206 assert!(dbg.contains(".."));
207 }
208
209 #[test]
210 fn ct_eq_is_true_for_equal_keys() {
211 let a = MasterKey::from_bytes([0x12; MASTER_KEY_LEN]);
212 let b = MasterKey::from_bytes([0x12; MASTER_KEY_LEN]);
213 assert!(a.ct_eq(&b));
214 }
215
216 #[test]
217 fn ct_eq_is_false_for_keys_that_differ_only_in_last_byte() {
218 let a = MasterKey::from_bytes([0x12; MASTER_KEY_LEN]);
219 let mut diff = [0x12_u8; MASTER_KEY_LEN];
220 diff[MASTER_KEY_LEN - 1] = 0x13;
221 let b = MasterKey::from_bytes(diff);
222 assert!(!a.ct_eq(&b));
223 }
224
225 #[test]
226 fn ct_eq_is_false_for_keys_that_differ_only_in_first_byte() {
227 let a = MasterKey::from_bytes([0x12; MASTER_KEY_LEN]);
228 let mut diff = [0x12_u8; MASTER_KEY_LEN];
229 diff[0] = 0x13;
230 let b = MasterKey::from_bytes(diff);
231 assert!(!a.ct_eq(&b));
232 }
233}