Skip to main content

secure_gate/sealed/
fixed.rs

1use alloc::sync::Arc;
2
3use super::prekey::{Prekey, SALT_LEN, TAG_LEN, scrubbed};
4use crate::{Fixed, RevealSecret, RevealSecretMut};
5
6/// A fixed-size byte secret held encrypted in RAM while idle, opened for one closure call
7/// at a time. The sealed counterpart of [`Fixed<[u8; N]>`](Fixed). Requires the `sealed`
8/// feature.
9///
10/// The ciphertext, tag and salt sit inline and are not secret. Each access derives the
11/// sealing key from the [`Prekey`], decrypts in place into a stack [`Fixed<[u8; N]>`](Fixed),
12/// hands the closure a reference, and lets that wrapper's `Drop` wipe it. Nothing is
13/// allocated per access.
14///
15/// There is no `expose_secret`, and the type does not implement [`RevealSecret`]: a
16/// reference that outlives the call would need plaintext that outlives it too, which is
17/// what sealing exists to prevent. Generic code that only needs scoped access can take
18/// [`RevealScoped`](crate::scoped::RevealScoped), which this type and every
19/// `RevealSecret` type implement.
20///
21/// ```rust
22/// use secure_gate::{Fixed, Prekey, SealedFixed};
23///
24/// let prekey = Prekey::new();
25/// let key = SealedFixed::seal(Fixed::new([0xAB; 32]), &prekey);
26/// key.with_secret(|bytes| assert_eq!(bytes[0], 0xAB));
27/// assert_eq!(format!("{key:?}"), "[REDACTED]");
28/// ```
29///
30/// # Panics
31///
32/// Every access panics if the sealed bytes fail to authenticate: they were modified or
33/// corrupted in memory and cannot be recovered. The temporary is wiped and the stack below
34/// the call scrubbed before the panic continues.
35#[must_use = "holds secret material; bind it or chain a method call"]
36pub struct SealedFixed<const N: usize> {
37    ct: [u8; N],
38    tag: [u8; TAG_LEN],
39    salt: [u8; SALT_LEN],
40    prekey: Arc<Prekey>,
41}
42
43impl<const N: usize> SealedFixed<N> {
44    /// Seals `secret` under `prekey`. The plain wrapper is consumed and wiped on drop.
45    pub fn seal(secret: Fixed<[u8; N]>, prekey: &Arc<Prekey>) -> Self {
46        let salt = Prekey::fresh_salt();
47        let mut ct = [0u8; N];
48        let tag = scrubbed(|| secret.with_secret(|plain| prekey.seal_into(&salt, plain, &mut ct)));
49        Self {
50            ct,
51            tag,
52            salt,
53            prekey: Arc::clone(prekey),
54        }
55    }
56
57    /// Generates the secret from the system RNG and seals it.
58    ///
59    /// # Panics
60    ///
61    /// Panics if the system RNG fails, as [`Fixed::from_random`] does.
62    pub fn from_random(prekey: &Arc<Prekey>) -> Self {
63        Self::seal(Fixed::from_random(), prekey)
64    }
65
66    /// Decrypts into a temporary for the length of `f`, then wipes the temporary.
67    #[inline]
68    pub fn with_secret<F, R>(&self, f: F) -> R
69    where
70        F: FnOnce(&[u8; N]) -> R,
71    {
72        scrubbed(|| self.open_and_call(f))
73    }
74
75    // The plaintext temporary lives in this frame, below the caller's, so the
76    // `scrub_stack` that follows lies over it and over anything a move left there. It is
77    // built as zeros and decrypted in place: a `Fixed` returned by value would be moved,
78    // leaving an unwiped copy in the slot it left.
79    #[inline(never)]
80    fn open_and_call<F, R>(&self, f: F) -> R
81    where
82        F: FnOnce(&[u8; N]) -> R,
83    {
84        let mut plain = Fixed::new([0u8; N]);
85        plain.with_secret_mut(|buf| self.open_into(buf));
86        plain.with_secret(f)
87    }
88
89    /// Decrypts into a temporary, lets `f` change it, then seals the result under a fresh
90    /// salt and wipes the temporary.
91    ///
92    /// If `f` panics, the temporary is still wiped, the stack scrubbed, and the sealed value
93    /// left as it was.
94    pub fn with_secret_mut<F, R>(&mut self, f: F) -> R
95    where
96        F: FnOnce(&mut [u8; N]) -> R,
97    {
98        scrubbed(|| self.open_mutate_reseal(f))
99    }
100
101    // As `open_and_call`: the temporary lives in this frame so the scrub reaches it.
102    #[inline(never)]
103    fn open_mutate_reseal<F, R>(&mut self, f: F) -> R
104    where
105        F: FnOnce(&mut [u8; N]) -> R,
106    {
107        let mut plain = Fixed::new([0u8; N]);
108        plain.with_secret_mut(|buf| self.open_into(buf));
109        let result = plain.with_secret_mut(f);
110        let salt = Prekey::fresh_salt();
111        self.tag = plain.with_secret(|p| self.prekey.seal_into(&salt, p, &mut self.ct));
112        self.salt = salt;
113        result
114    }
115
116    /// Opens into a temporary, hands it to `wrap` (a `fixed_newtype!` role's constructor)
117    /// and `f` gets the wrapped value; scrubbed like [`with_secret`](Self::with_secret).
118    /// The plumbing behind `sealed_fixed_newtype!`'s `with_role`; not a public API.
119    #[doc(hidden)]
120    #[inline]
121    pub fn __with_wrapped<T, W, F, R>(&self, wrap: W, f: F) -> R
122    where
123        W: FnOnce(Fixed<[u8; N]>) -> T,
124        F: FnOnce(&T) -> R,
125    {
126        scrubbed(|| self.open_wrap_call(wrap, f))
127    }
128
129    // The move into `wrap` copies the plaintext within this frame, which the scrub covers.
130    #[inline(never)]
131    fn open_wrap_call<T, W, F, R>(&self, wrap: W, f: F) -> R
132    where
133        W: FnOnce(Fixed<[u8; N]>) -> T,
134        F: FnOnce(&T) -> R,
135    {
136        let mut plain = Fixed::new([0u8; N]);
137        plain.with_secret_mut(|buf| self.open_into(buf));
138        let wrapped = wrap(plain);
139        f(&wrapped)
140    }
141
142    /// As [`__with_wrapped`](Self::__with_wrapped), mutably; `unwrap` gives the `Fixed` back
143    /// to re-seal under a fresh salt. Not a public API.
144    #[doc(hidden)]
145    #[inline]
146    pub fn __with_wrapped_mut<T, W, U, F, R>(&mut self, wrap: W, unwrap: U, f: F) -> R
147    where
148        W: FnOnce(Fixed<[u8; N]>) -> T,
149        U: FnOnce(T) -> Fixed<[u8; N]>,
150        F: FnOnce(&mut T) -> R,
151    {
152        scrubbed(|| self.open_wrap_mutate_reseal(wrap, unwrap, f))
153    }
154
155    #[inline(never)]
156    fn open_wrap_mutate_reseal<T, W, U, F, R>(&mut self, wrap: W, unwrap: U, f: F) -> R
157    where
158        W: FnOnce(Fixed<[u8; N]>) -> T,
159        U: FnOnce(T) -> Fixed<[u8; N]>,
160        F: FnOnce(&mut T) -> R,
161    {
162        let mut plain = Fixed::new([0u8; N]);
163        plain.with_secret_mut(|buf| self.open_into(buf));
164        let mut wrapped = wrap(plain);
165        let result = f(&mut wrapped);
166        let plain = unwrap(wrapped);
167        let salt = Prekey::fresh_salt();
168        self.tag = plain.with_secret(|p| self.prekey.seal_into(&salt, p, &mut self.ct));
169        self.salt = salt;
170        result
171    }
172
173    /// Opens the secret back into a plain [`Fixed`]. Protection from sealing ends here;
174    /// the returned wrapper still wipes on drop, and is an ordinary `Fixed` from then on,
175    /// moves included: returning it by value can leave a copy in the caller's frame that
176    /// nothing wipes (`SECURITY.md` §1; one was measured in a release build). To read the
177    /// secret, prefer [`with_secret`](Self::with_secret), which leaves nothing.
178    //
179    // Inlined, so `plain` is built in the caller's frame and opened in place through a
180    // reference: a `Fixed` built in a callee's frame and returned would leave a copy there
181    // that nothing wipes. The opening itself runs below, inside the scrubbed region.
182    #[inline(always)]
183    pub fn unseal(self) -> Fixed<[u8; N]> {
184        let mut plain = Fixed::new([0u8; N]);
185        scrubbed(|| plain.with_secret_mut(|buf| self.open_into_out_of_line(buf)));
186        plain
187    }
188
189    #[inline(never)]
190    fn open_into_out_of_line(&self, buf: &mut [u8; N]) {
191        self.open_into(buf);
192    }
193
194    /// The secret's length in bytes, `N`.
195    #[inline(always)]
196    pub const fn len(&self) -> usize {
197        N
198    }
199
200    /// `N == 0`. A zero-length `Fixed` does not compile, so this is `false` for every
201    /// `SealedFixed` that can exist; it is here for symmetry with `len`.
202    #[inline(always)]
203    pub const fn is_empty(&self) -> bool {
204        N == 0
205    }
206
207    #[inline(always)]
208    fn open_into(&self, buf: &mut [u8; N]) {
209        buf.copy_from_slice(&self.ct);
210        self.prekey.open_in_place(&self.salt, &self.tag, buf);
211    }
212}
213
214impl<const N: usize> crate::scoped::RevealScoped for SealedFixed<N> {
215    type Inner = [u8; N];
216
217    #[inline]
218    fn with_secret<F, R>(&self, f: F) -> R
219    where
220        F: FnOnce(&[u8; N]) -> R,
221    {
222        SealedFixed::with_secret(self, f)
223    }
224}
225
226/// Constant-time equality: opens both sides and compares the plaintext with `subtle`.
227/// Comparing ciphertexts could not work, since every seal has its own salt.
228#[cfg(feature = "ct-eq")]
229impl<const N: usize> crate::ConstantTimeEq for SealedFixed<N> {
230    fn ct_eq(&self, other: &Self) -> bool {
231        self.with_secret(|a| other.with_secret(|b| crate::ConstantTimeEq::ct_eq(a, b)))
232    }
233}
234
235/// Always prints `[REDACTED]`: no salt, no tag, no length.
236impl<const N: usize> core::fmt::Debug for SealedFixed<N> {
237    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
238        f.write_str("[REDACTED]")
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::sealed::prekey::tests::quota;
246
247    /// A re-seal whose key derivation fails (an idle no-access pre-key that cannot be made
248    /// readable) must leave the old ciphertext in place, never the new plaintext.
249    #[test]
250    fn a_failed_reseal_leaves_ciphertext_not_plaintext() {
251        let _q = quota();
252        let prekey = Prekey::new();
253        let mut s = SealedFixed::seal(Fixed::new([0x42u8; 32]), &prekey);
254        let before = s.ct;
255        let r = std::panic::catch_unwind(core::panic::AssertUnwindSafe(|| {
256            s.with_secret_mut(|b| {
257                *b = [0x99u8; 32];
258                crate::sealed::prekey::tests::fail_next_derivation();
259            });
260        }));
261        assert!(r.is_err());
262        assert_ne!(
263            s.ct, [0x99u8; 32],
264            "the new plaintext was left in the ciphertext"
265        );
266        assert_eq!(s.ct, before);
267        s.with_secret(|b| assert_eq!(b, &[0x42u8; 32]));
268    }
269
270    #[test]
271    fn the_ciphertext_is_not_the_plaintext_and_differs_per_seal() {
272        let _q = quota();
273        let prekey = Prekey::new();
274        let a = SealedFixed::seal(Fixed::new([0x42u8; 32]), &prekey);
275        let b = SealedFixed::seal(Fixed::new([0x42u8; 32]), &prekey);
276        assert_ne!(a.ct, [0x42u8; 32]);
277        assert_ne!(a.salt, b.salt);
278        assert_ne!(a.ct, b.ct);
279    }
280
281    #[test]
282    fn with_secret_mut_reseals_under_a_fresh_salt() {
283        let _q = quota();
284        let prekey = Prekey::new();
285        let mut s = SealedFixed::seal(Fixed::new([1u8; 16]), &prekey);
286        let (salt, ct) = (s.salt, s.ct);
287        s.with_secret_mut(|_| {});
288        assert_ne!(s.salt, salt);
289        assert_ne!(s.ct, ct);
290        s.with_secret(|p| assert_eq!(p, &[1u8; 16]));
291    }
292
293    #[test]
294    #[should_panic(expected = "sealed memory was modified or corrupted")]
295    fn a_flipped_ciphertext_bit_panics() {
296        let _q = quota();
297        let prekey = Prekey::new();
298        let mut s = SealedFixed::seal(Fixed::new([1u8; 32]), &prekey);
299        s.ct[3] ^= 0x10;
300        s.with_secret(|_| ());
301    }
302
303    #[test]
304    #[should_panic(expected = "sealed memory was modified or corrupted")]
305    fn a_flipped_salt_bit_panics() {
306        let _q = quota();
307        let prekey = Prekey::new();
308        let mut s = SealedFixed::seal(Fixed::new([1u8; 32]), &prekey);
309        s.salt[31] ^= 1;
310        let _ = s.unseal();
311    }
312}