Skip to main content

lacodda_seal/
lib.rs

1//! Sealing for the lacodda line: bytes sealed under a key, and a key locked
2//! under a passphrase.
3//!
4//! Two formats, each a few bytes of header in front of an XChaCha20-Poly1305
5//! ciphertext. The header says which format and which version it is, and
6//! everything a reader needs to open it except the secret - so a blob written
7//! today opens with a later release of this crate, and a blob from a newer
8//! format is refused by name instead of misread.
9//!
10//! - A [`Key`] is 32 random bytes. [`Key::seal`] turns bytes into a *sealed
11//!   blob*, [`Key::open`] turns it back - or fails, if the blob was sealed
12//!   under another key, for another context, or changed on the way.
13//! - A [`LockedKey`] is a key wrapped under a passphrase: Argon2id stretches
14//!   the passphrase into a key-encryption key, which seals the key. The
15//!   Argon2id parameters and the salt travel in the header, so the lock can be
16//!   stored anywhere - next to the data it protects, on a server that must not
17//!   read it - and opened on any device that knows the passphrase.
18//!
19//! Every blob is bound to a *context* chosen by the caller - a few bytes that
20//! say what the blob is for. Opening it under any other context fails, so a
21//! blob cannot be moved from one use to another and accepted there.
22//!
23//! ```
24//! use lacodda_seal::{Key, LockedKey};
25//!
26//! let key = Key::generate()?;
27//! let sealed = key.seal(b"notes/v1", b"the plan for Tuesday")?;
28//! assert_eq!(key.open(b"notes/v1", &sealed)?.as_slice(), b"the plan for Tuesday");
29//! assert!(key.open(b"photos/v1", &sealed).is_err());
30//!
31//! // The key itself, locked under a passphrase, can be stored in the open.
32//! let lock = LockedKey::lock(&key, b"correct horse battery staple", b"notes/key")?;
33//! let again = LockedKey::from_bytes(&lock.to_bytes())?.unlock(b"correct horse battery staple", b"notes/key")?;
34//! assert_eq!(again.id(), key.id());
35//! # Ok::<(), lacodda_seal::Error>(())
36//! ```
37//!
38//! The byte layouts are on the efema documentation site:
39//! <https://lacodda.github.io/efema/concepts/sealing/>.
40
41use std::fmt;
42use std::time::{Duration, SystemTime, UNIX_EPOCH};
43
44use argon2::{Algorithm, Argon2, Params, Version};
45use chacha20poly1305::aead::{Aead, KeyInit, Payload};
46use chacha20poly1305::{XChaCha20Poly1305, XNonce};
47use sha2::{Digest, Sha256};
48use zeroize::{Zeroize, Zeroizing};
49
50/// Length of a key, in bytes.
51pub const KEY_LEN: usize = 32;
52/// Length of a key's identity, in bytes.
53pub const KEY_ID_LEN: usize = 8;
54/// Length of an Argon2id salt, in bytes.
55pub const SALT_LEN: usize = 16;
56/// Length of an XChaCha20-Poly1305 nonce, in bytes.
57pub const NONCE_LEN: usize = 24;
58/// Length of a Poly1305 tag, in bytes.
59pub const TAG_LEN: usize = 16;
60
61/// The first byte of a sealed blob.
62const SEALED_KIND: u8 = b'S';
63/// The first byte of a locked key.
64const LOCKED_KIND: u8 = b'L';
65/// The format version this release writes, of both formats.
66const VERSION: u8 = 1;
67
68/// A sealed blob's header: kind, version, key identity, nonce.
69const SEALED_HEADER_LEN: usize = 2 + KEY_ID_LEN + NONCE_LEN;
70/// How many bytes sealing adds to the plaintext: the header and the tag.
71pub const SEALED_OVERHEAD: usize = SEALED_HEADER_LEN + TAG_LEN;
72
73/// A locked key's header: kind, version, three Argon2id parameters, the time
74/// of locking, salt, key identity, nonce.
75const LOCKED_HEADER_LEN: usize = 2 + 4 + 4 + 4 + 8 + SALT_LEN + KEY_ID_LEN + NONCE_LEN;
76/// Length of a locked key, in bytes: the header, the wrapped key and its tag.
77pub const LOCKED_LEN: usize = LOCKED_HEADER_LEN + KEY_LEN + TAG_LEN;
78
79// Domain separation: every SHA-256 and every AEAD input here starts with a
80// string no other one starts with, so a value made for one purpose is never
81// accepted for another.
82const KEY_ID_DOMAIN: &[u8] = b"lacodda-seal/v1/key-id";
83const SEALED_DOMAIN: &[u8] = b"lacodda-seal/v1/sealed";
84const LOCKED_DOMAIN: &[u8] = b"lacodda-seal/v1/locked";
85
86/// What can go wrong sealing, opening, locking or unlocking.
87#[derive(Debug, thiserror::Error, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum Error {
90    /// The bytes are not a blob of the expected kind: too short, too long, or
91    /// with another first byte.
92    #[error("not a {expected}: {reason}")]
93    Malformed {
94        /// What the bytes were read as.
95        expected: &'static str,
96        /// What is wrong with them.
97        reason: &'static str,
98    },
99    /// The blob is of a format version this release does not know - written
100    /// by a newer one.
101    #[error("a {kind} of format version {found}, and this release reads up to {VERSION}")]
102    NewerFormat {
103        /// What the bytes were read as.
104        kind: &'static str,
105        /// The version they carry.
106        found: u8,
107    },
108    /// The blob was sealed under another key; it names that key.
109    #[error("sealed under key {found}, not under key {expected}")]
110    WrongKey {
111        /// The key the blob was opened with.
112        expected: KeyId,
113        /// The key the blob says it was sealed under.
114        found: KeyId,
115    },
116    /// The blob does not open under this key and this context: it was sealed
117    /// for another context, or changed after sealing. The two cannot be told
118    /// apart, and this error does not try.
119    #[error("the blob does not open: it was sealed for another purpose, or changed after sealing")]
120    Inauthentic,
121    /// The lock does not open with this passphrase: the passphrase is wrong,
122    /// the context is another one, or the lock was changed. Indistinguishable
123    /// on purpose.
124    #[error("the passphrase does not unlock this key")]
125    WrongPassphrase,
126    /// Argon2id parameters outside what this release accepts.
127    #[error("Argon2id parameters out of bounds: {0}")]
128    Parameters(String),
129    /// The operating system could not supply random bytes.
130    #[error("the operating system's random source failed")]
131    Random,
132}
133
134/// The identity of a key: eight bytes of a hash over it.
135///
136/// Travels in the clear in every header, so a reader holding several keys
137/// knows which one a blob needs without trying each. It reveals nothing about
138/// the key - only whether two blobs were sealed under the same one.
139#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
140pub struct KeyId([u8; KEY_ID_LEN]);
141
142impl KeyId {
143    /// Wraps eight bytes.
144    pub const fn from_bytes(bytes: [u8; KEY_ID_LEN]) -> Self {
145        Self(bytes)
146    }
147
148    /// The eight bytes.
149    pub const fn as_bytes(&self) -> &[u8; KEY_ID_LEN] {
150        &self.0
151    }
152}
153
154impl fmt::Display for KeyId {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        for byte in self.0 {
157            write!(f, "{byte:02x}")?;
158        }
159        Ok(())
160    }
161}
162
163impl fmt::Debug for KeyId {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        write!(f, "KeyId({self})")
166    }
167}
168
169/// A symmetric key: 32 bytes, wiped from memory when dropped.
170///
171/// [`Debug`](fmt::Debug) prints its identity, never the bytes.
172#[derive(Clone)]
173pub struct Key {
174    bytes: Zeroizing<[u8; KEY_LEN]>,
175    id: KeyId,
176}
177
178impl Key {
179    /// A new key from the operating system's random source.
180    ///
181    /// # Errors
182    ///
183    /// [`Error::Random`] when there is no randomness to be had.
184    pub fn generate() -> Result<Self, Error> {
185        Ok(Self::from_bytes(*random::<KEY_LEN>()?))
186    }
187
188    /// A key from bytes kept elsewhere - a keyring, an encrypted store, a QR
189    /// code shown by another device.
190    pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
191        let bytes = Zeroizing::new(bytes);
192        let mut hasher = Sha256::new();
193        hasher.update(KEY_ID_DOMAIN);
194        hasher.update(bytes.as_slice());
195        let digest = hasher.finalize();
196        let mut id = [0u8; KEY_ID_LEN];
197        id.copy_from_slice(&digest[..KEY_ID_LEN]);
198        Self { bytes, id: KeyId(id) }
199    }
200
201    /// The key's bytes, for keeping it somewhere safe. They are the key: what
202    /// holds them can open everything sealed under it.
203    pub fn to_bytes(&self) -> Zeroizing<[u8; KEY_LEN]> {
204        self.bytes.clone()
205    }
206
207    /// The key's identity.
208    pub fn id(&self) -> KeyId {
209        self.id
210    }
211
212    /// Seals `plaintext` for `context` under a fresh random nonce.
213    ///
214    /// The result is [`SEALED_OVERHEAD`] bytes longer than the plaintext.
215    /// Sealing the same plaintext twice gives two different blobs.
216    ///
217    /// # Errors
218    ///
219    /// [`Error::Random`] when there is no randomness for the nonce.
220    pub fn seal(&self, context: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, Error> {
221        Ok(self.seal_with_nonce(context, plaintext, *random::<NONCE_LEN>()?))
222    }
223
224    fn seal_with_nonce(&self, context: &[u8], plaintext: &[u8], nonce: [u8; NONCE_LEN]) -> Vec<u8> {
225        let mut blob = Vec::with_capacity(SEALED_OVERHEAD + plaintext.len());
226        blob.extend_from_slice(&[SEALED_KIND, VERSION]);
227        blob.extend_from_slice(&self.id.0);
228        blob.extend_from_slice(&nonce);
229        let aad = associated(SEALED_DOMAIN, &blob, context);
230        let ciphertext = self
231            .cipher()
232            .encrypt(&XNonce::from(nonce), Payload { msg: plaintext, aad: &aad })
233            .expect("XChaCha20-Poly1305 seals any length a Vec can hold");
234        blob.extend_from_slice(&ciphertext);
235        blob
236    }
237
238    /// Opens a blob sealed for `context` under this key.
239    ///
240    /// # Errors
241    ///
242    /// [`Error::Malformed`] or [`Error::NewerFormat`] when the bytes are not a
243    /// sealed blob this release reads; [`Error::WrongKey`] when it was sealed
244    /// under another key; [`Error::Inauthentic`] when it was sealed for another
245    /// context or changed since.
246    pub fn open(&self, context: &[u8], sealed: &[u8]) -> Result<Zeroizing<Vec<u8>>, Error> {
247        let found = sealed_key_id(sealed)?;
248        if found != self.id {
249            return Err(Error::WrongKey { expected: self.id, found });
250        }
251        let (header, ciphertext) = sealed.split_at(SEALED_HEADER_LEN);
252        let nonce: [u8; NONCE_LEN] = header[2 + KEY_ID_LEN..].try_into().expect("the header has a nonce");
253        let aad = associated(SEALED_DOMAIN, header, context);
254        self.cipher()
255            .decrypt(&XNonce::from(nonce), Payload { msg: ciphertext, aad: &aad })
256            .map(Zeroizing::new)
257            .map_err(|_| Error::Inauthentic)
258    }
259
260    fn cipher(&self) -> XChaCha20Poly1305 {
261        XChaCha20Poly1305::new(&(*self.bytes).into())
262    }
263}
264
265impl fmt::Debug for Key {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        write!(f, "Key({})", self.id)
268    }
269}
270
271/// The identity of the key a sealed blob was sealed under, read from its
272/// header without opening it.
273///
274/// # Errors
275///
276/// [`Error::Malformed`] or [`Error::NewerFormat`] when the bytes are not a
277/// sealed blob this release reads.
278pub fn sealed_key_id(sealed: &[u8]) -> Result<KeyId, Error> {
279    const KIND: &str = "sealed blob";
280    check_header(sealed, SEALED_KIND, KIND)?;
281    if sealed.len() < SEALED_OVERHEAD {
282        return Err(Error::Malformed { expected: KIND, reason: "shorter than a header and a tag" });
283    }
284    Ok(KeyId(sealed[2..2 + KEY_ID_LEN].try_into().expect("the header has a key identity")))
285}
286
287/// How hard Argon2id works to turn a passphrase into a key.
288///
289/// Stored in every [`LockedKey`], so a lock made with stronger parameters
290/// opens with the same code. A lock read from elsewhere is held to the bounds
291/// below before any work is spent on it: a planted lock asking for a terabyte
292/// of memory is refused, not attempted.
293#[derive(Clone, Copy, Debug, PartialEq, Eq)]
294pub struct KdfParams {
295    memory_kib: u32,
296    iterations: u32,
297    parallelism: u32,
298}
299
300impl KdfParams {
301    /// What [`LockedKey::lock`] uses: 64 MiB, three passes, four lanes - a
302    /// fraction of a second on a laptop, the same as sefy's vaults.
303    pub const DEFAULT: Self = Self { memory_kib: 64 * 1024, iterations: 3, parallelism: 4 };
304    /// The weakest parameters accepted: OWASP's minimum for Argon2id (19 MiB,
305    /// two passes, one lane).
306    pub const MIN: Self = Self { memory_kib: 19 * 1024, iterations: 2, parallelism: 1 };
307    /// The strongest parameters accepted: 1 GiB, 16 passes, 16 lanes.
308    pub const MAX: Self = Self { memory_kib: 1024 * 1024, iterations: 16, parallelism: 16 };
309
310    /// Parameters within [`KdfParams::MIN`] and [`KdfParams::MAX`].
311    ///
312    /// # Errors
313    ///
314    /// [`Error::Parameters`] when any of the three is out of bounds.
315    pub fn new(memory_kib: u32, iterations: u32, parallelism: u32) -> Result<Self, Error> {
316        let params = Self { memory_kib, iterations, parallelism };
317        params.check()?;
318        Ok(params)
319    }
320
321    /// Memory, in KiB.
322    pub fn memory_kib(&self) -> u32 {
323        self.memory_kib
324    }
325
326    /// Passes over the memory.
327    pub fn iterations(&self) -> u32 {
328        self.iterations
329    }
330
331    /// Lanes.
332    pub fn parallelism(&self) -> u32 {
333        self.parallelism
334    }
335
336    fn check(&self) -> Result<(), Error> {
337        let within = |value: u32, min: u32, max: u32, what: &str| {
338            if (min..=max).contains(&value) {
339                Ok(())
340            } else {
341                Err(Error::Parameters(format!("{what} is {value}, and it must be from {min} to {max}")))
342            }
343        };
344        within(self.memory_kib, Self::MIN.memory_kib, Self::MAX.memory_kib, "memory (KiB)")?;
345        within(self.iterations, Self::MIN.iterations, Self::MAX.iterations, "iterations")?;
346        within(self.parallelism, Self::MIN.parallelism, Self::MAX.parallelism, "parallelism")
347    }
348
349    fn derive(&self, passphrase: &[u8], salt: &[u8; SALT_LEN]) -> Result<Key, Error> {
350        let params = Params::new(self.memory_kib, self.iterations, self.parallelism, Some(KEY_LEN))
351            .map_err(|e| Error::Parameters(e.to_string()))?;
352        let mut kek = Zeroizing::new([0u8; KEY_LEN]);
353        Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
354            .hash_password_into(passphrase, salt, kek.as_mut())
355            .map_err(|e| Error::Parameters(e.to_string()))?;
356        Ok(Key::from_bytes(*kek))
357    }
358}
359
360impl fmt::Display for KdfParams {
361    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362        let memory = if self.memory_kib.is_multiple_of(1024) {
363            format!("{} MiB", self.memory_kib / 1024)
364        } else {
365            format!("{} KiB", self.memory_kib)
366        };
367        write!(f, "argon2id {memory} × {} × {}", self.iterations, self.parallelism)
368    }
369}
370
371/// A key locked under a passphrase.
372///
373/// Safe to store in the open - next to the data, on a server - for as long as
374/// the passphrase is strong: whoever holds a lock can try passphrases against
375/// it offline, as fast as Argon2id lets them. That cost is the whole of its
376/// protection.
377#[derive(Clone, PartialEq, Eq)]
378pub struct LockedKey {
379    params: KdfParams,
380    locked_at: u64,
381    salt: [u8; SALT_LEN],
382    key_id: KeyId,
383    nonce: [u8; NONCE_LEN],
384    wrapped: [u8; KEY_LEN + TAG_LEN],
385}
386
387impl LockedKey {
388    /// Locks `key` under `passphrase` for `context`, with
389    /// [`KdfParams::DEFAULT`].
390    ///
391    /// # Errors
392    ///
393    /// [`Error::Random`] when there is no randomness for the salt and nonce.
394    pub fn lock(key: &Key, passphrase: &[u8], context: &[u8]) -> Result<Self, Error> {
395        Self::lock_with(key, passphrase, context, KdfParams::DEFAULT)
396    }
397
398    /// Locks `key` under `passphrase` for `context`, with `params`.
399    ///
400    /// # Errors
401    ///
402    /// [`Error::Random`] when there is no randomness for the salt and nonce;
403    /// [`Error::Parameters`] when Argon2id refuses `params`.
404    pub fn lock_with(key: &Key, passphrase: &[u8], context: &[u8], params: KdfParams) -> Result<Self, Error> {
405        let salt = *random::<SALT_LEN>()?;
406        let nonce = *random::<NONCE_LEN>()?;
407        Self::lock_exactly(key, passphrase, context, params, unix_now(), salt, nonce)
408    }
409
410    fn lock_exactly(
411        key: &Key,
412        passphrase: &[u8],
413        context: &[u8],
414        params: KdfParams,
415        locked_at: u64,
416        salt: [u8; SALT_LEN],
417        nonce: [u8; NONCE_LEN],
418    ) -> Result<Self, Error> {
419        params.check()?;
420        let mut lock = Self { params, locked_at, salt, key_id: key.id, nonce, wrapped: [0; KEY_LEN + TAG_LEN] };
421        let kek = params.derive(passphrase, &salt)?;
422        let aad = associated(LOCKED_DOMAIN, &lock.header(), context);
423        let wrapped = kek
424            .cipher()
425            .encrypt(&XNonce::from(nonce), Payload { msg: key.bytes.as_slice(), aad: &aad })
426            .expect("XChaCha20-Poly1305 seals 32 bytes");
427        lock.wrapped.copy_from_slice(&wrapped);
428        Ok(lock)
429    }
430
431    /// Unlocks the key with `passphrase`, for `context`.
432    ///
433    /// Spends what the lock's parameters ask for - by default 64 MiB and a
434    /// fraction of a second.
435    ///
436    /// # Errors
437    ///
438    /// [`Error::WrongPassphrase`] when the passphrase or the context is not
439    /// the one the key was locked with, or the lock was changed since.
440    pub fn unlock(&self, passphrase: &[u8], context: &[u8]) -> Result<Key, Error> {
441        let kek = self.params.derive(passphrase, &self.salt)?;
442        let aad = associated(LOCKED_DOMAIN, &self.header(), context);
443        let mut bytes = kek
444            .cipher()
445            .decrypt(&XNonce::from(self.nonce), Payload { msg: &self.wrapped, aad: &aad })
446            .map_err(|_| Error::WrongPassphrase)?;
447        let mut array = [0u8; KEY_LEN];
448        array.copy_from_slice(&bytes);
449        bytes.zeroize();
450        let key = Key::from_bytes(array);
451        array.zeroize();
452        // The identity is authenticated with the rest of the header, so a
453        // mismatch here means the locking side computed it wrongly - not
454        // tampering. Refused all the same: the lock would name another key.
455        if key.id != self.key_id {
456            return Err(Error::WrongPassphrase);
457        }
458        Ok(key)
459    }
460
461    /// The identity of the key inside, readable without the passphrase.
462    pub fn key_id(&self) -> KeyId {
463        self.key_id
464    }
465
466    /// The Argon2id parameters the lock was made with.
467    pub fn params(&self) -> KdfParams {
468        self.params
469    }
470
471    /// When the lock was made, by the clock of the device that made it.
472    pub fn locked_at(&self) -> SystemTime {
473        UNIX_EPOCH + Duration::from_secs(self.locked_at)
474    }
475
476    /// The lock as bytes: [`LOCKED_LEN`] of them.
477    pub fn to_bytes(&self) -> Vec<u8> {
478        let mut bytes = self.header();
479        bytes.extend_from_slice(&self.wrapped);
480        bytes
481    }
482
483    /// Reads a lock, and holds its parameters to [`KdfParams::MIN`] and
484    /// [`KdfParams::MAX`] before anything is spent on them.
485    ///
486    /// # Errors
487    ///
488    /// [`Error::Malformed`] or [`Error::NewerFormat`] when the bytes are not a
489    /// lock this release reads; [`Error::Parameters`] when its parameters are
490    /// out of bounds.
491    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
492        const KIND: &str = "locked key";
493        check_header(bytes, LOCKED_KIND, KIND)?;
494        if bytes.len() != LOCKED_LEN {
495            return Err(Error::Malformed { expected: KIND, reason: "not the length of a locked key" });
496        }
497        let mut fields = Fields { bytes, at: 2 };
498        let params = KdfParams {
499            memory_kib: u32::from_be_bytes(fields.take()),
500            iterations: u32::from_be_bytes(fields.take()),
501            parallelism: u32::from_be_bytes(fields.take()),
502        };
503        let locked_at = u64::from_be_bytes(fields.take());
504        let salt = fields.take();
505        let key_id = KeyId(fields.take());
506        let nonce = fields.take();
507        let wrapped = fields.take();
508        params.check()?;
509        Ok(Self { params, locked_at, salt, key_id, nonce, wrapped })
510    }
511
512    fn header(&self) -> Vec<u8> {
513        let mut header = Vec::with_capacity(LOCKED_LEN);
514        header.extend_from_slice(&[LOCKED_KIND, VERSION]);
515        header.extend_from_slice(&self.params.memory_kib.to_be_bytes());
516        header.extend_from_slice(&self.params.iterations.to_be_bytes());
517        header.extend_from_slice(&self.params.parallelism.to_be_bytes());
518        header.extend_from_slice(&self.locked_at.to_be_bytes());
519        header.extend_from_slice(&self.salt);
520        header.extend_from_slice(&self.key_id.0);
521        header.extend_from_slice(&self.nonce);
522        header
523    }
524}
525
526impl fmt::Debug for LockedKey {
527    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
528        f.debug_struct("LockedKey")
529            .field("key_id", &self.key_id)
530            .field("params", &self.params)
531            .field("locked_at", &self.locked_at)
532            .finish_non_exhaustive()
533    }
534}
535
536/// Whether `bytes` start as a sealed blob does - the first byte of the
537/// format, whatever its version. For telling the two kinds apart when they
538/// travel side by side; [`Key::open`] still decides whether one opens.
539pub fn is_sealed(bytes: &[u8]) -> bool {
540    bytes.first() == Some(&SEALED_KIND)
541}
542
543/// Whether `bytes` start as a locked key does, whatever its version.
544pub fn is_locked_key(bytes: &[u8]) -> bool {
545    bytes.first() == Some(&LOCKED_KIND)
546}
547
548/// Fixed-width fields read one after another. The length was checked before
549/// the first one is taken, so a take never runs past the end.
550struct Fields<'a> {
551    bytes: &'a [u8],
552    at: usize,
553}
554
555impl Fields<'_> {
556    fn take<const N: usize>(&mut self) -> [u8; N] {
557        let field = self.bytes[self.at..self.at + N].try_into().expect("the length was checked");
558        self.at += N;
559        field
560    }
561}
562
563fn check_header(bytes: &[u8], kind: u8, name: &'static str) -> Result<(), Error> {
564    match bytes {
565        [] | [_] => Err(Error::Malformed { expected: name, reason: "shorter than a header" }),
566        [first, ..] if *first != kind => Err(Error::Malformed { expected: name, reason: "another kind of blob" }),
567        [_, version, ..] if *version == VERSION => Ok(()),
568        [_, version, ..] if *version > VERSION => Err(Error::NewerFormat { kind: name, found: *version }),
569        _ => Err(Error::Malformed { expected: name, reason: "format version 0 does not exist" }),
570    }
571}
572
573/// The associated data of a blob: its domain, its header, the caller's
574/// context. The header and the domain have one length per format and version,
575/// so the context needs no length of its own to stay unambiguous.
576fn associated(domain: &[u8], header: &[u8], context: &[u8]) -> Vec<u8> {
577    [domain, header, context].concat()
578}
579
580fn random<const N: usize>() -> Result<Zeroizing<[u8; N]>, Error> {
581    let mut bytes = Zeroizing::new([0u8; N]);
582    getrandom::fill(bytes.as_mut()).map_err(|_| Error::Random)?;
583    Ok(bytes)
584}
585
586fn unix_now() -> u64 {
587    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    fn key() -> Key {
595        Key::from_bytes([7; KEY_LEN])
596    }
597
598    #[test]
599    fn a_sealed_blob_opens_under_its_key_and_context_only() {
600        let key = key();
601        let sealed = key.seal(b"ctx", b"payload").unwrap();
602        assert_eq!(key.open(b"ctx", &sealed).unwrap().as_slice(), b"payload");
603        assert_eq!(key.open(b"other", &sealed), Err(Error::Inauthentic));
604        let other = Key::from_bytes([8; KEY_LEN]);
605        assert_eq!(other.open(b"ctx", &sealed), Err(Error::WrongKey { expected: other.id(), found: key.id() }));
606    }
607
608    #[test]
609    fn every_byte_of_a_sealed_blob_is_covered() {
610        // Flipping any bit - header, nonce, ciphertext or tag - is refused.
611        let key = key();
612        let sealed = key.seal(b"ctx", b"payload").unwrap();
613        for index in 0..sealed.len() {
614            let mut changed = sealed.clone();
615            changed[index] ^= 0x01;
616            assert!(key.open(b"ctx", &changed).is_err(), "byte {index} is not authenticated");
617        }
618    }
619
620    #[test]
621    fn sealing_twice_gives_two_blobs_of_a_known_size() {
622        let key = key();
623        let first = key.seal(b"", b"payload").unwrap();
624        let second = key.seal(b"", b"payload").unwrap();
625        assert_ne!(first, second);
626        assert_eq!(first.len(), b"payload".len() + SEALED_OVERHEAD);
627        assert_eq!(key.seal(b"", b"").unwrap().len(), SEALED_OVERHEAD);
628    }
629
630    #[test]
631    fn a_blob_of_another_kind_or_a_newer_version_is_named_not_misread() {
632        let key = key();
633        let mut sealed = key.seal(b"", b"x").unwrap();
634        assert!(matches!(key.open(b"", &sealed[..1]), Err(Error::Malformed { .. })));
635        assert!(matches!(key.open(b"", &sealed[..SEALED_OVERHEAD - 1]), Err(Error::Malformed { .. })));
636        sealed[1] = VERSION + 1;
637        assert_eq!(key.open(b"", &sealed), Err(Error::NewerFormat { kind: "sealed blob", found: VERSION + 1 }));
638        sealed[0] = LOCKED_KIND;
639        assert!(matches!(key.open(b"", &sealed), Err(Error::Malformed { .. })));
640    }
641
642    #[test]
643    fn a_key_round_trips_through_its_lock() {
644        let key = key();
645        let lock = LockedKey::lock_with(&key, b"pass phrase", b"ctx", KdfParams::MIN).unwrap();
646        assert_eq!(lock.key_id(), key.id());
647        let bytes = lock.to_bytes();
648        assert_eq!(bytes.len(), LOCKED_LEN);
649        let read = LockedKey::from_bytes(&bytes).unwrap();
650        assert_eq!(read, lock);
651        assert_eq!(*read.unlock(b"pass phrase", b"ctx").unwrap().to_bytes(), *key.to_bytes());
652        assert_eq!(read.unlock(b"pass phrasE", b"ctx").unwrap_err(), Error::WrongPassphrase);
653        assert_eq!(read.unlock(b"pass phrase", b"other").unwrap_err(), Error::WrongPassphrase);
654    }
655
656    #[test]
657    fn every_byte_of_a_lock_is_covered() {
658        let key = key();
659        let lock = LockedKey::lock_with(&key, b"pw", b"ctx", KdfParams::MIN).unwrap().to_bytes();
660        // The parameters are covered by refusing to read them, the rest by
661        // the tag. Skipping the parameters' bytes would leave them unchecked:
662        // a bit flipped there must fail one way or the other.
663        for index in 0..lock.len() {
664            let mut changed = lock.clone();
665            changed[index] ^= 0x01;
666            let outcome = LockedKey::from_bytes(&changed).and_then(|l| l.unlock(b"pw", b"ctx"));
667            assert!(outcome.is_err(), "byte {index} of a lock is not authenticated");
668        }
669    }
670
671    #[test]
672    fn a_lock_out_of_bounds_is_refused_before_any_work() {
673        let key = key();
674        let lock = LockedKey::lock_with(&key, b"pw", b"ctx", KdfParams::MIN).unwrap().to_bytes();
675        let mut greedy = lock.clone();
676        // memory = 4 TiB: refused on reading, not attempted.
677        greedy[2..6].copy_from_slice(&u32::MAX.to_be_bytes());
678        assert!(matches!(LockedKey::from_bytes(&greedy), Err(Error::Parameters(_))));
679        let mut weak = lock;
680        weak[6..10].copy_from_slice(&1u32.to_be_bytes());
681        assert!(matches!(LockedKey::from_bytes(&weak), Err(Error::Parameters(_))));
682        assert!(KdfParams::new(KdfParams::MAX.memory_kib + 1, 3, 4).is_err());
683        assert_eq!(KdfParams::new(65536, 3, 4).unwrap(), KdfParams::DEFAULT);
684    }
685
686    #[test]
687    fn kinds_are_told_apart_by_their_first_byte() {
688        let key = key();
689        let sealed = key.seal(b"", b"x").unwrap();
690        let lock = LockedKey::lock_with(&key, b"pw", b"", KdfParams::MIN).unwrap().to_bytes();
691        assert!(is_sealed(&sealed) && !is_locked_key(&sealed));
692        assert!(is_locked_key(&lock) && !is_sealed(&lock));
693        assert!(matches!(LockedKey::from_bytes(&sealed), Err(Error::Malformed { .. })));
694        assert!(!is_sealed(&[]) && !is_locked_key(&[]));
695    }
696
697    #[test]
698    fn debug_never_shows_the_key() {
699        let key = key();
700        let shown = format!("{key:?}");
701        assert!(shown.starts_with("Key(") && !shown.contains("07, 07"), "{shown}");
702        assert_eq!(shown, format!("Key({})", key.id()));
703    }
704
705    // Frozen values, computed outside Rust - Python's `cryptography` (OpenSSL's
706    // Argon2id and ChaCha20-Poly1305) with HChaCha20 written out by hand - so
707    // they check the formats rather than echo this code. Every blob anyone has
708    // sealed depends on these bytes: if this test fails, the change is a new
709    // format version, not a fix.
710    #[test]
711    fn the_formats_are_frozen() {
712        let key = Key::from_bytes(*b"0123456789abcdef0123456789abcdef");
713        assert_eq!(key.id().to_string(), FROZEN_KEY_ID);
714
715        let sealed = key.seal_with_nonce(b"context", b"hello", *b"nonce-nonce-nonce-nonce!");
716        assert_eq!(hex(&sealed), FROZEN_SEALED);
717
718        let lock = LockedKey::lock_exactly(
719            &key,
720            b"correct horse battery staple",
721            b"context",
722            KdfParams::MIN,
723            1_760_000_000,
724            *b"salt-salt-salt-s",
725            *b"nonce-nonce-nonce-nonce!",
726        )
727        .unwrap();
728        assert_eq!(hex(&lock.to_bytes()), FROZEN_LOCKED);
729    }
730
731    const FROZEN_KEY_ID: &str = "fbcf86ef40348bea";
732    const FROZEN_SEALED: &str = concat!(
733        "5301fbcf86ef40348bea6e6f6e63652d6e6f6e63652d6e6f6e63652d6e6f6e63652143f707c8a51d90bc9391bd9cb2776f280e",
734        "74c75af6"
735    );
736    const FROZEN_LOCKED: &str = concat!(
737        "4c0100004c0000000002000000010000000068e7780073616c742d73616c742d73616c742d73fbcf86ef40348bea6e6f6e",
738        "63652d6e6f6e63652d6e6f6e63652d6e6f6e63652148cf6c30e1cc7edbd043709d3dbbd646ce6cb2a0cb26a692270f6818",
739        "e32c3e4df95dc80e3323b31aea6cbfc49a917243"
740    );
741
742    fn hex(bytes: &[u8]) -> String {
743        bytes.iter().map(|b| format!("{b:02x}")).collect()
744    }
745}