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        secret.with_secret(|plain| ct.copy_from_slice(plain));
49        let tag = scrubbed(|| prekey.seal_in_place(&salt, &mut ct));
50        Self {
51            ct,
52            tag,
53            salt,
54            prekey: Arc::clone(prekey),
55        }
56    }
57
58    /// Generates the secret from the system RNG and seals it.
59    ///
60    /// # Panics
61    ///
62    /// Panics if the system RNG fails, as [`Fixed::from_random`] does.
63    pub fn from_random(prekey: &Arc<Prekey>) -> Self {
64        Self::seal(Fixed::from_random(), prekey)
65    }
66
67    /// Decrypts into a temporary for the length of `f`, then wipes the temporary.
68    #[inline]
69    pub fn with_secret<F, R>(&self, f: F) -> R
70    where
71        F: FnOnce(&[u8; N]) -> R,
72    {
73        scrubbed(|| self.open_and_call(f))
74    }
75
76    // The plaintext temporary lives in this frame, below the caller's, so the
77    // `scrub_stack` that follows lies over it and over anything a move left there. It is
78    // built as zeros and decrypted in place: a `Fixed` returned by value would be moved,
79    // leaving an unwiped copy in the slot it left.
80    #[inline(never)]
81    fn open_and_call<F, R>(&self, f: F) -> R
82    where
83        F: FnOnce(&[u8; N]) -> R,
84    {
85        let mut plain = Fixed::new([0u8; N]);
86        plain.with_secret_mut(|buf| self.open_into(buf));
87        plain.with_secret(f)
88    }
89
90    /// Decrypts into a temporary, lets `f` change it, then seals the result under a fresh
91    /// salt and wipes the temporary.
92    ///
93    /// If `f` panics, the temporary is still wiped, the stack scrubbed, and the sealed value
94    /// left as it was.
95    pub fn with_secret_mut<F, R>(&mut self, f: F) -> R
96    where
97        F: FnOnce(&mut [u8; N]) -> R,
98    {
99        scrubbed(|| self.open_mutate_reseal(f))
100    }
101
102    // As `open_and_call`: the temporary lives in this frame so the scrub reaches it.
103    #[inline(never)]
104    fn open_mutate_reseal<F, R>(&mut self, f: F) -> R
105    where
106        F: FnOnce(&mut [u8; N]) -> R,
107    {
108        let mut plain = Fixed::new([0u8; N]);
109        plain.with_secret_mut(|buf| self.open_into(buf));
110        let result = plain.with_secret_mut(f);
111        let salt = Prekey::fresh_salt();
112        plain.with_secret(|p| self.ct.copy_from_slice(p));
113        self.tag = self.prekey.seal_in_place(&salt, &mut self.ct);
114        self.salt = salt;
115        result
116    }
117
118    /// Opens into a temporary, hands it to `wrap` (a `fixed_newtype!` role's constructor)
119    /// and `f` gets the wrapped value; scrubbed like [`with_secret`](Self::with_secret).
120    /// The plumbing behind `sealed_fixed_newtype!`'s `with_role`; not a public API.
121    #[doc(hidden)]
122    #[inline]
123    pub fn __with_wrapped<T, W, F, R>(&self, wrap: W, f: F) -> R
124    where
125        W: FnOnce(Fixed<[u8; N]>) -> T,
126        F: FnOnce(&T) -> R,
127    {
128        scrubbed(|| self.open_wrap_call(wrap, f))
129    }
130
131    // The move into `wrap` copies the plaintext within this frame, which the scrub covers.
132    #[inline(never)]
133    fn open_wrap_call<T, W, F, R>(&self, wrap: W, f: F) -> R
134    where
135        W: FnOnce(Fixed<[u8; N]>) -> T,
136        F: FnOnce(&T) -> R,
137    {
138        let mut plain = Fixed::new([0u8; N]);
139        plain.with_secret_mut(|buf| self.open_into(buf));
140        let wrapped = wrap(plain);
141        f(&wrapped)
142    }
143
144    /// As [`__with_wrapped`](Self::__with_wrapped), mutably; `unwrap` gives the `Fixed` back
145    /// to re-seal under a fresh salt. Not a public API.
146    #[doc(hidden)]
147    #[inline]
148    pub fn __with_wrapped_mut<T, W, U, F, R>(&mut self, wrap: W, unwrap: U, f: F) -> R
149    where
150        W: FnOnce(Fixed<[u8; N]>) -> T,
151        U: FnOnce(T) -> Fixed<[u8; N]>,
152        F: FnOnce(&mut T) -> R,
153    {
154        scrubbed(|| self.open_wrap_mutate_reseal(wrap, unwrap, f))
155    }
156
157    #[inline(never)]
158    fn open_wrap_mutate_reseal<T, W, U, F, R>(&mut self, wrap: W, unwrap: U, f: F) -> R
159    where
160        W: FnOnce(Fixed<[u8; N]>) -> T,
161        U: FnOnce(T) -> Fixed<[u8; N]>,
162        F: FnOnce(&mut T) -> R,
163    {
164        let mut plain = Fixed::new([0u8; N]);
165        plain.with_secret_mut(|buf| self.open_into(buf));
166        let mut wrapped = wrap(plain);
167        let result = f(&mut wrapped);
168        let plain = unwrap(wrapped);
169        let salt = Prekey::fresh_salt();
170        plain.with_secret(|p| self.ct.copy_from_slice(p));
171        self.tag = self.prekey.seal_in_place(&salt, &mut self.ct);
172        self.salt = salt;
173        result
174    }
175
176    /// Opens the secret back into a plain [`Fixed`]. Protection from sealing ends here;
177    /// the returned wrapper still wipes on drop, and is an ordinary `Fixed` from then on,
178    /// moves included: returning it by value can leave a copy in the caller's frame that
179    /// nothing wipes (`SECURITY.md` §1; one was measured in a release build). To read the
180    /// secret, prefer [`with_secret`](Self::with_secret), which leaves nothing.
181    //
182    // Inlined, so `plain` is built in the caller's frame and opened in place through a
183    // reference: a `Fixed` built in a callee's frame and returned would leave a copy there
184    // that nothing wipes. The opening itself runs below, inside the scrubbed region.
185    #[inline(always)]
186    pub fn unseal(self) -> Fixed<[u8; N]> {
187        let mut plain = Fixed::new([0u8; N]);
188        scrubbed(|| plain.with_secret_mut(|buf| self.open_into_out_of_line(buf)));
189        plain
190    }
191
192    #[inline(never)]
193    fn open_into_out_of_line(&self, buf: &mut [u8; N]) {
194        self.open_into(buf);
195    }
196
197    /// The secret's length in bytes, `N`.
198    #[inline(always)]
199    pub const fn len(&self) -> usize {
200        N
201    }
202
203    /// `N == 0`. A zero-length `Fixed` does not compile, so this is `false` for every
204    /// `SealedFixed` that can exist; it is here for symmetry with `len`.
205    #[inline(always)]
206    pub const fn is_empty(&self) -> bool {
207        N == 0
208    }
209
210    #[inline(always)]
211    fn open_into(&self, buf: &mut [u8; N]) {
212        buf.copy_from_slice(&self.ct);
213        self.prekey.open_in_place(&self.salt, &self.tag, buf);
214    }
215}
216
217impl<const N: usize> crate::scoped::RevealScoped for SealedFixed<N> {
218    type Inner = [u8; N];
219
220    #[inline]
221    fn with_secret<F, R>(&self, f: F) -> R
222    where
223        F: FnOnce(&[u8; N]) -> R,
224    {
225        SealedFixed::with_secret(self, f)
226    }
227}
228
229/// Constant-time equality: opens both sides and compares the plaintext with `subtle`.
230/// Comparing ciphertexts could not work, since every seal has its own salt.
231#[cfg(feature = "ct-eq")]
232impl<const N: usize> crate::ConstantTimeEq for SealedFixed<N> {
233    fn ct_eq(&self, other: &Self) -> bool {
234        self.with_secret(|a| other.with_secret(|b| crate::ConstantTimeEq::ct_eq(a, b)))
235    }
236}
237
238/// Always prints `[REDACTED]`: no salt, no tag, no length.
239impl<const N: usize> core::fmt::Debug for SealedFixed<N> {
240    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
241        f.write_str("[REDACTED]")
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::sealed::prekey::tests::quota;
249
250    #[test]
251    fn the_ciphertext_is_not_the_plaintext_and_differs_per_seal() {
252        let _q = quota();
253        let prekey = Prekey::new();
254        let a = SealedFixed::seal(Fixed::new([0x42u8; 32]), &prekey);
255        let b = SealedFixed::seal(Fixed::new([0x42u8; 32]), &prekey);
256        assert_ne!(a.ct, [0x42u8; 32]);
257        assert_ne!(a.salt, b.salt);
258        assert_ne!(a.ct, b.ct);
259    }
260
261    #[test]
262    fn with_secret_mut_reseals_under_a_fresh_salt() {
263        let _q = quota();
264        let prekey = Prekey::new();
265        let mut s = SealedFixed::seal(Fixed::new([1u8; 16]), &prekey);
266        let (salt, ct) = (s.salt, s.ct);
267        s.with_secret_mut(|_| {});
268        assert_ne!(s.salt, salt);
269        assert_ne!(s.ct, ct);
270        s.with_secret(|p| assert_eq!(p, &[1u8; 16]));
271    }
272
273    #[test]
274    #[should_panic(expected = "sealed memory was modified or corrupted")]
275    fn a_flipped_ciphertext_bit_panics() {
276        let _q = quota();
277        let prekey = Prekey::new();
278        let mut s = SealedFixed::seal(Fixed::new([1u8; 32]), &prekey);
279        s.ct[3] ^= 0x10;
280        s.with_secret(|_| ());
281    }
282
283    #[test]
284    #[should_panic(expected = "sealed memory was modified or corrupted")]
285    fn a_flipped_salt_bit_panics() {
286        let _q = quota();
287        let prekey = Prekey::new();
288        let mut s = SealedFixed::seal(Fixed::new([1u8; 32]), &prekey);
289        s.salt[31] ^= 1;
290        let _ = s.unseal();
291    }
292}