Skip to main content

secure_gate/sealed/
mod.rs

1//! Secrets held **encrypted in RAM while idle**, opened for one closure call at a time.
2//! Requires the `sealed` feature.
3//!
4//! [`Fixed`](crate::Fixed) and [`Dynamic`](crate::Dynamic) hold their secret in plaintext
5//! for their whole lifetime and wipe it on drop. [`SealedFixed`] and [`SealedDynamic`] hold
6//! it as ChaCha20-Poly1305 ciphertext instead, and decrypt only into a temporary that
7//! exists for the length of a `with_secret` / `with_secret_mut` call. The design follows
8//! `sequoia-openpgp`'s `crypto::mem::Encrypted`, itself following OpenSSH.
9//!
10//! ```rust
11//! use secure_gate::{Fixed, Prekey, SealedFixed};
12//!
13//! // One per session; drop it, and everything sealed under it, at lock or logout.
14//! let prekey = Prekey::new();
15//! if let Err(why) = prekey.lock_status() {
16//!     eprintln!("note: {why}"); // not fatal
17//! }
18//!
19//! let mut key = SealedFixed::seal(Fixed::new([7u8; 32]), &prekey);
20//! key.with_secret(|k| assert_eq!(k[0], 7));
21//! key.with_secret_mut(|k| k[0] = 8); // re-sealed under a fresh salt
22//! let plain: Fixed<[u8; 32]> = key.unseal();
23//! ```
24//!
25//! # How it works
26//!
27//! - A [`Prekey`] is 16 KiB of random bytes, page-aligned and locked in RAM where the
28//!   operating system allows it. Each sealed object keeps an `Arc` to the one it was sealed
29//!   under.
30//! - Every seal draws a random 32-byte salt. The sealing key is `SHA-256(salt ‖ pre-key)`,
31//!   recomputed on every access and wiped as soon as the cipher is keyed. It is never
32//!   stored in the object. The cipher leaves a copy of it on the stack, so every seal and
33//!   open ends by overwriting the 32 KiB of stack below the call (sequoia's `zero_stack`
34//!   technique), on a panic as well as a normal return, which also removes plaintext a move
35//!   left there. Measured on x86_64 Windows:
36//!   `docs/design/receipts/sealed-stack-residue/` in the repository.
37//! - Because every seal has its own key, the AEAD nonce is all-zero. Every re-seal after
38//!   [`with_secret_mut`](SealedFixed::with_secret_mut) draws a new salt, so no key ever
39//!   encrypts twice.
40//!
41//! # What it protects against
42//!
43//! **Lossy readout** of process memory: Spectre-class side channels, Rowhammer, cold boot.
44//! These recover memory with bit errors. A 32-byte key read with a few flipped bits is
45//! still recoverable by search; a sealing key that depends on all 131,072 bits of the
46//! pre-key is not, because one wrong bit anywhere avalanches through SHA-256.
47//!
48//! With the pre-key locked, a sealed secret that reaches the **swap file** cannot be
49//! opened from it, because the pre-key never gets there.
50//!
51//! # What it does not protect against
52//!
53//! - A **clean** read of the address space: a debugger, `/proc/<pid>/mem`, an attacker who
54//!   can run code in the process. That read gets pre-key, salt and ciphertext together.
55//! - **Hibernation** files and **crash or core dumps**, which capture locked pages too.
56//! - A failed lock ([`Prekey::lock_status`] says so): the pre-key can then be swapped out
57//!   like any other memory.
58//! - The plaintext temporary while a closure runs. It lives on the stack
59//!   ([`SealedFixed`]) or the heap ([`SealedDynamic`]) and is not locked; it is wiped, and
60//!   the stack below the call overwritten, when the call returns. Copies the closure makes
61//!   are the caller's to wipe.
62//! - Registers, and stack residue beyond the overwritten 32 KiB. The overwrite was measured
63//!   on one target; another target's cipher backend or frame layout may differ.
64//! - Pages larger than the pre-key block's alignment (16 KiB on x86, x86_64 and Apple
65//!   aarch64; 64 KiB elsewhere): the pre-key would share its page, so it is not locked at
66//!   all, and [`Prekey::lock_status`] says so. No known target's default page is larger.
67//! - What [`unseal`](SealedFixed::unseal) returns: an ordinary `Fixed`, whose by-value
68//!   return can leave a copy in the caller's frame (`SECURITY.md` §1). Read through
69//!   `with_secret` instead where you can.
70//!
71//! It narrows one threat. It is not a substitute for encrypted swap, disabled core dumps,
72//! or anything else in `SECURITY.md`.
73//!
74//! # Cost
75//!
76//! Each access needs about 32 KiB of free stack for the scrub, on top of the cipher's own
77//! frames: mind small custom thread stacks. Measured on x86_64 Windows: an access on a
78//! 64 KiB thread stack completes in a release build and overflows in a debug build; 96 KiB
79//! completes in both. [`Prekey::new`] in an unoptimized build needs more: a few times the
80//! pre-key block's size (about 192 KiB where the block is 64 KiB).
81//!
82//! Not zero-overhead. Each access hashes 16 KiB and runs one AEAD pass; each mutation adds
83//! an RNG call and a second pass. The repository's `sealed_vs_fixed` benchmark measures it.
84//!
85//! # Not implemented, on purpose
86//!
87//! - `expose_secret` / [`RevealSecret`](crate::RevealSecret): plaintext must not outlive
88//!   the call. Use [`RevealScoped`](crate::scoped::RevealScoped) for generic scoped access.
89//! - `Hash`: it would feed the plaintext into a hasher whose state is never wiped.
90//! - `Debug` beyond `[REDACTED]`: no salt, no tag, no length.
91
92mod dynamic;
93mod fixed;
94mod prekey;
95
96pub use dynamic::{SealedDynamic, SealedPayload};
97pub use fixed::SealedFixed;
98pub use prekey::{LockError, Prekey};
99
100/// What the sealed types must not offer, checked by compiling each misuse.
101///
102/// No `expose_secret`, so no reference outlives the call:
103///
104/// ```compile_fail,E0599
105/// use secure_gate::{Fixed, Prekey, SealedFixed};
106/// let s = SealedFixed::seal(Fixed::new([0u8; 32]), &Prekey::new());
107/// let _ = s.expose_secret();
108/// ```
109///
110/// No `RevealSecret` impl to reach it through either:
111///
112/// ```compile_fail,E0277
113/// use secure_gate::{Fixed, Prekey, RevealSecret, SealedFixed};
114/// fn needs<S: RevealSecret>(_: &S) {}
115/// needs(&SealedFixed::seal(Fixed::new([0u8; 32]), &Prekey::new()));
116/// ```
117///
118/// No `Hash`, which would feed the plaintext to a hasher that is never wiped:
119///
120/// ```compile_fail,E0277
121/// use secure_gate::{Fixed, Prekey, SealedFixed};
122/// fn needs<H: core::hash::Hash>(_: &H) {}
123/// needs(&SealedFixed::seal(Fixed::new([0u8; 32]), &Prekey::new()));
124/// ```
125///
126/// `with_secret_mut` on a sealed `Vec` hands out a fixed-length slice, so it cannot grow:
127///
128/// ```compile_fail,E0599
129/// use secure_gate::{Dynamic, Prekey, SealedDynamic};
130/// let mut s = SealedDynamic::seal(Dynamic::<Vec<u8>>::new(vec![1]), &Prekey::new());
131/// s.with_secret_mut(|v| v.push(2));
132/// ```
133///
134/// Nor can a sealed `String`:
135///
136/// ```compile_fail,E0599
137/// use secure_gate::{Dynamic, Prekey, SealedDynamic};
138/// let mut s = SealedDynamic::seal(Dynamic::<String>::new(String::from("a")), &Prekey::new());
139/// s.with_secret_mut(|v| v.push_str("b"));
140/// ```
141///
142/// A borrow from `with_secret` cannot escape the closure:
143///
144/// ```compile_fail
145/// use secure_gate::{Fixed, Prekey, SealedFixed};
146/// let s = SealedFixed::seal(Fixed::new([0u8; 32]), &Prekey::new());
147/// let leaked: &[u8; 32] = s.with_secret(|b| b);
148/// ```
149///
150/// Not `Clone`, even with the `cloneable` feature: like `Fixed<[u8; N]>`, a sealed byte
151/// secret is not copied implicitly.
152///
153/// ```compile_fail,E0277
154/// use secure_gate::{Fixed, Prekey, SealedFixed};
155/// fn needs<C: Clone>(_: &C) {}
156/// needs(&SealedFixed::seal(Fixed::new([0u8; 32]), &Prekey::new()));
157/// ```
158///
159/// Only `Vec<u8>` and `String` can be sealed as variable-length secrets:
160///
161/// ```compile_fail,E0277
162/// use secure_gate::{Dynamic, Prekey, SealedDynamic};
163/// let _ = SealedDynamic::seal(Dynamic::<Vec<u16>>::new(vec![1]), &Prekey::new());
164/// ```
165#[cfg(doctest)]
166pub struct MisuseDoesNotCompile;