Skip to main content

key_vault/fetcher/
file.rs

1//! [`FileFetch`] — file-based [`KeyFetch`] backend.
2//!
3//! Reads key bytes from a file on disk. On Unix the file's permission bits
4//! are checked: by default we reject files that are readable by group or
5//! world (any mode bit in `0o077` set). On Windows we trust the platform's
6//! NTFS ACLs and do not perform a separate permission check.
7//!
8//! # On-disk format
9//!
10//! `FileFetch` does **not** perform AEAD decryption in this release. The
11//! file contents are read verbatim and returned as the key. For
12//! encryption-at-rest pair this fetcher with OS-level disk encryption
13//! (LUKS / FileVault / BitLocker) or a sealed-key file format from
14//! another crate. AEAD-encrypted file support is on the post-1.0 backlog.
15//!
16//! # Threat profile
17//!
18//! Higher security than [`EnvFetch`](super::env::EnvFetch) because file
19//! permissions confine access to one user account on POSIX systems. Lower
20//! security than [`KeychainFetch`](super::keychain::KeychainFetch) since
21//! the bytes live on disk in cleartext and persist across reboots.
22
23use alloc::borrow::Cow;
24use alloc::format;
25use alloc::string::String;
26use std::path::{Path, PathBuf};
27
28use super::{FetchContext, KeyFetch, RawKey};
29use crate::Result;
30use crate::error::Error;
31
32/// `KeyFetch` implementation that reads bytes from a file on disk.
33///
34/// By default Unix permission bits stricter than `0o600` are rejected; call
35/// [`FileFetch::allow_loose_perms`] to disable that check (not recommended
36/// outside of tests).
37///
38/// # Examples
39///
40/// ```no_run
41/// use key_vault::{FetchContext, FileFetch, KeyFetch};
42///
43/// # fn main() -> Result<(), key_vault::Error> {
44/// // Assume /etc/myapp/key.bin is mode 0600 and contains 32 bytes.
45/// let fetcher = FileFetch::new("/etc/myapp/key.bin");
46/// let raw = fetcher.fetch(&FetchContext::new("primary"))?;
47/// assert_eq!(raw.len(), 32);
48/// # Ok(())
49/// # }
50/// ```
51#[derive(Debug, Clone)]
52pub struct FileFetch {
53    path: PathBuf,
54    strict_perms: bool,
55}
56
57impl FileFetch {
58    /// Construct a fetcher that reads the file at `path`. Strict Unix
59    /// permission checking is enabled by default.
60    #[must_use]
61    pub fn new(path: impl Into<PathBuf>) -> Self {
62        Self {
63            path: path.into(),
64            strict_perms: true,
65        }
66    }
67
68    /// Disable strict Unix permission checking.
69    ///
70    /// Useful for test fixtures and containers where the user controlling
71    /// the file is the same as the process user but the file may have
72    /// been created with `0o644`. **Do not** disable strict perms in
73    /// production deployments where multiple users share the host.
74    #[must_use]
75    pub fn allow_loose_perms(mut self) -> Self {
76        self.strict_perms = false;
77        self
78    }
79
80    /// Path the fetcher reads from. Used in audit / diagnostic output.
81    #[must_use]
82    pub fn path(&self) -> &Path {
83        &self.path
84    }
85}
86
87impl KeyFetch for FileFetch {
88    fn fetch(&self, _ctx: &FetchContext) -> Result<RawKey> {
89        if self.strict_perms {
90            check_perms(&self.path)?;
91        }
92        std::fs::read(&self.path).map_or_else(
93            |e| {
94                Err(Error::Acquisition {
95                    source: Cow::Borrowed("file"),
96                    reason: io_failure_message(&self.path, &e),
97                })
98            },
99            |bytes| Ok(RawKey::new(bytes)),
100        )
101    }
102
103    fn describe(&self) -> Cow<'_, str> {
104        Cow::Borrowed("file")
105    }
106}
107
108#[cfg(unix)]
109fn check_perms(path: &Path) -> Result<()> {
110    use std::os::unix::fs::PermissionsExt;
111    let meta = std::fs::metadata(path).map_err(|e| Error::Acquisition {
112        source: Cow::Borrowed("file"),
113        reason: io_failure_message(path, &e),
114    })?;
115    let mode = meta.permissions().mode();
116    if (mode & 0o077) != 0 {
117        return Err(Error::Acquisition {
118            source: Cow::Borrowed("file"),
119            reason: format!(
120                "{} is too permissive (mode {:o}); expected 0600 or stricter",
121                path.display(),
122                mode & 0o777
123            ),
124        });
125    }
126    Ok(())
127}
128
129#[cfg(not(unix))]
130#[allow(clippy::unnecessary_wraps)] // matches the Unix sibling's signature.
131fn check_perms(_path: &Path) -> Result<()> {
132    // Windows ACL inspection is non-trivial and platform-specific. For 1.0
133    // we trust the OS-level access controls. Users who need additional
134    // verification on Windows should layer it on top of FileFetch.
135    Ok(())
136}
137
138/// Build a redaction-clean error message for a file I/O failure.
139fn io_failure_message(path: &Path, e: &std::io::Error) -> String {
140    // `std::io::Error` may include the OS error string. We restrict the
141    // message to the path and the error *kind* (which is a short enum
142    // discriminant) so secret-like substrings don't accidentally leak.
143    format!("failed to read {}: {:?}", path.display(), e.kind())
144}
145
146#[cfg(test)]
147#[allow(clippy::unwrap_used, clippy::expect_used)]
148mod tests {
149    use super::*;
150    use std::io::Write;
151
152    /// Build a temporary file in the OS temp directory with the given
153    /// contents. Returns the path; the file is cleaned up at end of test
154    /// via the returned `_TempFile` guard.
155    struct TempFile {
156        path: PathBuf,
157    }
158
159    impl TempFile {
160        fn new(prefix: &str, contents: &[u8]) -> Self {
161            let mut path = std::env::temp_dir();
162            // Use process id + a counter for uniqueness. Pre-existing files
163            // are overwritten — that's fine for unique prefixes.
164            let suffix = format!("{}_{}", std::process::id(), prefix);
165            path.push(format!("kv_test_{suffix}.bin"));
166            let mut f = std::fs::File::create(&path).unwrap();
167            f.write_all(contents).unwrap();
168            drop(f);
169            Self { path }
170        }
171
172        fn path(&self) -> &Path {
173            &self.path
174        }
175    }
176
177    impl Drop for TempFile {
178        fn drop(&mut self) {
179            let _ = std::fs::remove_file(&self.path);
180        }
181    }
182
183    #[test]
184    fn reads_file_contents() {
185        let f = TempFile::new("read_ok", b"hello, world!");
186        let fetcher = FileFetch::new(f.path()).allow_loose_perms();
187        let raw = fetcher.fetch(&FetchContext::new("k")).unwrap();
188        assert_eq!(raw.len(), 13);
189    }
190
191    #[test]
192    fn missing_file_returns_acquisition_error() {
193        let fetcher =
194            FileFetch::new("/nonexistent/path/key-vault-test-missing.bin").allow_loose_perms();
195        let err = fetcher.fetch(&FetchContext::new("k")).unwrap_err();
196        match err {
197            Error::Acquisition { source, reason } => {
198                assert_eq!(source, "file");
199                assert!(reason.contains("failed to read"));
200            }
201            other => panic!("expected Acquisition, got {other:?}"),
202        }
203    }
204
205    #[cfg(unix)]
206    #[test]
207    fn strict_perms_rejects_world_readable_file() {
208        use std::os::unix::fs::PermissionsExt;
209        let f = TempFile::new("strict_perm", b"key");
210        std::fs::set_permissions(f.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
211        let fetcher = FileFetch::new(f.path());
212        let err = fetcher.fetch(&FetchContext::new("k")).unwrap_err();
213        match err {
214            Error::Acquisition { reason, .. } => {
215                assert!(reason.contains("too permissive"));
216            }
217            other => panic!("expected Acquisition, got {other:?}"),
218        }
219    }
220
221    #[cfg(unix)]
222    #[test]
223    fn strict_perms_accepts_0600() {
224        use std::os::unix::fs::PermissionsExt;
225        let f = TempFile::new("strict_0600", b"key");
226        std::fs::set_permissions(f.path(), std::fs::Permissions::from_mode(0o600)).unwrap();
227        let fetcher = FileFetch::new(f.path());
228        let raw = fetcher.fetch(&FetchContext::new("k")).unwrap();
229        assert_eq!(raw.len(), 3);
230    }
231
232    #[test]
233    fn describe_returns_file() {
234        assert_eq!(FileFetch::new("/dev/null").describe(), "file");
235    }
236}