Skip to main content

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}