Skip to main content

siegel/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::marker::PhantomData;
4use zeroize::{Zeroize, ZeroizeOnDrop};
5
6mod protected;
7pub use protected::{ProtectedRegion, ProtectionError};
8
9/// Container for a single secret in protected memory. Type safe.
10///
11/// A Siegel is structured so a secret can only be "used" (i.e. read)
12/// once. This is to direct the loading of sensitive secrets into memory
13/// only for the time they're required. A common path for this is:
14/// 1. Load a secret from the keychain
15/// 2. Store it in a [`Siegel`].
16/// 3. Use it to sign an operation.
17///
18/// [`Siegel::new`] initializes a new empty `Siegel`. A secret can be
19/// filled via [`Siegel::write`], and subsequently used with [`Siegel::read_once`].
20pub struct Siegel<State> {
21    region: ProtectedRegion,
22    _state: PhantomData<State>,
23}
24
25/// Marker for a freshly-allocated siegel that has not yet been filled.
26pub struct Empty;
27
28/// Marker for a siegel that holds bytes and is ready for one-shot use.
29pub struct Loaded;
30
31impl Siegel<Empty> {
32    /// Allocate a new siegel for a specific `len`.
33    ///
34    /// Call [`write`](Self::write) to fill it.
35    ///
36    /// # Errors
37    /// - `SiegelError::InvalidLength` (on well, invalid length)
38    /// - Allocation / protection / lock errors if the OS calls fail.
39    pub fn new(len: usize) -> Result<Self, SiegelError> {
40        let region = ProtectedRegion::new(len)?;
41        Ok(Self {
42            region,
43            _state: PhantomData,
44        })
45    }
46
47    /// Copy `bytes` into the protected region and seal. This method
48    /// consumes the `Siegel` and returns a "Loaded" Siegel.
49    ///
50    /// # Errors
51    ///
52    /// `SiegelError::LengthMismatch` if `bytes.len()` doesn't equal the
53    /// siegel's capacity. Protection errors propagate.
54    pub fn write(mut self, bytes: &[u8]) -> Result<Siegel<Loaded>, SiegelError> {
55        let expected = self.region.len();
56        if bytes.len() != expected {
57            return Err(SiegelError::LengthMismatch {
58                expected,
59                got: bytes.len(),
60            });
61        }
62        self.region.with_write(|buf| buf.copy_from_slice(bytes))?;
63        Ok(Siegel {
64            region: self.region,
65            _state: PhantomData,
66        })
67    }
68}
69
70impl Siegel<Loaded> {
71    /// Unseal, run `f` with `&[u8]` of the secret bytes and then drop.
72    ///
73    /// The closure runs while the region is briefly `PROT_READ`. The
74    /// returned `T` is whatever the closure produces.
75    ///
76    /// The caller is responsible for the secrecy of any derived value.
77    ///
78    /// Consumes `self`: the siegel cannot be used again after this call.
79    ///
80    /// # Errors
81    /// - Protection / canary errors.
82    pub fn read_once<T, F>(mut self, f: F) -> Result<T, SiegelError>
83    where
84        F: FnOnce(&[u8]) -> T,
85    {
86        let result = self.region.with_read(f)?;
87        Ok(result)
88        // `ProtectedRegion` drops and gets zeroized.
89    }
90}
91
92impl<State> Siegel<State> {
93    /// Drop the siegel without using it. Zeroizes via `Drop`. 🪄
94    pub fn obliviate(self) {
95        drop(self);
96    }
97
98    /// Capacity of the siegel in bytes.
99    #[must_use]
100    #[expect(clippy::len_without_is_empty, reason = "always non-empty")]
101    pub fn len(&self) -> usize {
102        self.region.len()
103    }
104}
105
106impl<State> Zeroize for Siegel<State> {
107    fn zeroize(&mut self) {
108        self.region.zeroize();
109    }
110}
111
112impl<State> ZeroizeOnDrop for Siegel<State> {}
113
114/// Errors with `Siegel` operations.
115#[derive(Debug, thiserror::Error)]
116pub enum SiegelError {
117    #[error("requested size must be 1..=1Mb")]
118    InvalidLength,
119    #[error("input length {got} doesn't match siegel capacity {expected}")]
120    LengthMismatch { expected: usize, got: usize },
121    #[error("memory allocation failed: {reason}")]
122    AllocationFailed { reason: String },
123    #[error("memory protection failed: {reason}")]
124    ProtectionFailed { reason: String },
125    #[error("memory lock failed: {reason}")]
126    LockFailed { reason: String },
127    #[error("canary check failed — possible memory corruption")]
128    CanaryCorrupted,
129}
130
131impl From<ProtectionError> for SiegelError {
132    fn from(e: ProtectionError) -> Self {
133        match e {
134            ProtectionError::InvalidSize => Self::InvalidLength,
135            ProtectionError::Mmap(e) => Self::AllocationFailed {
136                reason: e.to_string(),
137            },
138            ProtectionError::Mprotect(e) => Self::ProtectionFailed {
139                reason: e.to_string(),
140            },
141            ProtectionError::Mlock(e) => Self::LockFailed {
142                reason: e.to_string(),
143            },
144            ProtectionError::CanaryCorrupted => Self::CanaryCorrupted,
145        }
146    }
147}
148
149#[cfg(test)]
150#[expect(clippy::unwrap_used, reason = "tests")]
151mod tests {
152    use sha2::{Digest, Sha256};
153
154    use super::*;
155
156    #[test]
157    fn new_creates_empty_of_given_size() {
158        let s: Siegel<Empty> = Siegel::new(32).unwrap();
159        assert_eq!(s.len(), 32);
160    }
161
162    #[test]
163    fn new_rejects_zero_length() {
164        assert!(Siegel::<Empty>::new(0).is_err());
165    }
166
167    #[test]
168    fn test_completew_flow() {
169        let secret = vec![0x42; 32];
170        let empty: Siegel<Empty> = Siegel::new(32).unwrap();
171        let loaded: Siegel<Loaded> = empty.write(&secret).unwrap();
172        let copy = loaded.read_once(<[u8]>::to_vec).unwrap();
173        assert_eq!(copy, secret);
174    }
175
176    #[test]
177    #[expect(clippy::panic, reason = "looking for specific failure")]
178    fn write_rejects_length_mismatch() {
179        let empty: Siegel<Empty> = Siegel::new(16).unwrap();
180        match empty.write(&[0u8; 8]) {
181            Ok(_) => panic!("expected LengthMismatch"),
182            Err(SiegelError::LengthMismatch {
183                expected: 16,
184                got: 8,
185            }) => {}
186            Err(other) => panic!("unexpected error: {other:?}"),
187        }
188    }
189
190    #[test]
191    fn closure_can_perform_arbitrary_operation() {
192        let secret = vec![0x42; 16];
193        let loaded = Siegel::<Empty>::new(16).unwrap().write(&secret).unwrap();
194        let digest = loaded
195            .read_once(|bytes| {
196                let mut h = Sha256::new();
197                h.update(bytes);
198                h.update(b"context");
199                h.finalize().to_vec()
200            })
201            .unwrap();
202        let mut expected = Sha256::new();
203        expected.update(&secret);
204        expected.update(b"context");
205        assert_eq!(digest, expected.finalize().to_vec());
206    }
207
208    #[test]
209    fn closure_return_type_is_generic() {
210        let loaded = Siegel::<Empty>::new(8).unwrap().write(&[1u8; 8]).unwrap();
211        let len: usize = loaded.read_once(<[u8]>::len).unwrap();
212        assert_eq!(len, 8);
213    }
214
215    #[test]
216    fn obliviate_on_empty() {
217        let empty: Siegel<Empty> = Siegel::new(16).unwrap();
218        empty.obliviate();
219    }
220
221    #[test]
222    fn obliviate_on_loaded() {
223        let loaded = Siegel::<Empty>::new(16).unwrap().write(&[7u8; 16]).unwrap();
224        loaded.obliviate();
225    }
226
227    #[test]
228    #[expect(clippy::panic, reason = "deliberately panicking inside the closure")]
229    fn closure_panic_still_drops_loaded() {
230        let loaded = Siegel::<Empty>::new(8).unwrap().write(&[6u8; 8]).unwrap();
231        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
232            loaded.read_once(|_| panic!("operation failed"))
233        }));
234        assert!(result.is_err());
235        // No usable handle to the siegel after a panicking consume; the
236        // unwind dropped it, which zeroized the region.
237    }
238}