maincopy_server/config/
secret.rs1use 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#[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 SecretFileReference,
100 "SecretFileReference(<redacted>)",
101 "<redacted-secret-file-reference>"
102);
103
104struct ResolvedSecret {
110 storage: Box<[u8]>,
111 len: usize,
112 #[cfg(test)]
113 drop_probe: Option<SecretDropProbe>,
114}
115
116impl ResolvedSecret {
117 fn read_from(reader: &mut impl Read) -> io::Result<Self> {
123 Self::empty().fill_from(reader)
124 }
125
126 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 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
190pub(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 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}