1use std::io::Write;
7use std::path::{Path, PathBuf};
8
9pub const KEY_DIR_ENV: &str = "ASHLAR_CONTROL_KEY_DIR";
13
14pub 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
19pub 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
26pub fn instance_id() -> Option<String> {
28 parse_instance_id(&std::fs::read_to_string("/.flatpak-info").ok()?)
29}
30
31pub 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
43pub 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
60pub 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
75pub fn read_key_file(p: &Path) -> Result<String, String> {
77 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
87pub 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#[derive(Debug)]
96pub struct KeyFile(PathBuf);
97
98impl KeyFile {
99 pub(crate) fn new(p: PathBuf) -> Self {
100 Self(p)
101 }
102
103 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 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}