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}