Skip to main content

ashlar_control/
key.rs

1//! The control key: a random secret the app writes to a file only its user can read, and that
2//! every request must carry. One rule for the app (which writes it) and the clients (which read
3//! it): `<dir>/control-key.<instance>`, `<dir>` being a folder the app chooses (its settings
4//! folder), `<instance>` the Flatpak instance id or `local-<port>`.
5
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9/// A launcher (`ashlar-debug open`, a test harness) that starts an app with `--control <port>` sets
10/// this to the folder the app must write its key to, so it can read the key back without knowing
11/// the app's settings folder.
12pub const KEY_DIR_ENV: &str = "ASHLAR_CONTROL_KEY_DIR";
13
14/// The key folder a launcher asked for ([`KEY_DIR_ENV`]), if any.
15pub fn key_dir_from_env() -> Option<PathBuf> {
16    std::env::var_os(KEY_DIR_ENV).filter(|v| !v.is_empty()).map(PathBuf::from)
17}
18
19/// The `instance-id=` value of a `/.flatpak-info` file, only when it is `[A-Za-z0-9_-]+` (it
20/// becomes part of a file name).
21pub fn parse_instance_id(info: &str) -> Option<String> {
22    let v = info.lines().find_map(|l| l.strip_prefix("instance-id="))?.trim();
23    (!v.is_empty() && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')).then(|| v.to_string())
24}
25
26/// The Flatpak instance id, or `None` outside Flatpak.
27pub fn instance_id() -> Option<String> {
28    parse_instance_id(&std::fs::read_to_string("/.flatpak-info").ok()?)
29}
30
31/// `<dir>/control-key.<instance>`.
32pub fn key_path(dir: &Path, port: u16) -> PathBuf {
33    key_path_for(dir, instance_id().as_deref(), port)
34}
35
36pub(crate) fn key_path_for(dir: &Path, instance: Option<&str>, port: u16) -> PathBuf {
37    match instance {
38        Some(id) => dir.join(format!("control-key.{id}")),
39        None => dir.join(format!("control-key.local-{port}")),
40    }
41}
42
43/// A new key: 32 random bytes from the operating system, as 64 hex digits.
44pub fn random_key() -> Result<String, String> {
45    let mut b = [0u8; 32];
46    fill_random(&mut b)?;
47    Ok(b.iter().map(|x| format!("{x:02x}")).collect())
48}
49
50#[cfg(not(target_arch = "wasm32"))]
51fn fill_random(b: &mut [u8]) -> Result<(), String> {
52    getrandom::fill(b).map_err(|e| e.to_string())
53}
54
55#[cfg(target_arch = "wasm32")]
56fn fill_random(_: &mut [u8]) -> Result<(), String> {
57    Err("no control port on the web".into())
58}
59
60/// Write the key file: a stale one (from a run that did not exit cleanly) is replaced; on Unix the
61/// new file is created with mode 0600 (never readable by other users, not even for a moment).
62pub fn write_key_file(p: &Path, key: &str) -> Result<(), String> {
63    if let Some(d) = p.parent() {
64        std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?;
65    }
66    let _ = std::fs::remove_file(p);
67    let mut open = std::fs::OpenOptions::new();
68    open.write(true).create_new(true);
69    #[cfg(unix)]
70    std::os::unix::fs::OpenOptionsExt::mode(&mut open, 0o600);
71    let mut f = open.open(p).map_err(|e| format!("cannot create {}: {e}", p.display()))?;
72    f.write_all(key.as_bytes()).map_err(|e| format!("cannot write {}: {e}", p.display()))
73}
74
75/// The key in a key file (trimmed). The error says where it looked.
76pub fn read_key_file(p: &Path) -> Result<String, String> {
77    // A key is 64 bytes; never read a huge file someone put there.
78    let text = std::fs::read(p).map_err(|e| format!("no control key at {} ({e}); is the app running with its control port on?", p.display()))?;
79    let text = text.get(..4096).unwrap_or(&text);
80    let k = String::from_utf8_lossy(text).trim().to_string();
81    if k.is_empty() {
82        return Err(format!("the control key file {} is empty", p.display()));
83    }
84    Ok(k)
85}
86
87/// Compare a given key with the app's in time that does not depend on where they differ. An empty
88/// key never matches.
89pub fn keys_match(given: &str, key: &str) -> bool {
90    let (a, b) = (given.as_bytes(), key.as_bytes());
91    !b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
92}
93
94/// A written key file, removed when dropped (the app closes its control port).
95#[derive(Debug)]
96pub struct KeyFile(PathBuf);
97
98impl KeyFile {
99    pub(crate) fn new(p: PathBuf) -> Self {
100        Self(p)
101    }
102
103    /// Write `key` to `path` (as [`write_key_file`] does) and keep the file until this is dropped:
104    /// for an app that serves the protocol itself instead of through [`crate::Server`]
105    /// ([`key_path`] gives the path clients look at).
106    pub fn create(path: impl Into<PathBuf>, key: &str) -> Result<KeyFile, String> {
107        let path = path.into();
108        write_key_file(&path, key)?;
109        Ok(Self(path))
110    }
111
112    pub fn path(&self) -> &Path {
113        &self.0
114    }
115}
116
117impl Drop for KeyFile {
118    fn drop(&mut self) {
119        let _ = std::fs::remove_file(&self.0);
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
128
129    #[test]
130    fn instance_id_only_safe_values() {
131        assert_eq!(parse_instance_id("[Instance]\ninstance-id=abc_123-X\n").as_deref(), Some("abc_123-X"));
132        assert_eq!(parse_instance_id("instance-id= 42 \n").as_deref(), Some("42"));
133        for bad in ["instance-id=../etc\n", "instance-id=a/b\n", "instance-id=a b\n", "instance-id=..\n", "instance-id=\n", "app=x\n"] {
134            assert_eq!(parse_instance_id(bad), None, "{bad}");
135        }
136    }
137
138    #[test]
139    fn key_file_per_instance() {
140        let dir = Path::new("/settings/app");
141        assert_eq!(key_path_for(dir, Some("1234"), 7981), dir.join("control-key.1234"));
142        assert_eq!(key_path_for(dir, None, 7981), dir.join("control-key.local-7981"));
143        assert_eq!(key_path_for(dir, None, 7982), dir.join("control-key.local-7982"));
144    }
145
146    #[test]
147    fn keys_compare_whole() {
148        assert!(keys_match(KEY, KEY));
149        assert!(!keys_match(KEY, &KEY[..63]));
150        assert!(!keys_match(&KEY[..63], KEY));
151        assert!(!keys_match(KEY, &KEY.replace('f', "e")));
152        assert!(!keys_match("", ""), "an empty key never matches");
153    }
154
155    #[test]
156    fn random_keys_are_256_bits() {
157        let (a, b) = (random_key().unwrap(), random_key().unwrap());
158        assert_eq!(a.len(), 64);
159        assert!(a.bytes().all(|c| c.is_ascii_hexdigit()));
160        assert_ne!(a, b);
161    }
162
163    #[test]
164    fn a_stale_key_file_is_replaced_with_mode_0600() {
165        let dir = tempfile::tempdir().unwrap();
166        let p = dir.path().join("control-key.test");
167        std::fs::write(&p, "old").unwrap();
168        #[cfg(unix)]
169        {
170            use std::os::unix::fs::PermissionsExt;
171            std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
172        }
173        write_key_file(&p, KEY).unwrap();
174        assert_eq!(read_key_file(&p).unwrap(), KEY);
175        #[cfg(unix)]
176        {
177            use std::os::unix::fs::PermissionsExt;
178            assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
179        }
180        drop(KeyFile::new(p.clone()));
181        assert!(!p.exists());
182        assert!(read_key_file(&p).unwrap_err().contains("no control key"));
183        // an app serving the protocol itself owns its key file the same way
184        let own = KeyFile::create(p.clone(), KEY).unwrap();
185        assert_eq!((own.path(), read_key_file(&p).unwrap().as_str()), (p.as_path(), KEY));
186        drop(own);
187        assert!(!p.exists());
188    }
189}