1use std::sync::OnceLock;
27
28use sha2::{Digest, Sha256};
29
30#[derive(Debug, thiserror::Error)]
31pub enum MachineError {
32 #[error("platform not supported for machine binding (only macOS/Linux)")]
33 Unsupported,
34 #[error("failed to read machine id: {0}")]
35 Io(String),
36 #[error("machine id empty or malformed")]
37 Malformed,
38 #[error("key bound to different machine")]
39 Mismatch,
40}
41
42pub fn raw_machine_id() -> Result<String, MachineError> {
44 static CACHE: OnceLock<Result<String, String>> = OnceLock::new();
45 match CACHE.get_or_init(|| read_raw_machine_id().map_err(|e| e.to_string())) {
46 Ok(s) => Ok(s.clone()),
47 Err(e) => Err(map_cached_err(e)),
48 }
49}
50
51fn map_cached_err(s: &str) -> MachineError {
52 if s == "platform not supported for machine binding (only macOS/Linux)" {
53 MachineError::Unsupported
54 } else if s == "machine id empty or malformed" {
55 MachineError::Malformed
56 } else {
57 MachineError::Io(s.to_string())
58 }
59}
60
61#[cfg(target_os = "linux")]
62fn read_raw_machine_id() -> Result<String, MachineError> {
63 for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
64 if let Ok(s) = std::fs::read_to_string(path) {
65 let trimmed = s.trim();
66 if !trimmed.is_empty() {
67 return Ok(trimmed.to_string());
68 }
69 }
70 }
71 Err(MachineError::Io("/etc/machine-id not readable".to_string()))
72}
73
74#[cfg(target_os = "macos")]
75fn read_raw_machine_id() -> Result<String, MachineError> {
76 let out = std::process::Command::new("ioreg")
77 .args(["-rd1", "-c", "IOPlatformExpertDevice"])
78 .output()
79 .map_err(|e| MachineError::Io(format!("ioreg: {e}")))?;
80 if !out.status.success() {
81 return Err(MachineError::Io(format!("ioreg exit {}", out.status)));
82 }
83 let text = String::from_utf8_lossy(&out.stdout);
84 for line in text.lines() {
85 if let Some((_before, after)) = line.split_once("\"IOPlatformUUID\"") {
86 let after_eq = after.split_once('=').map(|x| x.1).unwrap_or("");
88 let start = after_eq.find('"').map(|i| i + 1);
89 let end = start.and_then(|s| after_eq[s..].find('"').map(|e| s + e));
90 if let (Some(s), Some(e)) = (start, end) {
91 let uuid = &after_eq[s..e];
92 if !uuid.is_empty() {
93 return Ok(uuid.to_string());
94 }
95 }
96 }
97 }
98 Err(MachineError::Malformed)
99}
100
101#[cfg(not(any(target_os = "linux", target_os = "macos")))]
102fn read_raw_machine_id() -> Result<String, MachineError> {
103 Err(MachineError::Unsupported)
104}
105
106pub fn fingerprint_for(key_id: &str) -> Result<String, MachineError> {
108 let raw = raw_machine_id()?;
109 Ok(fingerprint_from_raw(key_id, &raw))
110}
111
112pub fn fingerprint_from_raw(key_id: &str, raw: &str) -> String {
114 let mut h = Sha256::new();
115 h.update(b"futu-machine-bind:v1:");
116 h.update(key_id.as_bytes());
117 h.update(b":");
118 h.update(raw.as_bytes());
119 hex::encode(h.finalize())
120}
121
122pub fn check(key_id: &str, allowed: Option<&[String]>) -> Result<(), MachineError> {
128 let Some(list) = allowed else {
129 return Ok(());
130 };
131 let fp = fingerprint_for(key_id)?;
132 if list.iter().any(|x| x == &fp) {
133 Ok(())
134 } else {
135 Err(MachineError::Mismatch)
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn fingerprint_deterministic() {
145 let a = fingerprint_from_raw("k1", "uuid-abc");
146 let b = fingerprint_from_raw("k1", "uuid-abc");
147 assert_eq!(a, b);
148 }
149
150 #[test]
151 fn fingerprint_differs_by_key_id() {
152 let a = fingerprint_from_raw("k1", "uuid-abc");
153 let b = fingerprint_from_raw("k2", "uuid-abc");
154 assert_ne!(a, b);
155 }
156
157 #[test]
158 fn fingerprint_differs_by_machine() {
159 let a = fingerprint_from_raw("k1", "uuid-abc");
160 let b = fingerprint_from_raw("k1", "uuid-xyz");
161 assert_ne!(a, b);
162 }
163
164 #[test]
165 fn fingerprint_hex_length() {
166 let fp = fingerprint_from_raw("k", "r");
167 assert_eq!(fp.len(), 64);
168 assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
169 }
170
171 #[test]
172 fn check_none_passes() {
173 assert!(check("any", None).is_ok());
174 }
175
176 #[test]
177 fn check_empty_list_fails_on_supported_platforms() {
178 match check("any", Some(&[])) {
180 Err(MachineError::Mismatch) => {}
181 Err(MachineError::Unsupported) => {}
182 Err(MachineError::Io(_)) | Err(MachineError::Malformed) => {}
183 Ok(()) => panic!("empty allowlist should not pass"),
184 }
185 }
186
187 #[cfg(any(target_os = "linux", target_os = "macos"))]
188 #[test]
189 fn raw_machine_id_available() {
190 let id = raw_machine_id().expect("machine id");
192 assert!(!id.is_empty());
193 }
194}