Skip to main content

maincopy_server/config/
secret.rs

1use std::{
2    fmt,
3    fs::File,
4    io::{self, Read},
5    path::{Path, PathBuf},
6};
7
8use thiserror::Error;
9use zeroize::Zeroize as _;
10
11#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
12pub(crate) enum ProtectedSecretFileError {
13    #[error("the secret must be a bounded private regular file owned by this service")]
14    Protection,
15    #[error("the protected secret file could not be read")]
16    Read,
17}
18
19/// Open the validated descriptor without following a final symlink or blocking
20/// on a special file. Callers must also bound reads against concurrent growth.
21#[cfg(unix)]
22pub(crate) fn open_protected_secret_file(
23    path: &Path,
24    max_bytes: u64,
25) -> Result<File, ProtectedSecretFileError> {
26    use rustix::{
27        fs::{Mode, OFlags, open},
28        process::geteuid,
29    };
30    use std::os::unix::fs::MetadataExt as _;
31    if !path.is_absolute() {
32        return Err(ProtectedSecretFileError::Protection);
33    }
34    let file = File::from(
35        open(
36            path,
37            OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
38            Mode::empty(),
39        )
40        .map_err(|_| ProtectedSecretFileError::Read)?,
41    );
42    let metadata = file
43        .metadata()
44        .map_err(|_| ProtectedSecretFileError::Read)?;
45    if !metadata.is_file()
46        || metadata.uid() != geteuid().as_raw()
47        || !matches!(metadata.mode() & 0o7777, 0o400 | 0o600)
48        || metadata.len() > max_bytes
49    {
50        return Err(ProtectedSecretFileError::Protection);
51    }
52    Ok(file)
53}
54
55#[cfg(not(unix))]
56pub(crate) fn open_protected_secret_file(
57    _path: &Path,
58    _max_bytes: u64,
59) -> Result<File, ProtectedSecretFileError> {
60    Err(ProtectedSecretFileError::Protection)
61}
62
63const MAX_RESOLVED_SECRET_BYTES: usize = 64 * 1024;
64const RESOLVED_SECRET_BUFFER_BYTES: usize = MAX_RESOLVED_SECRET_BYTES + 1;
65const RESOLVED_SECRET_TOO_LARGE: &str = "resolved secret exceeds the inclusive 64 KiB hard limit";
66
67macro_rules! redacted_path_type {
68    ($(#[$attribute:meta])* $name:ident, $debug:literal, $display:literal) => {
69        $(#[$attribute])*
70        #[derive(Clone, Eq, PartialEq)]
71        pub struct $name(PathBuf);
72
73        impl $name {
74            pub fn new(path: PathBuf) -> Option<Self> {
75                (!path.as_os_str().is_empty()).then_some(Self(path))
76            }
77
78            pub fn path(&self) -> &Path {
79                &self.0
80            }
81        }
82
83        impl fmt::Debug for $name {
84            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85                formatter.write_str($debug)
86            }
87        }
88
89        impl fmt::Display for $name {
90            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91                formatter.write_str($display)
92            }
93        }
94    };
95}
96
97redacted_path_type!(
98    /// A redacted reference to a file that contains protected bytes.
99    SecretFileReference,
100    "SecretFileReference(<redacted>)",
101    "<redacted-secret-file-reference>"
102);
103
104/// Bytes in one fixed allocation that is wiped before deallocation.
105///
106/// This type intentionally has no cloning, serialization, dereference, slice
107/// conversion, or inner-value extraction API. A consuming callback is the only
108/// way to inspect the initialized bytes.
109struct ResolvedSecret {
110    storage: Box<[u8]>,
111    len: usize,
112    #[cfg(test)]
113    drop_probe: Option<SecretDropProbe>,
114}
115
116impl ResolvedSecret {
117    /// Reads at most 64 KiB directly into one fixed allocation.
118    ///
119    /// The extra byte is a sentinel that detects input beyond the inclusive
120    /// hard limit. The allocation already belongs to `ResolvedSecret` while
121    /// reads occur, so read failures also take the zeroizing drop path.
122    fn read_from(reader: &mut impl Read) -> io::Result<Self> {
123        Self::empty().fill_from(reader)
124    }
125
126    /// Gives one callback a scoped view and wipes the allocation afterwards.
127    fn expose_to<Output>(
128        self,
129        use_secret: impl for<'secret> FnOnce(&'secret [u8]) -> Output,
130    ) -> Output {
131        use_secret(&self.storage[..self.len])
132    }
133
134    fn empty() -> Self {
135        Self {
136            // This vector contains only zeros. Secret bytes enter storage only
137            // after it becomes a fixed-size boxed slice that cannot reallocate.
138            storage: vec![0; RESOLVED_SECRET_BUFFER_BYTES].into_boxed_slice(),
139            len: 0,
140            #[cfg(test)]
141            drop_probe: None,
142        }
143    }
144
145    fn fill_from(mut self, reader: &mut impl Read) -> io::Result<Self> {
146        loop {
147            match reader.read(&mut self.storage[self.len..]) {
148                Ok(0) => return Ok(self),
149                Ok(read) => {
150                    self.len += read;
151                    if self.len > MAX_RESOLVED_SECRET_BYTES {
152                        return Err(io::Error::new(
153                            io::ErrorKind::InvalidData,
154                            RESOLVED_SECRET_TOO_LARGE,
155                        ));
156                    }
157                }
158                Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
159                Err(error) => return Err(error),
160            }
161        }
162    }
163
164    #[cfg(test)]
165    fn read_from_with_probe(
166        reader: &mut impl Read,
167        drop_probe: SecretDropProbe,
168    ) -> io::Result<Self> {
169        let mut secret = Self::empty();
170        secret.drop_probe = Some(drop_probe);
171        secret.fill_from(reader)
172    }
173}
174
175impl Drop for ResolvedSecret {
176    fn drop(&mut self) {
177        self.storage.zeroize();
178        self.len = 0;
179
180        #[cfg(test)]
181        if let Some(probe) = &self.drop_probe {
182            probe.store(
183                self.storage.iter().all(|byte| *byte == 0),
184                std::sync::atomic::Ordering::SeqCst,
185            );
186        }
187    }
188}
189
190/// Reads from an already secured source and gives one callback a scoped view.
191///
192/// The consuming composition boundary must open and validate the credential file
193/// before it calls this boundary. This function does not define file-opening
194/// policy.
195pub(crate) fn with_resolved_secret<Output>(
196    reader: &mut impl Read,
197    use_secret: impl for<'secret> FnOnce(&'secret [u8]) -> Output,
198) -> io::Result<Output> {
199    let secret = ResolvedSecret::read_from(reader)?;
200    Ok(secret.expose_to(use_secret))
201}
202
203redacted_path_type!(
204    /// A path whose existence can reveal protected runtime metadata.
205    SensitivePath,
206    "SensitivePath(<redacted>)",
207    "<redacted-sensitive-path>"
208);
209
210#[cfg(test)]
211type SecretDropProbe = std::sync::Arc<std::sync::atomic::AtomicBool>;
212
213#[cfg(test)]
214mod tests {
215    use std::{
216        borrow::Borrow,
217        io::Cursor,
218        ops::Deref,
219        panic::{AssertUnwindSafe, catch_unwind},
220    };
221
222    use serde::{Serialize, de::DeserializeOwned};
223
224    use super::*;
225
226    macro_rules! assert_not_impl {
227        ($value:ty: $bound:path) => {
228            const _: fn() = || {
229                trait AmbiguousIfImpl<Marker> {
230                    fn marker() {}
231                }
232
233                impl<Value: ?Sized> AmbiguousIfImpl<()> for Value {}
234                impl<Value: ?Sized + $bound> AmbiguousIfImpl<u8> for Value {}
235
236                let _ = <$value as AmbiguousIfImpl<_>>::marker;
237            };
238        };
239    }
240
241    assert_not_impl!(ResolvedSecret: Copy);
242    assert_not_impl!(ResolvedSecret: Clone);
243    assert_not_impl!(ResolvedSecret: Serialize);
244    assert_not_impl!(ResolvedSecret: DeserializeOwned);
245    assert_not_impl!(ResolvedSecret: Eq);
246    assert_not_impl!(ResolvedSecret: PartialEq);
247    assert_not_impl!(ResolvedSecret: Deref);
248    assert_not_impl!(ResolvedSecret: AsRef<[u8]>);
249    assert_not_impl!(ResolvedSecret: Borrow<[u8]>);
250
251    struct FailingReader;
252
253    impl Read for FailingReader {
254        fn read(&mut self, _output: &mut [u8]) -> io::Result<usize> {
255            Err(io::Error::other("synthetic read failure"))
256        }
257    }
258
259    fn probed_secret() -> (ResolvedSecret, SecretDropProbe) {
260        let probe = SecretDropProbe::default();
261        let mut reader = io::repeat(0xa5).take(MAX_RESOLVED_SECRET_BYTES as u64);
262        let secret = ResolvedSecret::read_from_with_probe(&mut reader, probe.clone()).unwrap();
263        (secret, probe)
264    }
265
266    fn assert_zeroized_after_drop(probe: &SecretDropProbe) {
267        assert!(probe.load(std::sync::atomic::Ordering::SeqCst));
268    }
269
270    #[test]
271    fn secret_and_sensitive_paths_are_redacted() {
272        let file = SecretFileReference::new(PathBuf::from("/secret/credential.json")).unwrap();
273        let cache = SensitivePath::new(PathBuf::from("/secret/private-cache")).unwrap();
274
275        let rendered = format!("{file:?} {file} {cache:?} {cache}");
276        for protected in ["/secret", "credential.json", "private-cache"] {
277            assert!(!rendered.contains(protected));
278        }
279    }
280
281    #[test]
282    fn resolved_secret_boundary_exposes_only_a_scoped_borrow() {
283        let length =
284            with_resolved_secret(&mut Cursor::new(b"protected"), |bytes| bytes.len()).unwrap();
285
286        assert_eq!(length, 9);
287    }
288
289    #[test]
290    fn normal_callback_return_zeroizes_the_complete_allocation() {
291        let (secret, probe) = probed_secret();
292        assert!(!probe.load(std::sync::atomic::Ordering::SeqCst));
293
294        let length = secret.expose_to(|bytes| bytes.len());
295
296        assert_eq!(length, MAX_RESOLVED_SECRET_BYTES);
297        assert_zeroized_after_drop(&probe);
298    }
299
300    #[test]
301    fn ordinary_drop_zeroizes_the_complete_allocation() {
302        let (secret, probe) = probed_secret();
303
304        drop(secret);
305
306        assert_zeroized_after_drop(&probe);
307    }
308
309    #[test]
310    fn callback_error_zeroizes_the_complete_allocation() {
311        let (secret, probe) = probed_secret();
312
313        let result: Result<(), &'static str> = secret.expose_to(|_| Err("synthetic parse error"));
314
315        assert_eq!(result, Err("synthetic parse error"));
316        assert_zeroized_after_drop(&probe);
317    }
318
319    #[test]
320    fn callback_panic_zeroizes_the_complete_allocation_during_unwind() {
321        let (secret, probe) = probed_secret();
322
323        let result = catch_unwind(AssertUnwindSafe(|| {
324            secret.expose_to::<()>(|_| panic!("synthetic callback panic"));
325        }));
326
327        assert!(result.is_err());
328        assert_zeroized_after_drop(&probe);
329    }
330
331    #[test]
332    fn partial_read_error_zeroizes_initialized_bytes_before_deallocation() {
333        let probe = SecretDropProbe::default();
334        let mut reader = Cursor::new([0xa5; 32]).chain(FailingReader);
335        let error = ResolvedSecret::read_from_with_probe(&mut reader, probe.clone())
336            .err()
337            .unwrap();
338
339        assert_eq!(error.kind(), io::ErrorKind::Other);
340        assert_zeroized_after_drop(&probe);
341    }
342
343    #[test]
344    fn inclusive_hard_limit_is_accepted() {
345        let mut reader = io::repeat(0xa5).take(MAX_RESOLVED_SECRET_BYTES as u64);
346        let length = ResolvedSecret::read_from(&mut reader)
347            .unwrap()
348            .expose_to(|bytes| bytes.len());
349        assert_eq!(length, MAX_RESOLVED_SECRET_BYTES);
350    }
351
352    #[test]
353    fn sentinel_rejects_one_byte_over_limit_and_zeroizes_it() {
354        let probe = SecretDropProbe::default();
355        let mut reader = io::repeat(0xa5).take(RESOLVED_SECRET_BUFFER_BYTES as u64);
356
357        let error = ResolvedSecret::read_from_with_probe(&mut reader, probe.clone())
358            .err()
359            .unwrap();
360
361        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
362        assert_eq!(error.to_string(), RESOLVED_SECRET_TOO_LARGE);
363        assert_zeroized_after_drop(&probe);
364    }
365}