Skip to main content

qualia2fa_core/vault/
crypto.rs

1// Copyright (C) 2026 Qualia2FA contributors
2//
3// This library is free software; you can redistribute it and/or modify it under
4// the terms of the GNU Lesser General Public License as published by the Free
5// Software Foundation; either version 3 of the License, or (at your option) any
6// later version.
7//
8// This library is distributed in the hope that it will be useful, but WITHOUT
9// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10// FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
11// details.
12//
13// You should have received a copy of the GNU Lesser General Public License
14// along with this library; if not, see <https://www.gnu.org/licenses/>.
15
16//! Cryptographic primitives of the vault: master key, subkey derivation, record AEAD.
17//!
18//! # Scope
19//!
20//! Turning one byte string into another and owning the lifetime of key material. The
21//! file format, entry semantics and atomic writes live in other modules.
22//!
23//! # Three invariants
24//!
25//! 1. **The random nonce is 192-bit.** Devices encrypt independently and without
26//!    central coordination, so a random nonce is the only workable choice; 96 bits
27//!    reaches the birthday bound at vault scale. See [`Nonce::LEN`].
28//! 2. **The AAD binds entry identity and format version.** Without it, the ciphertext
29//!    of entry A could be moved to the position of entry B, or the ciphertext of an
30//!    older format could be placed into a newer file: both moves would pass the AEAD,
31//!    because the ciphertext itself is valid. See [`build_aad`].
32//! 3. **The master key is only used through HKDF-derived subkeys**, never directly as
33//!    an AEAD key. See [`entry_key`].
34
35use super::entry::EntryId;
36use crate::{Error, ExposeSecret, Result, SecretBytes};
37use chacha20poly1305::aead::{AeadInOut, KeyInit};
38use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
39use hkdf::Hkdf;
40use sha2::Sha256;
41use subtle::ConstantTimeEq as _;
42
43/// Length of the master key in bytes.
44///
45/// 256-bit. Symmetric key lengths above 256-bit give no practical security gain
46/// (128-bit effective strength under Grover's algorithm is the limit of quantum
47/// search), while they make a recovery key longer and harder to copy by hand.
48pub const MASTER_KEY_LEN: usize = 32;
49
50/// The master key: a 256-bit CSPRNG random value.
51///
52/// # It is the only long-lived secret of the application
53///
54/// OTP seeds, device identities and sync payloads are all derived from it or encrypted
55/// under it. With no central server and no account, there is no recovery path: a lost
56/// key is lost. The key is meant to be held by the platform hardware key store (Windows
57/// TPM).
58///
59/// # Boundaries of secret protection
60///
61/// - No `Clone` (which would silently copy the plaintext) and no plaintext in `Debug`
62///   output.
63/// - Erased on `Drop` (guaranteed by [`SecretBytes`]).
64/// - The plaintext is **not** protected from being copied into registers, stack copies
65///   or the swap file; `forbid(unsafe_code)` rules out `mlock` to prevent paging.
66/// - Malicious code in the same process is **not** defended against. Reading the
67///   plaintext requires an explicit call to [`MasterKey::expose`], which keeps every
68///   use site visible in code review.
69pub struct MasterKey(SecretBytes);
70
71impl MasterKey {
72    /// Generates a new random master key.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`Error::RandomnessUnavailable`] when the OS random source is
77    /// unavailable, and there is no fallback to a user-space PRNG: a predictable master
78    /// key would let an attacker decrypt every entry, and such a failure would be
79    /// silent. The key bytes are handed to [`SecretBytes`], which owns the buffer and
80    /// erases it on drop.
81    pub fn generate() -> Result<Self> {
82        let mut bytes = [0_u8; MASTER_KEY_LEN];
83        crate::getrandom::fill(&mut bytes).map_err(|_| Error::RandomnessUnavailable)?;
84        Ok(Self(Vec::from(bytes).into()))
85    }
86
87    /// Restores the master key from existing bytes.
88    ///
89    /// **Security contract**: the caller must ensure that `bytes` come from a trusted
90    /// source and are erased afterwards. Device login is the only consumer: the master
91    /// key is recovered from the platform key store or from an out-of-band channel (a
92    /// QR code). This is **not** a way to keep a master key in an ordinary file.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`Error::MasterKeyWrongLength`] when the length is not
97    /// [`MASTER_KEY_LEN`]. A wrong length is rejected rather than padded or truncated:
98    /// padding would silently weaken the key, and truncation would make two devices
99    /// derive different subkeys.
100    pub fn from_bytes(bytes: SecretBytes) -> Result<Self> {
101        if bytes.expose_secret().len() != MASTER_KEY_LEN {
102            return Err(Error::MasterKeyWrongLength {
103                expected: MASTER_KEY_LEN,
104                actual: bytes.expose_secret().len(),
105            });
106        }
107        Ok(Self(bytes))
108    }
109
110    /// Explicitly reads the master key plaintext.
111    ///
112    /// `Deref` and `AsRef` are deliberately not implemented: they would read the
113    /// plaintext silently in many places, whereas an explicit method name keeps every
114    /// use site visible to `grep` and code review.
115    #[must_use]
116    pub fn expose(&self) -> &[u8] {
117        self.0.expose_secret()
118    }
119
120    /// Constant-time equality.
121    ///
122    /// Used where two independently recovered keys are compared — for example "the password
123    /// wrap and the platform wrap must yield the same master key". A short-circuiting `==`
124    /// would leak how many leading bytes matched.
125    #[must_use]
126    pub fn matches(&self, other: &Self) -> bool {
127        let left = self.expose();
128        let right = other.expose();
129        if left.len() != right.len() {
130            return false;
131        }
132        let mut difference = 0_u8;
133        for (a, b) in left.iter().zip(right.iter()) {
134            difference |= a ^ b;
135        }
136        difference == 0
137    }
138}
139
140impl core::fmt::Debug for MasterKey {
141    /// Prints the type name only, not even the length.
142    ///
143    /// The length carries no diagnostic value here, since it is a constant.
144    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
145        f.write_str("MasterKey([REDACTED])")
146    }
147}
148
149/// Serialized form of the master key.
150///
151/// For the device login flow only: it hands the master key to the platform key store or
152/// to a QR-code envelope. The returned container is erased on `Drop`, but the caller
153/// should still hand it over as soon as possible.
154///
155/// The [`SecretBytes`] clone is intentional: the caller takes independent ownership of
156/// one copy, and both copies erase themselves.
157#[must_use]
158pub fn master_key_to_bytes(key: &MasterKey) -> SecretBytes {
159    key.0.clone()
160}
161
162/// Length of a nonce in bytes.
163///
164/// **192-bit, not a tunable parameter.** Devices encrypt independently and without
165/// central coordination, so a random nonce is the only workable choice, and a 96-bit
166/// random nonce reaches the birthday bound at vault scale: random-nonce `aes-gcm` is
167/// known to be unsafe there.
168pub const NONCE_LEN: usize = 24;
169
170/// Length of the Poly1305 authentication tag in bytes.
171pub const TAG_LEN: usize = 16;
172
173/// The encryption nonce of a record.
174///
175/// A **fresh** one is generated for every encryption; nonces are neither reused nor
176/// incremented. An incrementing counter would need coordination between devices, and
177/// this application has no coordination point.
178pub type Nonce = [u8; NONCE_LEN];
179
180/// Generates a new random nonce from the OS random source.
181pub(crate) fn random_nonce() -> Result<Nonce> {
182    let mut nonce = [0_u8; NONCE_LEN];
183    crate::getrandom::fill(&mut nonce).map_err(|_| Error::RandomnessUnavailable)?;
184    Ok(nonce)
185}
186
187/// HKDF context string for subkey derivation.
188///
189/// The master key also takes part in device pairing and recovery, so keys are derived
190/// per purpose: a subkey leaked in one context exposes neither the master key itself nor
191/// the data of another context.
192///
193/// The string carries a version (`v1`): a future algorithm or key-length change uses a
194/// new string while old data stays readable under the old one.
195const HKDF_INFO_ENTRY: &[u8] = b"Qualia2FA vault v1 entry encryption";
196
197/// HKDF salt.
198///
199/// Fixed and all-zero. HKDF's salt separates several unrelated protocols that share one
200/// input key material; here the input material (the master key) is already 256-bit
201/// uniformly random and dedicated to this application, so no such sharing exists. A
202/// fixed salt also keeps derivation **deterministic**: the same master key yields the
203/// same subkey on every device, otherwise synced entries could not be opened.
204///
205/// Domain separation is carried by [`HKDF_INFO_ENTRY`], which is the right tool for it.
206const HKDF_SALT: [u8; 32] = [0_u8; 32];
207
208/// Derives the subkey used for entry encryption from the master key.
209///
210/// Derivation happens on every call and is not cached: HKDF costs two HMAC-SHA256
211/// operations, which is negligible for a code generated once every 30 seconds, whereas
212/// caching would keep the subkey in memory longer.
213///
214/// `pub(crate)` rather than private: the sync layer **verifies** that its own key
215/// differs from this one, and that check can only use the value produced here.
216pub(crate) fn entry_key(master: &MasterKey) -> Result<[u8; 32]> {
217    derive_key(master, HKDF_INFO_ENTRY)
218}
219
220/// Derives a 32-byte subkey from the master key with a context string.
221///
222/// # Domain separation relies on `info`
223///
224/// Every subkey derived from the same master key **must** use a different `info`.
225/// Sharing one string between two places is not a slight weakening but full domain
226/// confusion: a ciphertext that is valid in one domain is valid in the other, so moving
227/// a ciphertext from a sync payload into a vault file would authenticate.
228///
229/// Extracted into one function so that [`crate::sync`] derives its keys through the same
230/// rules instead of repeating the HKDF call.
231pub(crate) fn derive_key(master: &MasterKey, info: &[u8]) -> Result<[u8; 32]> {
232    derive_key_with_salt(master, &HKDF_SALT, info)
233}
234
235/// Derives a 32-byte subkey from the master key with the given salt and context string.
236///
237/// # The salt is a parameter
238///
239/// Records of [`crate::sync`] and records of a vault file can carry byte-identical AAD
240/// (one kind byte, a 16-byte identifier and a version byte). Identical AAD means the
241/// ciphertexts of the two domains are interchangeable: a sync payload fed to the vault
242/// parser as a file would authenticate.
243///
244/// Distinct salts separate the domains **structurally**: their subkeys diverge during
245/// HKDF-Extract, independently of how the AAD is laid out.
246///
247/// # Errors
248///
249/// Fails only when the output length exceeds 255 × HashLen, which 32 bytes cannot
250/// reach; the `Result` exists to honour the crate's no-panic rule.
251pub(crate) fn derive_key_with_salt(
252    master: &MasterKey,
253    salt: &[u8],
254    info: &[u8],
255) -> Result<[u8; 32]> {
256    let hkdf = Hkdf::<Sha256>::new(Some(salt), master.expose());
257    let mut key = [0_u8; 32];
258    hkdf.expand(info, &mut key)
259        .map_err(|_| Error::VaultDecryptionFailed)?;
260    Ok(key)
261}
262
263/// HKDF context string for inner sync records.
264///
265/// Not a different string from [`HKDF_INFO_ENTRY`]: **the two deliberately share one**,
266/// because domain separation is carried by the salt (see [`derive_key_with_salt`]).
267/// `info` describes the purpose of a record, and "entry encryption" is the same purpose
268/// in both domains.
269pub(crate) const HKDF_INFO_SYNC_RECORD: &[u8] = HKDF_INFO_ENTRY;
270
271/// HKDF context string for the outer envelope.
272pub(crate) const HKDF_INFO_SYNC_ENVELOPE: &[u8] = b"Qualia2FA sync v1 envelope";
273
274/// Encrypts bytes that are **outside the record format** (the envelope of a sync
275/// payload) with an already derived key.
276///
277/// Unlike [`seal_with_key`], which binds kind + entry ID + version into the AAD, this
278/// binds the caller-supplied AAD as is: the envelope's AAD is the whole plaintext
279/// header, not the record triple.
280///
281/// # Errors
282///
283/// Can only fail when the random source is unavailable.
284pub(crate) fn seal_bytes(key: &[u8; 32], aad: &[u8], plaintext: &[u8]) -> Result<(Nonce, Vec<u8>)> {
285    let cipher = XChaCha20Poly1305::new(&Key::from(*key));
286    let nonce = random_nonce()?;
287    let mut buffer = plaintext.to_vec();
288    cipher
289        .encrypt_in_place(&XNonce::from(nonce), aad, &mut buffer)
290        .map_err(|_| Error::VaultDecryptionFailed)?;
291    Ok((nonce, buffer))
292}
293
294/// Inverse of [`seal_bytes`].
295///
296/// # Errors
297///
298/// Any authentication failure returns [`Error::VaultDecryptionFailed`] without
299/// distinguishing causes. That is **not** the error the sync layer exposes to callers:
300/// [`crate::sync::Payload::open`] translates it into
301/// [`Error::SyncAuthenticationFailed`], because an unopenable file and an untrusted sync
302/// are handled differently at the application boundary.
303pub(crate) fn open_bytes(
304    key: &[u8; 32],
305    aad: &[u8],
306    nonce: &Nonce,
307    ciphertext: &[u8],
308) -> Result<Vec<u8>> {
309    let cipher = XChaCha20Poly1305::new(&Key::from(*key));
310    let mut buffer = ciphertext.to_vec();
311    cipher
312        .decrypt_in_place(&XNonce::from(*nonce), aad, &mut buffer)
313        .map_err(|_| Error::VaultDecryptionFailed)?;
314    Ok(buffer)
315}
316
317/// Length of the master key check value in bytes.
318pub const KEY_CHECK_LEN: usize = 32;
319
320/// HKDF context string for deriving the master key check value.
321///
322/// Different from [`HKDF_INFO_ENTRY`]: this string is what separates the two domains.
323/// Sharing it would make the check value equal to the entry subkey, and the check value
324/// is **written into the file in plaintext**.
325const HKDF_INFO_KEY_CHECK: &[u8] = b"Qualia2FA vault v1 master key check";
326
327/// Derives the check value that states which master key wrote a file.
328///
329/// # What it establishes
330///
331/// The master key takes part only in each record's AEAD, and the file header holds
332/// nothing about it, so **an empty vault file parses successfully under any 32-byte
333/// key**; a wrong master key would only surface once the user adds a first entry or a
334/// first record is synced in. With the check value in the file, `parse` decides whether
335/// the key is correct **before reading the first record**.
336///
337/// # Properties
338///
339/// The derivation is fixed, so the same master key yields the same check value in every
340/// file, and this layer reveals nothing beyond the single bit "the guessed master key is
341/// correct". An attacker holding the file could test keys record by record anyway, since
342/// the AEAD tag is a ready-made verifier.
343///
344/// The artefact that resists offline brute force is the **recovery-key file**: its key is
345/// encrypted under a key derived from a user passphrase with Argon2id, so every attempt
346/// costs 64 MiB of memory and 3 iterations. A vault file holds no passphrase and relies
347/// on the master key being 256-bit uniformly random and unenumerable.
348///
349/// The 32-byte `expand` cannot fail (the HKDF output limit is 255 × 32 bytes), so the
350/// `expect` is unreachable. It is used instead of `unwrap` because the crate's no-panic
351/// rule admits no exception, and the function returns an array with no error channel.
352#[must_use]
353pub fn key_check(master: &MasterKey) -> [u8; KEY_CHECK_LEN] {
354    let hkdf = Hkdf::<Sha256>::new(Some(&HKDF_SALT), master.expose());
355    let mut check = [0_u8; KEY_CHECK_LEN];
356    hkdf.expand(HKDF_INFO_KEY_CHECK, &mut check)
357        .expect("a 32-byte HKDF output cannot fail");
358    check
359}
360
361/// Verifies the master key check value stored in a file.
362///
363/// # Errors
364///
365/// A wrong length returns [`Error::VaultBadKeyCheck`] (structural); a correct length with
366/// a mismatching value returns [`Error::VaultDecryptionFailed`] (authentication). The two
367/// are kept apart because the first says the file is malformed while the second says the
368/// **master key is wrong or the file was modified**, and the two are handled differently
369/// at the application boundary.
370///
371/// # Constant-time comparison
372///
373/// The check value is plaintext, so an attacker can call this function repeatedly
374/// locally to measure timing. Comparing in constant time costs the same, so there is no
375/// reason for a non-constant-time branch here.
376pub fn verify_key_check(master: &MasterKey, stored: &[u8]) -> Result<()> {
377    let stored: &[u8; KEY_CHECK_LEN] = stored.try_into().map_err(|_| Error::VaultBadKeyCheck)?;
378    if key_check(master).ct_eq(stored).into() {
379        Ok(())
380    } else {
381        Err(Error::VaultDecryptionFailed)
382    }
383}
384
385/// The kind of a record, part of the AAD construction.
386///
387/// The kind is part of the AAD, so a tombstone's ciphertext cannot be moved into the position of a
388/// live entry (or the other way round): a live entry and a tombstone use the same key and the same
389/// AEAD, and both are valid ciphertexts that the AEAD alone cannot tell apart.
390///
391/// # The sync kinds are here but outside the vault file's value range
392///
393/// The variants cover **two** record sets: `Entry`/`Tombstone` for vault files and
394/// `SyncEntry`/`SyncTombstone` for sync payloads. They share one subkey and are
395/// separated by the kind byte and by the format version.
396///
397/// The values sit in the high half (from `0x80`), far from the values that occur in a
398/// file (`0x01`/`0x02`), so kinds added to the file format are taken from the low half
399/// and cannot collide with the sync kinds. Moreover, [`RecordKind::from_byte`] does
400/// **not** accept the sync kinds, so the vault parser reports a sync record inside a file
401/// as [`crate::Error::VaultUnknownRecordKind`] instead of accepting it as a valid
402/// record.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub(crate) enum RecordKind {
405    /// A live entry (vault file).
406    Entry,
407    /// A tombstone (vault file).
408    Tombstone,
409    /// A group with its ordering (vault file).
410    ///
411    /// Added in format version 3. Its identifier occupies the same 16-byte field as an entry
412    /// identifier and takes part in the AAD through this kind byte, so a group record can never
413    /// be read as an entry.
414    Group,
415    /// A live entry, found in a **sync payload**.
416    SyncEntry,
417    /// A tombstone, found in a **sync payload**.
418    SyncTombstone,
419    /// A group with its ordering, found in a **sync payload**.
420    SyncGroup,
421}
422
423impl RecordKind {
424    /// The byte value written to the file and fed into the AAD.
425    ///
426    /// **This value is a public contract**: it is part of the AAD, so changing it makes
427    /// every existing ciphertext undecryptable.
428    pub(crate) const fn as_byte(self) -> u8 {
429        match self {
430            Self::Entry => 0x01,
431            Self::Tombstone => 0x02,
432            Self::Group => 0x03,
433            Self::SyncEntry => 0x81,
434            Self::SyncTombstone => 0x82,
435            Self::SyncGroup => 0x83,
436        }
437    }
438
439    /// Restores a kind from a byte read from a **vault file**.
440    ///
441    /// Unknown values are rejected (`None`) rather than treated as a live entry: taking
442    /// an unknown kind for "probably an entry" would revive a tombstone written by a
443    /// future version as a live entry.
444    ///
445    /// The sync kinds are deliberately absent: they only occur in sync payloads, and the
446    /// parser of that layer tries decryption per kind (see the module documentation of
447    /// [`crate::sync::record`]).
448    pub(crate) const fn from_byte(byte: u8) -> Option<Self> {
449        match byte {
450            0x01 => Some(Self::Entry),
451            0x02 => Some(Self::Tombstone),
452            0x03 => Some(Self::Group),
453            _ => None,
454        }
455    }
456
457    /// Restores a kind from a byte read from a **sync payload**.
458    ///
459    /// # No production call site, but load-bearing
460    ///
461    /// The payload parser does not choose kinds with this function (it tries decryption
462    /// per kind), so the normal path never calls it. It is kept because it defines which
463    /// byte values are valid sync record kinds, and the compile-time assertions and unit
464    /// tests in `crate::sync` depend on it.
465    ///
466    /// Strictly disjoint from [`RecordKind::from_byte`]: a device decodes a sync payload
467    /// into a set of entries before touching its own vault, so no record kind may be
468    /// valid in both layers.
469    #[cfg_attr(
470        not(test),
471        expect(
472            dead_code,
473            reason = "defined by contract, used by the compile-time assertions and tests of sync; payload parsing tries each kind instead of coming through here"
474        )
475    )]
476    pub(crate) const fn from_sync_byte(byte: u8) -> Option<Self> {
477        match byte {
478            0x81 => Some(Self::SyncEntry),
479            0x82 => Some(Self::SyncTombstone),
480            0x83 => Some(Self::SyncGroup),
481            _ => None,
482        }
483    }
484}
485
486/// AAD length in bytes: 1 (kind) + 16 (entry_id) + 1 (format version).
487const AAD_LEN: usize = 1 + EntryId::LEN + 1;
488
489/// Builds the additional authenticated data (AAD).
490///
491/// # The three moves it blocks
492///
493/// 1. **Ciphertext moved between entries** — the ciphertext of entry A copied to the
494///    position of entry B. The AAD contains `entry_id`, so authentication fails as soon
495///    as the position changes.
496/// 2. **Ciphertext moved between kinds** — a tombstone ciphertext used as an entry
497///    ciphertext (a deletion silently undone) or the reverse (an entry silently
498///    deleted). The AAD contains [`RecordKind`].
499/// 3. **Ciphertext moved between format versions** — an old-format ciphertext placed
500///    into a new-format file to bypass the new format's extra checks. The AAD contains
501///    the format version.
502///
503/// The layout is a fixed-length byte string rather than a self-describing encoding, so it
504/// is easy to check byte by byte, and it evolves together with
505/// [`crate::vault::FORMAT_VERSION`].
506pub(crate) fn build_aad(
507    kind: RecordKind,
508    record_id: &[u8; EntryId::LEN],
509    format_version: u8,
510) -> [u8; AAD_LEN] {
511    let mut aad = [0_u8; AAD_LEN];
512    aad[0] = kind.as_byte();
513    aad[1..1 + EntryId::LEN].copy_from_slice(record_id);
514    aad[AAD_LEN - 1] = format_version;
515    aad
516}
517
518/// Encrypts record plaintext.
519///
520/// The returned ciphertext **includes** the 16-byte Poly1305 authentication tag
521/// (appended at the end), so its length is `plaintext.len() + TAG_LEN`. The caller stores
522/// the nonce in the file: a nonce is not a secret, but tampering with it makes decryption
523/// fail, which is the wanted outcome.
524///
525/// # Errors
526///
527/// Can only fail when the random source is unavailable
528/// ([`Error::RandomnessUnavailable`]). Encryption itself cannot fail:
529/// XChaCha20-Poly1305 is defined for inputs of any length.
530pub(crate) fn seal(
531    master: &MasterKey,
532    kind: RecordKind,
533    record_id: &[u8; EntryId::LEN],
534    format_version: u8,
535    plaintext: &[u8],
536) -> Result<(Nonce, Vec<u8>)> {
537    let key = entry_key(master)?;
538    seal_with_key(&key, kind, record_id, format_version, plaintext)
539}
540
541/// Encrypts with a subkey that is **already derived**.
542///
543/// Extracted so that one sync derives its subkey once: a payload holds hundreds or
544/// thousands of records, and re-deriving HKDF per record would multiply two HMAC-SHA256
545/// operations by the record count. The plaintext is encrypted in place, so no second copy
546/// of it stays in memory.
547///
548/// # Errors
549///
550/// Can only fail when the random source is unavailable
551/// ([`Error::RandomnessUnavailable`]).
552pub(crate) fn seal_with_key(
553    key: &[u8; 32],
554    kind: RecordKind,
555    record_id: &[u8; EntryId::LEN],
556    format_version: u8,
557    plaintext: &[u8],
558) -> Result<(Nonce, Vec<u8>)> {
559    let cipher = XChaCha20Poly1305::new(&Key::from(*key));
560    let nonce = random_nonce()?;
561    let aad = build_aad(kind, record_id, format_version);
562    let mut buffer = plaintext.to_vec();
563    cipher
564        .encrypt_in_place(&XNonce::from(nonce), &aad, &mut buffer)
565        .map_err(|_| Error::VaultDecryptionFailed)?;
566    Ok((nonce, buffer))
567}
568
569/// Decrypts record ciphertext.
570///
571/// # Errors
572///
573/// Any authentication failure returns [`Error::VaultDecryptionFailed`], **without**
574/// distinguishing a wrong key, a modified ciphertext and a mismatching AAD. Telling them
575/// apart would help an attacker (it reveals whether a guessed key is right and which part
576/// was modified) and gives the user nothing actionable: all three mean that this vault
577/// cannot be opened.
578pub(crate) fn open(
579    master: &MasterKey,
580    kind: RecordKind,
581    record_id: &[u8; EntryId::LEN],
582    format_version: u8,
583    nonce: &Nonce,
584    ciphertext: &[u8],
585) -> Result<Vec<u8>> {
586    let key = entry_key(master)?;
587    open_with_key(&key, kind, record_id, format_version, nonce, ciphertext)
588}
589
590/// Decrypts with a subkey that is **already derived**. Same reasoning as
591/// [`seal_with_key`].
592///
593/// # Errors
594///
595/// Any authentication failure returns [`Error::VaultDecryptionFailed`], **without**
596/// distinguishing a wrong key, a modified ciphertext and a mismatching AAD.
597pub(crate) fn open_with_key(
598    key: &[u8; 32],
599    kind: RecordKind,
600    record_id: &[u8; EntryId::LEN],
601    format_version: u8,
602    nonce: &Nonce,
603    ciphertext: &[u8],
604) -> Result<Vec<u8>> {
605    let cipher = XChaCha20Poly1305::new(&Key::from(*key));
606    let aad = build_aad(kind, record_id, format_version);
607    let mut buffer = ciphertext.to_vec();
608    cipher
609        .decrypt_in_place(&XNonce::from(*nonce), &aad, &mut buffer)
610        .map_err(|_| Error::VaultDecryptionFailed)?;
611    Ok(buffer)
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    fn key() -> MasterKey {
619        MasterKey::generate().expect("OS randomness available")
620    }
621
622    #[test]
623    fn master_key_is_32_bytes_and_debug_is_redacted() {
624        let k = key();
625        assert_eq!(k.expose().len(), MASTER_KEY_LEN);
626
627        let debug = format!("{k:?}");
628        assert!(debug.contains("REDACTED"), "Debug is not redacted: {debug}");
629        for byte in k.expose() {
630            assert!(
631                !debug.contains(&format!("{byte}")),
632                "Debug leaked key bytes: {debug}"
633            );
634        }
635    }
636
637    #[test]
638    fn master_key_rejects_wrong_length() {
639        for len in [0_usize, 1, 31, 33, 64] {
640            let bytes: SecretBytes = vec![0_u8; len].into();
641            match MasterKey::from_bytes(bytes) {
642                Err(Error::MasterKeyWrongLength { expected, actual }) => {
643                    assert_eq!(expected, MASTER_KEY_LEN);
644                    assert_eq!(actual, len);
645                }
646                other => panic!("length {len} must be rejected, actual ok={}", other.is_ok()),
647            }
648        }
649    }
650
651    #[test]
652    fn master_key_accepts_exactly_32_bytes() {
653        let bytes: SecretBytes = vec![7_u8; MASTER_KEY_LEN].into();
654        let k = MasterKey::from_bytes(bytes).expect("correct length");
655        assert_eq!(k.expose(), &[7_u8; MASTER_KEY_LEN]);
656    }
657
658    /// `matches` compares the whole key, and a difference in the last byte is enough.
659    #[test]
660    fn master_key_matches_compares_all_bytes() {
661        let a = MasterKey::from_bytes(vec![5_u8; MASTER_KEY_LEN].into()).expect("correct length");
662        let b = MasterKey::from_bytes(vec![5_u8; MASTER_KEY_LEN].into()).expect("correct length");
663        assert!(a.matches(&b), "equal keys must match");
664
665        let mut last = [5_u8; MASTER_KEY_LEN];
666        last[MASTER_KEY_LEN - 1] = 6;
667        let c = MasterKey::from_bytes(Vec::from(last).into()).expect("correct length");
668        assert!(
669            !a.matches(&c),
670            "a difference in the last byte must be detected"
671        );
672
673        let mut first = [5_u8; MASTER_KEY_LEN];
674        first[0] = 4;
675        let d = MasterKey::from_bytes(Vec::from(first).into()).expect("correct length");
676        assert!(
677            !a.matches(&d),
678            "a difference in the first byte must be detected"
679        );
680    }
681
682    /// The same master key must derive the same subkey, otherwise synced entries cannot
683    /// be opened.
684    #[test]
685    fn derived_key_is_deterministic() {
686        let bytes: SecretBytes = vec![3_u8; MASTER_KEY_LEN].into();
687        let a = MasterKey::from_bytes(bytes.clone()).expect("correct length");
688        let b = MasterKey::from_bytes(bytes).expect("correct length");
689        assert_eq!(
690            entry_key(&a).expect("derivation succeeds"),
691            entry_key(&b).expect("derivation succeeds")
692        );
693    }
694
695    /// Different master keys must derive different subkeys.
696    #[test]
697    fn derived_key_differs_per_master_key() {
698        let a = MasterKey::from_bytes(vec![1_u8; MASTER_KEY_LEN].into()).expect("correct length");
699        let b = MasterKey::from_bytes(vec![2_u8; MASTER_KEY_LEN].into()).expect("correct length");
700        assert_ne!(
701            entry_key(&a).expect("derivation succeeds"),
702            entry_key(&b).expect("derivation succeeds")
703        );
704    }
705
706    /// A subkey must **not** equal the master key itself.
707    ///
708    /// Catches a derivation that degenerates into the identity map.
709    #[test]
710    fn derived_key_is_not_the_master_key() {
711        let bytes: SecretBytes = vec![5_u8; MASTER_KEY_LEN].into();
712        let k = MasterKey::from_bytes(bytes).expect("correct length");
713        assert_ne!(
714            entry_key(&k).expect("derivation succeeds"),
715            *k.expose().first_chunk::<32>().expect("32 bytes")
716        );
717    }
718
719    #[test]
720    fn seal_then_open_roundtrips() {
721        let k = key();
722        let id = EntryId::generate().expect("OS randomness available");
723        let plaintext = b"the quick brown fox";
724
725        let (nonce, ciphertext) =
726            seal(&k, RecordKind::Entry, id.as_bytes(), 1, plaintext).expect("sealing succeeds");
727        assert_eq!(ciphertext.len(), plaintext.len() + TAG_LEN);
728        assert_ne!(
729            &ciphertext[..plaintext.len()],
730            &plaintext[..],
731            "the ciphertext equals the plaintext"
732        );
733
734        let opened = open(&k, RecordKind::Entry, id.as_bytes(), 1, &nonce, &ciphertext)
735            .expect("opening succeeds");
736        assert_eq!(opened, plaintext);
737    }
738
739    /// Every encryption must use a fresh nonce; the security of a random nonce depends
740    /// on it.
741    #[test]
742    fn each_seal_uses_a_fresh_nonce() {
743        let k = key();
744        let id = EntryId::generate().expect("OS randomness available");
745        let (n1, _) =
746            seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"same").expect("sealing succeeds");
747        let (n2, _) =
748            seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"same").expect("sealing succeeds");
749        assert_ne!(n1, n2, "two encryptions reused a nonce");
750    }
751
752    /// Two encryptions of the same plaintext must differ, otherwise they leak that the
753    /// two contents are equal.
754    #[test]
755    fn ciphertext_is_not_deterministic() {
756        let k = key();
757        let id = EntryId::generate().expect("OS randomness available");
758        let (_, c1) =
759            seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"same").expect("sealing succeeds");
760        let (_, c2) =
761            seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"same").expect("sealing succeeds");
762        assert_ne!(c1, c2);
763    }
764
765    /// Another master key must fail to decrypt.
766    #[test]
767    fn wrong_master_key_fails() {
768        let id = EntryId::generate().expect("OS randomness available");
769        let (nonce, ciphertext) =
770            seal(&key(), RecordKind::Entry, id.as_bytes(), 1, b"secret").expect("sealing succeeds");
771        let err = open(
772            &key(),
773            RecordKind::Entry,
774            id.as_bytes(),
775            1,
776            &nonce,
777            &ciphertext,
778        );
779        assert!(matches!(err, Err(Error::VaultDecryptionFailed)));
780    }
781
782    /// **Core AAD test**: ciphertext must not move between entries. It opens at its
783    /// original position and fails at another `entry_id`.
784    #[test]
785    fn ciphertext_cannot_move_between_entry_ids() {
786        let k = key();
787        let a = EntryId::generate().expect("OS randomness available");
788        let b = EntryId::generate().expect("OS randomness available");
789        let (nonce, ciphertext) =
790            seal(&k, RecordKind::Entry, a.as_bytes(), 1, b"payload").expect("sealing succeeds");
791
792        assert!(open(&k, RecordKind::Entry, a.as_bytes(), 1, &nonce, &ciphertext).is_ok());
793
794        let err = open(&k, RecordKind::Entry, b.as_bytes(), 1, &nonce, &ciphertext);
795        assert!(
796            matches!(err, Err(Error::VaultDecryptionFailed)),
797            "the ciphertext was moved to another entry yet still opens"
798        );
799    }
800
801    /// Ciphertext must not move between kinds (a tombstone impersonating an entry and
802    /// the reverse).
803    #[test]
804    fn ciphertext_cannot_move_between_kinds() {
805        let k = key();
806        let id = EntryId::generate().expect("OS randomness available");
807
808        let (nonce, ciphertext) =
809            seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"payload").expect("sealing succeeds");
810        assert!(
811            open(
812                &k,
813                RecordKind::Tombstone,
814                id.as_bytes(),
815                1,
816                &nonce,
817                &ciphertext
818            )
819            .is_err()
820        );
821
822        let (nonce2, ciphertext2) = seal(&k, RecordKind::Tombstone, id.as_bytes(), 1, b"payload")
823            .expect("sealing succeeds");
824        assert!(
825            open(
826                &k,
827                RecordKind::Entry,
828                id.as_bytes(),
829                1,
830                &nonce2,
831                &ciphertext2
832            )
833            .is_err()
834        );
835    }
836
837    /// Ciphertext must not move between format versions.
838    #[test]
839    fn ciphertext_cannot_move_between_format_versions() {
840        let k = key();
841        let id = EntryId::generate().expect("OS randomness available");
842        let (nonce, ciphertext) =
843            seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"payload").expect("sealing succeeds");
844        assert!(open(&k, RecordKind::Entry, id.as_bytes(), 2, &nonce, &ciphertext).is_err());
845    }
846
847    /// **Flipping any single byte of the ciphertext, the nonce or the tag must fail.**
848    ///
849    /// Every position is flipped, covering the ciphertext body, all 24 nonce bytes and
850    /// the 16 tag bytes.
851    #[test]
852    fn flipping_any_ciphertext_byte_fails_authentication() {
853        let k = key();
854        let id = EntryId::generate().expect("OS randomness available");
855        let plaintext = b"0123456789abcdef0123456789abcdef";
856        let (nonce, ciphertext) =
857            seal(&k, RecordKind::Entry, id.as_bytes(), 1, plaintext).expect("sealing succeeds");
858
859        for index in 0..ciphertext.len() {
860            let mut tampered = ciphertext.clone();
861            tampered[index] ^= 0x01;
862            let err = open(&k, RecordKind::Entry, id.as_bytes(), 1, &nonce, &tampered);
863            assert!(
864                matches!(err, Err(Error::VaultDecryptionFailed)),
865                "ciphertext byte {index} was flipped yet still authenticates"
866            );
867        }
868
869        for index in 0..NONCE_LEN {
870            let mut tampered = nonce;
871            tampered[index] ^= 0x01;
872            let err = open(
873                &k,
874                RecordKind::Entry,
875                id.as_bytes(),
876                1,
877                &tampered,
878                &ciphertext,
879            );
880            assert!(
881                matches!(err, Err(Error::VaultDecryptionFailed)),
882                "nonce byte {index} was flipped yet still authenticates"
883            );
884        }
885    }
886
887    /// The AAD length and layout are a public contract (they define file semantics); the
888    /// test pins them down.
889    #[test]
890    fn aad_layout_is_stable() {
891        let id = EntryId::from_bytes([0xAB; EntryId::LEN]);
892        let aad = build_aad(RecordKind::Entry, id.as_bytes(), 1);
893        assert_eq!(aad.len(), AAD_LEN);
894        assert_eq!(aad[0], 0x01, "kind byte");
895        assert_eq!(&aad[1..17], &[0xAB; 16], "entry_id must follow immediately");
896        assert_eq!(aad[17], 1, "the format version must be the last byte");
897
898        assert_eq!(build_aad(RecordKind::Tombstone, id.as_bytes(), 7)[0], 0x02);
899        assert_eq!(build_aad(RecordKind::Tombstone, id.as_bytes(), 7)[17], 7);
900    }
901
902    /// Kind bytes must round-trip and unknown values must be rejected.
903    ///
904    /// The bytes are written out literally: they are part of the AAD, so changing one makes
905    /// every existing ciphertext of that kind undecryptable, and this test has to fail rather
906    /// than follow the constant.
907    #[test]
908    fn record_kind_byte_roundtrips_and_rejects_unknown() {
909        for (kind, byte) in [
910            (RecordKind::Entry, 0x01),
911            (RecordKind::Tombstone, 0x02),
912            (RecordKind::Group, 0x03),
913        ] {
914            assert_eq!(
915                kind.as_byte(),
916                byte,
917                "the file-format kind bytes are a contract"
918            );
919            assert_eq!(RecordKind::from_byte(byte), Some(kind));
920        }
921        for unknown in [0_u8, 4, 0xFF] {
922            assert_eq!(RecordKind::from_byte(unknown), None);
923        }
924        for sync in [RecordKind::SyncEntry, RecordKind::SyncTombstone] {
925            assert_eq!(
926                RecordKind::from_byte(sync.as_byte()),
927                None,
928                "a sync kind must never be accepted inside a vault file"
929            );
930        }
931    }
932
933    /// Empty plaintext must round-trip too (boundary).
934    #[test]
935    fn empty_plaintext_roundtrips() {
936        let k = key();
937        let id = EntryId::generate().expect("OS randomness available");
938        let (nonce, ciphertext) =
939            seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"").expect("sealing succeeds");
940        assert_eq!(
941            ciphertext.len(),
942            TAG_LEN,
943            "empty plaintext leaves only the tag"
944        );
945        let opened = open(&k, RecordKind::Entry, id.as_bytes(), 1, &nonce, &ciphertext)
946            .expect("opening succeeds");
947        assert!(opened.is_empty());
948    }
949
950    /// Truncated ciphertext must fail; an incomplete tag is not a valid short plaintext.
951    #[test]
952    fn truncated_ciphertext_fails() {
953        let k = key();
954        let id = EntryId::generate().expect("OS randomness available");
955        let (nonce, ciphertext) =
956            seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"payload").expect("sealing succeeds");
957
958        for cut in 1..=ciphertext.len() {
959            let truncated = &ciphertext[..ciphertext.len() - cut];
960            let err = open(&k, RecordKind::Entry, id.as_bytes(), 1, &nonce, truncated);
961            assert!(
962                matches!(err, Err(Error::VaultDecryptionFailed)),
963                "truncating {cut} bytes still authenticates"
964            );
965        }
966    }
967
968    /// `master_key_to_bytes` must produce bytes that rebuild the same key.
969    #[test]
970    fn master_key_bytes_roundtrip() {
971        let original =
972            MasterKey::from_bytes(vec![9_u8; MASTER_KEY_LEN].into()).expect("correct length");
973        let bytes = master_key_to_bytes(&original);
974        let rebuilt = MasterKey::from_bytes(bytes).expect("correct length");
975        assert_eq!(original.expose(), rebuilt.expose());
976    }
977}