Skip to main content

secure_gate/sealed/
prekey.rs

1//! The pre-key: 16 KiB of random bytes every sealing key is derived from.
2
3use alloc::sync::Arc;
4
5use chacha20poly1305::aead::AeadInOut;
6use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
7use guarded_block::GuardedBlock;
8use rand::{TryRng, rngs::SysRng};
9use sha2::{Digest, Sha256};
10use zeroize::Zeroize;
11
12use crate::{Fixed, RevealSecret};
13
14/// Size of the pre-key in bytes: four 4 KiB pages, 131,072 bits.
15pub(crate) const PREKEY_LEN: usize = 16 * 1024;
16
17/// Size of the detached ChaCha20-Poly1305 tag stored beside each ciphertext.
18pub(crate) const TAG_LEN: usize = 16;
19
20/// Size of the random salt drawn for every seal.
21pub(crate) const SALT_LEN: usize = 32;
22
23/// Why the pre-key's pages are not locked in RAM. Returned by [`Prekey::lock_status`].
24///
25/// Not fatal: the sealed types work the same either way. What is lost is the guarantee
26/// that the pre-key never reaches the swap file. On Windows the usual cause is the
27/// process working-set quota, which every `VirtualLock` in the process shares; on Unix it
28/// is `RLIMIT_MEMLOCK`.
29///
30/// It is also returned, without asking the operating system, for a pre-key made with
31/// [`Prekey::new_idle_noaccess`] on Windows, where a no-access page cannot stay locked, and
32/// in a `fork()` child of the process that made the pre-key, which does not inherit its lock.
33///
34/// Carries the operating system's error code, never an address.
35///
36/// # Example
37///
38/// Report the reason, and raise the quota where it is the cause:
39///
40/// ```
41/// use secure_gate::Prekey;
42///
43/// let prekey = Prekey::new();
44/// match prekey.lock_status() {
45///     Ok(()) => {}
46///     // ERROR_WORKING_SET_QUOTA: every `VirtualLock` in a Windows process shares the
47///     // working-set quota (about eleven pre-keys under the default). Raise it at startup
48///     // with `SetProcessWorkingSetSize` if the application holds more.
49///     Err(why) if why.raw_os_error() == Some(1453) => eprintln!("raise the quota: {why}"),
50///     Err(why) => eprintln!("sealed secrets work, but may reach swap: {why}"),
51/// }
52/// ```
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub struct LockError {
55    os_code: Option<i32>,
56    idle_noaccess: bool,
57    forked: bool,
58}
59
60impl LockError {
61    fn from_guarded(err: guarded_block::LockError) -> Self {
62        Self {
63            os_code: err.raw_os_error(),
64            idle_noaccess: err.idle_noaccess(),
65            forked: err.forked(),
66        }
67    }
68
69    /// The operating system's error code (`GetLastError` on Windows, `errno` on Unix), when
70    /// the lock was attempted and refused.
71    pub fn raw_os_error(&self) -> Option<i32> {
72        self.os_code
73    }
74
75    /// `true` when locking was not attempted because the pre-key was made with
76    /// [`Prekey::new_idle_noaccess`] on Windows: a no-access page leaves the process's
77    /// working set, and its lock with it.
78    pub fn idle_noaccess(&self) -> bool {
79        self.idle_noaccess
80    }
81
82    /// `true` in a `fork()` child of the process that made the pre-key: page locks are not
83    /// inherited, so the child holds the pre-key unlocked whatever the parent was granted.
84    pub fn forked(&self) -> bool {
85        self.forked
86    }
87}
88
89impl core::fmt::Display for LockError {
90    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
91        if self.forked {
92            return f.write_str(
93                "the sealed-memory pre-key is not locked in RAM in this process: it was made \
94                 before a fork, and page locks are not inherited",
95            );
96        }
97        match (self.idle_noaccess, self.os_code) {
98            (true, _) => f.write_str(
99                "the sealed-memory pre-key is not locked in RAM: it is no-access while idle \
100                 (Prekey::new_idle_noaccess), and on Windows a no-access page cannot stay locked",
101            ),
102            (false, Some(code)) => write!(
103                f,
104                "the sealed-memory pre-key could not be locked in RAM (OS error {code})"
105            ),
106            (false, None) => f.write_str("the sealed-memory pre-key could not be locked in RAM"),
107        }
108    }
109}
110
111impl std::error::Error for LockError {}
112
113/// The 16 KiB of random bytes that every sealing key is derived from, in memory of its own
114/// between no-access guard pages, read-only, and locked in RAM where the operating system
115/// allows it.
116///
117/// Each [`SealedFixed`](crate::SealedFixed) and [`SealedDynamic`](crate::SealedDynamic)
118/// keeps an `Arc` to the `Prekey` it was sealed under and derives its key afresh on every
119/// access as `SHA-256(salt ‖ pre-key)`. The key itself is never stored, and the stack the
120/// cipher used is overwritten after each access, since the cipher leaves a copy there. A
121/// lossy readout of memory (Spectre-class side channels, Rowhammer, cold boot) that gets any
122/// one of the pre-key's 131,072 bits wrong yields a useless key, with nothing to say which
123/// bit was wrong. See the [`sealed`](crate::sealed) module for the whole threat model.
124///
125/// # Memory
126///
127/// The pre-key is not on the heap. It has a mapping of its own (from the
128/// [`guarded-block`](https://crates.io/crates/guarded-block) crate, which holds the `unsafe`
129/// this crate forbids), laid out as a no-access guard page, the pre-key's pages, and a
130/// no-access guard page:
131///
132/// - A linear over-read from a buffer below it, such as a C library's in the same process
133///   (Heartbleed-style), faults on the guard page instead of reading the pre-key.
134/// - It is read-only from the moment it is filled until it drops.
135/// - Its pages are locked (`VirtualLock` on Windows, `mlock` on Unix), and on Linux
136///   marked `MADV_DONTDUMP`, which the kernel documents as leaving it out of core dumps.
137/// - With [`new_idle_noaccess`](Self::new_idle_noaccess) it is also no-access except while a
138///   sealing key is being derived from it (unless the system refuses to make it no-access
139///   again), which on Windows costs the lock.
140///
141/// # Lifetime
142///
143/// The pre-key is not a `static`. It lives as long as the last sealed object, or the last
144/// other `Arc`, that refers to it. When that one drops, the pre-key is wiped (while its
145/// pages are still locked, if the lock was granted), then unlocked, then unmapped. An application typically holds one
146/// `Prekey` for a session and drops it, along with everything sealed under it, at lock or
147/// logout.
148///
149/// # Locking
150///
151/// [`new`](Self::new) asks the operating system to keep the pre-key's pages out of swap and
152/// never fails because it could not: [`lock_status`](Self::lock_status) reports the outcome
153/// so the caller can decide. Locking covers swap only. Hibernation and a clean read of the
154/// process's memory (a debugger, `/proc/<pid>/mem`) still see the pre-key, and so does a
155/// crash dump on Windows (unless the pre-key is idle no-access) and on macOS.
156///
157/// # Example
158///
159/// One pre-key per session, created at login and dropped at logout with everything sealed
160/// under it:
161///
162/// ```
163/// use std::sync::Arc;
164/// use secure_gate::{Dynamic, Fixed, Prekey, RevealSecret, SealedDynamic, SealedFixed};
165///
166/// struct Session {
167///     prekey: Arc<Prekey>,
168///     db_key: SealedFixed<32>,
169///     password: SealedDynamic<String>,
170/// }
171///
172/// fn login(db_key: Fixed<[u8; 32]>, password: Dynamic<String>) -> Session {
173///     let prekey = Prekey::new();
174///     if let Err(why) = prekey.lock_status() {
175///         eprintln!("{why}"); // not fatal; see `LockError`
176///     }
177///     Session {
178///         db_key: SealedFixed::seal(db_key, &prekey),
179///         password: SealedDynamic::seal(password, &prekey),
180///         prekey,
181///     }
182/// }
183///
184/// let session = login(Fixed::new([7u8; 32]), Dynamic::new(String::from("hunter2")));
185/// session.db_key.with_secret(|k| assert_eq!(k[0], 7));
186/// session.password.with_secret(|p| assert_eq!(p, "hunter2"));
187/// drop(session); // logout: the sealed values, then the pre-key, are wiped
188/// ```
189///
190/// [`new_idle_noaccess`](Self::new_idle_noaccess) is the alternative where crash dumps or
191/// same-user memory reads matter more than the pagefile; its docs compare the two.
192///
193/// # Fork
194///
195/// A child created by `fork()` shares the parent's pre-key bytes (copy-on-write) but not
196/// its page locks, and [`lock_status`](Self::lock_status) says so in the child. A child that
197/// should not hold the parent's sealing material creates its own `Prekey` and re-seals what
198/// it keeps.
199///
200/// A child of a *multithreaded* parent must not use an inherited
201/// [idle no-access](Self::new_idle_noaccess) pre-key at all: a thread that was mid-access at
202/// the fork leaves the child its open window (the pre-key stays readable) or its held mutex
203/// (the child's first access deadlocks). POSIX allows such a child only async-signal-safe
204/// calls until it execs in any case.
205pub struct Prekey {
206    block: GuardedBlock<PREKEY_LEN>,
207}
208
209impl Prekey {
210    /// Generates a pre-key from the system RNG into guarded, read-only memory, and tries
211    /// to lock its pages in RAM.
212    ///
213    /// Returns an `Arc`, because every sealed object keeps one.
214    ///
215    /// # Panics
216    ///
217    /// Panics if the system RNG fails, as [`Fixed::from_random`] does, or if the operating
218    /// system refuses to map the pre-key or protect its pages (memory or address-space
219    /// exhaustion). A failure to lock is not a panic; see [`lock_status`](Self::lock_status).
220    pub fn new() -> Arc<Self> {
221        Arc::new(Self::create(false))
222    }
223
224    /// As [`new`](Self::new), and the pre-key is also no-access except while a sealing key
225    /// is being derived from it (about 6 µs per access). If the system refuses to make it
226    /// no-access again after an access, which happens only in exhaustion cases (out of
227    /// memory mappings), it stays readable until a later access closes it; nothing reports
228    /// that, and [`lock_status`](Self::lock_status) is about the lock only.
229    ///
230    /// What that adds: while idle, the pre-key cannot be read at its address at all. On
231    /// Windows that keeps it out of other processes' `ReadProcessMemory` and out of a
232    /// full-memory crash dump taken while it is idle, which a locked pre-key does not
233    /// (measured; the repository's `docs/design/sealed_memory.md` has the numbers).
234    ///
235    /// What it costs:
236    ///
237    /// - **On Windows, the lock.** A no-access page leaves the process's working set, and
238    ///   its `VirtualLock` is dropped (measured), so the pre-key may be written to the
239    ///   pagefile while idle. This mode therefore does not lock on Windows, and
240    ///   [`lock_status`](Self::lock_status) returns an error whose
241    ///   [`idle_noaccess`](LockError::idle_noaccess) is `true`. On Linux the lock is kept.
242    /// - **Time.** Measured at about 4 µs on top of an access's 6 µs hash in a quiet
243    ///   process, 9 µs with 16 busy threads. On Windows most of it is faulting the pre-key's
244    ///   pages back into the working set, which a no-access page leaves; on Linux it is the two
245    ///   protection changes, which interrupt every CPU running one of the process's threads.
246    /// - **A rare panic.** Making the pre-key readable for an access can fail where mapping
247    ///   it would have (the process is out of memory mappings); the access then panics.
248    ///
249    /// Choose it where crash dumps or same-user memory reads are a larger risk than the
250    /// pagefile, for example a Windows machine with an encrypted system drive that collects
251    /// full crash dumps. [`new`](Self::new) is the default because, without an encrypted
252    /// drive, a pre-key in the pagefile outlives the process.
253    ///
254    /// # Panics
255    ///
256    /// As [`new`](Self::new).
257    pub fn new_idle_noaccess() -> Arc<Self> {
258        Arc::new(Self::create(true))
259    }
260
261    fn create(idle: bool) -> Self {
262        let fill = |block: &mut [u8; PREKEY_LEN]| {
263            SysRng
264                .try_fill_bytes(block)
265                .expect("SysRng failure is a program error");
266        };
267        let block = if idle {
268            GuardedBlock::new_with_idle_noaccess(fill)
269        } else {
270            GuardedBlock::new_with(fill)
271        };
272        Self {
273            block: block.unwrap_or_else(|err| {
274                panic!("the sealed-memory pre-key could not be set up: {err}")
275            }),
276        }
277    }
278
279    /// `Ok(())` if the pre-key's pages are locked in RAM; otherwise why they are not.
280    pub fn lock_status(&self) -> Result<(), LockError> {
281        self.block.lock_status().map_err(LockError::from_guarded)
282    }
283
284    /// `true` if this pre-key was made with [`new_idle_noaccess`](Self::new_idle_noaccess).
285    pub fn is_idle_noaccess(&self) -> bool {
286        self.block.is_idle_noaccess()
287    }
288
289    /// `SHA-256(salt ‖ pre-key)`, written straight into a wrapper that wipes it on drop.
290    /// The pre-key is read only inside this call, which in idle no-access mode is its whole
291    /// readable window.
292    #[inline]
293    fn sealing_key(&self, salt: &[u8; SALT_LEN]) -> Fixed<[u8; 32]> {
294        #[cfg(test)]
295        tests::maybe_fail_derivation();
296        Fixed::new_with(|key: &mut [u8; 32]| {
297            self.block.with(|prekey| {
298                let mut hasher = Sha256::new();
299                hasher.update(salt);
300                hasher.update(prekey);
301                hasher.finalize_into(key.into());
302            });
303        })
304    }
305
306    #[inline]
307    fn cipher(&self, salt: &[u8; SALT_LEN]) -> ChaCha20Poly1305 {
308        self.sealing_key(salt)
309            .with_secret(|key| ChaCha20Poly1305::new(key.into()))
310    }
311
312    /// A fresh random salt, and so a fresh sealing key. Drawn before any plaintext is
313    /// copied into a ciphertext buffer, so an RNG failure cannot leave plaintext behind.
314    pub(crate) fn fresh_salt() -> [u8; SALT_LEN] {
315        let mut rng = SysRng;
316        let words: [u64; SALT_LEN / 8] = core::array::from_fn(|_| {
317            rng.try_next_u64()
318                .expect("SysRng failure is a program error")
319        });
320        core::array::from_fn(|i| words[i / 8].to_le_bytes()[i % 8])
321    }
322
323    /// Copies `plain` into `out` and encrypts it there under the key for `salt`; returns
324    /// the detached tag.
325    ///
326    /// The key is derived before `out` is written. Deriving it is the one step that can
327    /// fail (for an idle no-access pre-key, when it cannot be made readable), and a failure
328    /// after the copy would leave plaintext in `out`, a ciphertext buffer nothing wipes.
329    /// Use this, not a copy followed by [`seal_in_place`](Self::seal_in_place), wherever
330    /// `out` is not itself a wiping buffer.
331    pub(crate) fn seal_into(
332        &self,
333        salt: &[u8; SALT_LEN],
334        plain: &[u8],
335        out: &mut [u8],
336    ) -> [u8; TAG_LEN] {
337        let cipher = self.cipher(salt);
338        out.copy_from_slice(plain);
339        cipher
340            .encrypt_inout_detached(&Nonce::default(), &[], out.into())
341            .expect("ChaCha20-Poly1305 rejects only inputs over 256 GiB")
342            .into()
343    }
344
345    /// Encrypts `buf` in place under the key for `salt`; returns the detached tag. Only for
346    /// a `buf` that wipes itself if this panics; see [`seal_into`](Self::seal_into).
347    ///
348    /// The nonce is all-zero. That is sound only because every seal draws a new salt with
349    /// [`fresh_salt`](Self::fresh_salt) and so a new key: no key ever encrypts twice.
350    pub(crate) fn seal_in_place(&self, salt: &[u8; SALT_LEN], buf: &mut [u8]) -> [u8; TAG_LEN] {
351        self.cipher(salt)
352            .encrypt_inout_detached(&Nonce::default(), &[], buf.into())
353            .expect("ChaCha20-Poly1305 rejects only inputs over 256 GiB")
354            .into()
355    }
356
357    /// Decrypts `buf` in place. On a tag mismatch `buf` is wiped and this panics: sealed
358    /// memory that fails to authenticate was modified or corrupted, and nothing can be
359    /// recovered from it. (The tag is checked before any keystream is applied, so `buf`
360    /// still holds ciphertext then; it is wiped regardless.)
361    pub(crate) fn open_in_place(&self, salt: &[u8; SALT_LEN], tag: &[u8; TAG_LEN], buf: &mut [u8]) {
362        let opened = self.cipher(salt).decrypt_inout_detached(
363            &Nonce::default(),
364            &[],
365            buf.into(),
366            tag.into(),
367        );
368        if opened.is_err() {
369            buf.zeroize();
370            panic!("sealed memory was modified or corrupted");
371        }
372    }
373}
374
375/// Size of the stack region [`scrub_stack`] overwrites.
376const SCRUB_LEN: usize = 32 * 1024;
377
378/// Overwrites the stack region below its caller with zeros.
379///
380/// The ChaCha20 backend leaves its state block (constant, then the 32-byte sealing key) on
381/// the stack after a pass, and moves can leave a copy of a temporary plaintext. Neither is
382/// reachable for a wipe. Called at the end of every seal and open, from the frame that made
383/// the calls, this function's frame lies over the region those calls used and zeroes it.
384/// The technique is sequoia's `zero_stack`; `[u64]` keeps the volatile writes word-sized.
385///
386/// Best effort: it covers what the calls left in `SCRUB_LEN` bytes below the caller, not
387/// registers, and not copies a closure parks elsewhere.
388#[inline(never)]
389pub(crate) fn scrub_stack() {
390    let mut area = [0u64; SCRUB_LEN / 8];
391    area.zeroize();
392    core::hint::black_box(&area);
393}
394
395/// Runs `f`, then [`scrub_stack`], on every exit: a panic from `f` (the caller's closure, or
396/// a tag mismatch) is caught, the stack scrubbed, and the panic resumed. Without this an
397/// unwind skips the scrub and leaves the cipher's copy of the sealing key behind. A `Drop`
398/// guard is not a substitute: on Windows, unwinding runs cleanup below the frames that
399/// threw, so the guard's scrub would not lie over them. Under `panic = "abort"` there is
400/// no unwind to survive.
401#[inline(always)]
402pub(crate) fn scrubbed<R>(f: impl FnOnce() -> R) -> R {
403    let result = std::panic::catch_unwind(core::panic::AssertUnwindSafe(f));
404    scrub_stack();
405    match result {
406        Ok(value) => value,
407        Err(payload) => std::panic::resume_unwind(payload),
408    }
409}
410
411/// Always prints `[REDACTED]`.
412impl core::fmt::Debug for Prekey {
413    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
414        f.write_str("[REDACTED]")
415    }
416}
417
418#[cfg(test)]
419pub(crate) mod tests {
420    use super::*;
421
422    /// Every test in this crate's unit tests that creates a pre-key takes this first: they
423    /// share one per-process lock quota (about 11 pre-keys under Windows' default).
424    pub(crate) fn quota() -> std::sync::MutexGuard<'static, ()> {
425        static QUOTA: std::sync::Mutex<()> = std::sync::Mutex::new(());
426        QUOTA
427            .lock()
428            .unwrap_or_else(std::sync::PoisonError::into_inner)
429    }
430
431    std::thread_local! {
432        static FAIL_NEXT_DERIVATION: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
433    }
434
435    /// Makes this thread's next sealing-key derivation panic, as it does in idle no-access
436    /// mode when the pre-key cannot be made readable.
437    pub(crate) fn fail_next_derivation() {
438        FAIL_NEXT_DERIVATION.with(|f| f.set(true));
439    }
440
441    pub(super) fn maybe_fail_derivation() {
442        if FAIL_NEXT_DERIVATION.with(|f| f.replace(false)) {
443            panic!("injected: could not make the guarded block readable");
444        }
445    }
446
447    /// A pre-key holding `bytes`, in the same guarded memory as a real one.
448    fn with_bytes(bytes: &[u8; PREKEY_LEN]) -> Prekey {
449        Prekey {
450            block: GuardedBlock::new_with(|b| b.copy_from_slice(bytes)).unwrap(),
451        }
452    }
453
454    fn protection_at(addr: usize) -> region::Protection {
455        region::query(addr as *const u8).unwrap().protection()
456    }
457
458    #[test]
459    fn the_prekey_lies_between_noaccess_guard_pages_and_is_read_only() {
460        let _q = quota();
461        let prekey = Prekey::new();
462        let [below, block, above] = prekey.block.layout();
463        assert!(block.len() >= PREKEY_LEN);
464        assert_eq!(protection_at(below.start), region::Protection::NONE);
465        assert_eq!(protection_at(block.start), region::Protection::READ);
466        assert_eq!(protection_at(block.end - 1), region::Protection::READ);
467        assert_eq!(protection_at(above.start), region::Protection::NONE);
468        assert!(!prekey.is_idle_noaccess());
469    }
470
471    #[test]
472    fn an_idle_noaccess_prekey_is_noaccess_between_accesses() {
473        let _q = quota();
474        let prekey = Prekey::new_idle_noaccess();
475        assert!(prekey.is_idle_noaccess());
476        let block = prekey.block.layout()[1].clone();
477        assert_eq!(protection_at(block.start), region::Protection::NONE);
478        let salt = Prekey::fresh_salt();
479        let mut buf = *b"idle";
480        let tag = prekey.seal_in_place(&salt, &mut buf);
481        prekey.open_in_place(&salt, &tag, &mut buf);
482        assert_eq!(&buf, b"idle");
483        assert_eq!(protection_at(block.start), region::Protection::NONE);
484        if cfg!(windows) {
485            let err = prekey.lock_status().unwrap_err();
486            assert!(err.idle_noaccess());
487            assert_eq!(err.raw_os_error(), None);
488        }
489    }
490
491    #[test]
492    fn lock_errors_say_why_and_carry_no_address() {
493        let quota = LockError {
494            os_code: Some(1453),
495            idle_noaccess: false,
496            forked: false,
497        };
498        assert_eq!(quota.raw_os_error(), Some(1453));
499        assert!(!quota.idle_noaccess());
500        assert_eq!(
501            format!("{quota:?}"),
502            "LockError { os_code: Some(1453), idle_noaccess: false, forked: false }"
503        );
504        assert_eq!(
505            quota.to_string(),
506            "the sealed-memory pre-key could not be locked in RAM (OS error 1453)"
507        );
508        let bare = LockError {
509            os_code: None,
510            idle_noaccess: false,
511            forked: false,
512        };
513        assert_eq!(
514            bare.to_string(),
515            "the sealed-memory pre-key could not be locked in RAM"
516        );
517        let idle = LockError {
518            os_code: None,
519            idle_noaccess: true,
520            forked: false,
521        };
522        assert_eq!(
523            idle.to_string(),
524            "the sealed-memory pre-key is not locked in RAM: it is no-access while idle \
525             (Prekey::new_idle_noaccess), and on Windows a no-access page cannot stay locked"
526        );
527        let forked = LockError {
528            os_code: None,
529            idle_noaccess: false,
530            forked: true,
531        };
532        assert_eq!(
533            forked.to_string(),
534            "the sealed-memory pre-key is not locked in RAM in this process: it was made \
535             before a fork, and page locks are not inherited"
536        );
537    }
538
539    /// The derivation is the one rc.13 shipped, checked against a vector computed outside
540    /// Rust: Python's `hashlib.sha256(bytes([7]) * 32 + bytes(i % 251 for i in range(16384)))`.
541    #[test]
542    fn the_sealing_key_matches_an_independent_sha256_vector() {
543        let _q = quota();
544        let bytes: [u8; PREKEY_LEN] = core::array::from_fn(|i| (i % 251) as u8);
545        let prekey = with_bytes(&bytes);
546        let key = prekey.sealing_key(&[7u8; SALT_LEN]).with_secret(|k| *k);
547        let hex: alloc::string::String = key.iter().map(|b| alloc::format!("{b:02x}")).collect();
548        assert_eq!(
549            hex,
550            "5fdf15d9989af9c0c55f07f7ba974f74ac47ada2c2b21a6e0539eaebd833cd62"
551        );
552    }
553
554    #[test]
555    fn the_sealing_key_depends_on_every_prekey_bit() {
556        let _q = quota();
557        let bytes: [u8; PREKEY_LEN] = core::array::from_fn(|i| (i % 251) as u8);
558        let mut flipped = bytes;
559        flipped[PREKEY_LEN - 1] ^= 1;
560        let salt = Prekey::fresh_salt();
561        let a = with_bytes(&bytes).sealing_key(&salt).with_secret(|k| *k);
562        let b = with_bytes(&flipped).sealing_key(&salt).with_secret(|k| *k);
563        assert_ne!(a, b);
564    }
565
566    #[test]
567    fn the_sealing_key_is_sha256_of_salt_then_prekey() {
568        let _q = quota();
569        let prekey = Prekey::new();
570        let salt = Prekey::fresh_salt();
571        let expected: [u8; 32] = prekey.block.with(|pk| {
572            let mut hasher = Sha256::new();
573            hasher.update(salt);
574            hasher.update(pk);
575            hasher.finalize().into()
576        });
577        assert_eq!(prekey.sealing_key(&salt).with_secret(|k| *k), expected);
578    }
579
580    #[test]
581    fn every_salt_is_fresh() {
582        assert_ne!(Prekey::fresh_salt(), Prekey::fresh_salt());
583    }
584
585    #[test]
586    #[should_panic(expected = "sealed memory was modified or corrupted")]
587    fn a_wrong_tag_panics() {
588        let _q = quota();
589        let prekey = Prekey::new();
590        let salt = Prekey::fresh_salt();
591        let mut buf = [1u8; 8];
592        let mut tag = prekey.seal_in_place(&salt, &mut buf);
593        tag[0] ^= 1;
594        prekey.open_in_place(&salt, &tag, &mut buf);
595    }
596
597    #[test]
598    fn a_failed_open_wipes_the_buffer_before_panicking() {
599        let _q = quota();
600        let prekey = Prekey::new();
601        let salt = Prekey::fresh_salt();
602        let mut buf = [1u8; 8];
603        let tag = prekey.seal_in_place(&salt, &mut buf);
604        buf[0] ^= 1;
605        let result = std::panic::catch_unwind(core::panic::AssertUnwindSafe(|| {
606            prekey.open_in_place(&salt, &tag, &mut buf);
607        }));
608        assert!(result.is_err());
609        assert_eq!(buf, [0u8; 8]);
610    }
611
612    #[test]
613    fn debug_is_redacted() {
614        let _q = quota();
615        assert_eq!(format!("{:?}", Prekey::new()), "[REDACTED]");
616        assert_eq!(format!("{:?}", Prekey::new_idle_noaccess()), "[REDACTED]");
617    }
618}