Skip to main content

apollo/
fs_secure.rs

1use std::io::Write;
2use std::path::Path;
3
4/// Write a secret to disk so that it is never, even briefly, readable by
5/// anyone but its owner.
6///
7/// Opening the destination directly is not enough: `mode` applies only when
8/// the file is created, so rewriting a pre-existing 0644 file would hold the
9/// new secret at 0644 for the whole write. Instead the content goes to a
10/// fresh 0600 temporary file in the same directory and is renamed over the
11/// destination — which also makes the replacement atomic, so a crash or a
12/// full disk leaves the previous contents rather than a truncated file.
13pub fn write_secret_file(path: &Path, content: &str) -> anyhow::Result<()> {
14    let dir = path.parent().unwrap_or_else(|| Path::new("."));
15    let file_name = path
16        .file_name()
17        .and_then(|n| n.to_str())
18        .ok_or_else(|| anyhow::anyhow!("cannot write a secret to {}", path.display()))?;
19    let temp = dir.join(format!(".{}.{}.tmp", file_name, std::process::id()));
20
21    // Best effort: a temp file left by an earlier crash must not be reused
22    // with whatever mode it carries.
23    let _ = std::fs::remove_file(&temp);
24
25    let result = (|| -> anyhow::Result<()> {
26        let mut options = std::fs::OpenOptions::new();
27        options.write(true).create_new(true);
28        #[cfg(unix)]
29        {
30            use std::os::unix::fs::OpenOptionsExt;
31            options.mode(0o600);
32        }
33        let mut file = options.open(&temp)?;
34        file.write_all(content.as_bytes())?;
35        file.sync_all()?;
36        drop(file);
37        std::fs::rename(&temp, path)?;
38        Ok(())
39    })();
40
41    if result.is_err() {
42        let _ = std::fs::remove_file(&temp);
43    }
44    result
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    #[test]
52    fn secret_file_is_owner_only() {
53        let tmp = tempfile::tempdir().unwrap();
54        let path = tmp.path().join("secret");
55        write_secret_file(&path, "value").unwrap();
56        assert_eq!(std::fs::read_to_string(&path).unwrap(), "value");
57        #[cfg(unix)]
58        {
59            use std::os::unix::fs::PermissionsExt;
60            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
61            assert_eq!(mode & 0o777, 0o600);
62        }
63    }
64
65    #[test]
66    fn rewriting_a_loose_file_tightens_it() {
67        let tmp = tempfile::tempdir().unwrap();
68        let path = tmp.path().join("secret");
69        std::fs::write(&path, "old").unwrap();
70        #[cfg(unix)]
71        {
72            use std::os::unix::fs::PermissionsExt;
73            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
74        }
75        write_secret_file(&path, "new").unwrap();
76        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
77        #[cfg(unix)]
78        {
79            use std::os::unix::fs::PermissionsExt;
80            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
81            assert_eq!(mode & 0o777, 0o600);
82        }
83    }
84
85    /// The final mode is not the property that matters — a reader racing the
86    /// write must never see the new secret at a loose mode. Assert that the
87    /// inode carrying the new content was never the old, world-readable one.
88    #[cfg(unix)]
89    #[test]
90    fn the_new_secret_never_lives_in_the_old_loose_inode() {
91        use std::os::unix::fs::MetadataExt;
92        use std::os::unix::fs::PermissionsExt;
93
94        let tmp = tempfile::tempdir().unwrap();
95        let path = tmp.path().join("secret");
96        std::fs::write(&path, "old").unwrap();
97        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
98        let old_inode = std::fs::metadata(&path).unwrap().ino();
99
100        // An open handle to the old inode is exactly what a racing reader
101        // holds. It must keep seeing the old content, never the new secret.
102        let stale = std::fs::File::open(&path).unwrap();
103
104        write_secret_file(&path, "new-secret").unwrap();
105
106        let new_inode = std::fs::metadata(&path).unwrap().ino();
107        assert_ne!(
108            old_inode, new_inode,
109            "the secret must land in a fresh 0600 inode, not the existing 0644 one"
110        );
111        let mut through_stale = String::new();
112        {
113            use std::io::Read;
114            let mut stale = stale;
115            stale.read_to_string(&mut through_stale).unwrap();
116        }
117        assert_eq!(
118            through_stale, "old",
119            "a handle opened before the write must never observe the new secret"
120        );
121        assert_eq!(
122            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
123            0o600
124        );
125    }
126
127    #[cfg(unix)]
128    #[test]
129    fn a_leftover_temp_file_does_not_taint_the_write() {
130        use std::os::unix::fs::PermissionsExt;
131        let tmp = tempfile::tempdir().unwrap();
132        let path = tmp.path().join("secret");
133        let stale_temp = tmp
134            .path()
135            .join(format!(".secret.{}.tmp", std::process::id()));
136        std::fs::write(&stale_temp, "junk").unwrap();
137        std::fs::set_permissions(&stale_temp, std::fs::Permissions::from_mode(0o666)).unwrap();
138
139        write_secret_file(&path, "value").unwrap();
140
141        assert_eq!(std::fs::read_to_string(&path).unwrap(), "value");
142        assert_eq!(
143            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
144            0o600
145        );
146    }
147}