1use std::sync::OnceLock;
28
29use sha2::{Digest, Sha256};
30
31#[cfg(any(target_os = "macos", test))]
32const MACOS_IOREG_PATH: &str = "/usr/sbin/ioreg";
35#[cfg(any(target_os = "macos", test))]
36const MAX_MACOS_IOREG_STDOUT_BYTES: usize = 64 * 1024;
39
40#[derive(Debug, Clone, thiserror::Error)]
41#[non_exhaustive]
42pub enum MachineError {
43 #[error("platform not supported for machine binding (only macOS/Linux)")]
44 Unsupported,
45 #[error("failed to read machine id: {0}")]
46 Io(String),
47 #[error("machine id empty or malformed")]
48 Malformed,
49 #[error("key bound to different machine")]
50 Mismatch,
51}
52
53pub fn raw_machine_id() -> Result<String, MachineError> {
55 static CACHE: OnceLock<Result<String, MachineError>> = OnceLock::new();
56 cached_raw_machine_id(&CACHE, read_raw_machine_id)
57}
58
59fn cached_raw_machine_id(
60 cache: &OnceLock<Result<String, MachineError>>,
61 reader: impl FnOnce() -> Result<String, MachineError>,
62) -> Result<String, MachineError> {
63 cache.get_or_init(reader).clone()
64}
65
66#[cfg(target_os = "linux")]
67fn read_raw_machine_id() -> Result<String, MachineError> {
68 for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
69 if let Ok(s) = std::fs::read_to_string(path) {
70 let trimmed = s.trim();
71 if !trimmed.is_empty() {
72 return Ok(trimmed.to_string());
73 }
74 }
75 }
76 Err(MachineError::Io("/etc/machine-id not readable".to_string()))
77}
78
79#[cfg(target_os = "macos")]
80fn read_raw_machine_id() -> Result<String, MachineError> {
81 let out = std::process::Command::new(MACOS_IOREG_PATH)
82 .args(["-rd1", "-c", "IOPlatformExpertDevice"])
83 .output()
84 .map_err(|e| MachineError::Io(format!("ioreg: {e}")))?;
85 if !out.status.success() {
86 return Err(MachineError::Io(format!("ioreg exit {}", out.status)));
87 }
88 parse_macos_ioreg_uuid(&out.stdout)
89}
90
91#[cfg(any(target_os = "macos", test))]
92fn parse_macos_ioreg_uuid(stdout: &[u8]) -> Result<String, MachineError> {
93 if stdout.len() > MAX_MACOS_IOREG_STDOUT_BYTES {
94 return Err(MachineError::Io(format!(
95 "ioreg output too large: {} bytes",
96 stdout.len()
97 )));
98 }
99
100 let text = String::from_utf8_lossy(stdout);
101 for line in text.lines() {
102 if let Some((_before, after)) = line.split_once("\"IOPlatformUUID\"") {
103 let after_eq = after.split_once('=').map(|x| x.1).unwrap_or("");
105 let start = after_eq.find('"').map(|i| i + 1);
106 let end = start.and_then(|s| after_eq[s..].find('"').map(|e| s + e));
107 if let (Some(s), Some(e)) = (start, end) {
108 let uuid = &after_eq[s..e];
109 if is_valid_platform_uuid(uuid) {
110 return Ok(uuid.to_string());
111 }
112 return Err(MachineError::Malformed);
113 }
114 }
115 }
116 Err(MachineError::Malformed)
117}
118
119#[cfg(any(target_os = "macos", test))]
120fn is_valid_platform_uuid(value: &str) -> bool {
121 let bytes = value.as_bytes();
122 if bytes.len() != 36 {
123 return false;
124 }
125 for (idx, byte) in bytes.iter().enumerate() {
126 match idx {
127 8 | 13 | 18 | 23 => {
128 if *byte != b'-' {
129 return false;
130 }
131 }
132 _ => {
133 if !byte.is_ascii_hexdigit() {
134 return false;
135 }
136 }
137 }
138 }
139 true
140}
141
142#[cfg(not(any(target_os = "linux", target_os = "macos")))]
143fn read_raw_machine_id() -> Result<String, MachineError> {
144 Err(MachineError::Unsupported)
145}
146
147pub fn fingerprint_for(key_id: &str) -> Result<String, MachineError> {
149 let raw = raw_machine_id()?;
150 Ok(fingerprint_from_raw(key_id, &raw))
151}
152
153#[must_use]
155pub fn fingerprint_from_raw(key_id: &str, raw: &str) -> String {
156 let mut h = Sha256::new();
157 h.update(b"futu-machine-bind:v1:");
158 h.update(key_id.as_bytes());
159 h.update(b":");
160 h.update(raw.as_bytes());
161 hex::encode(h.finalize())
162}
163
164pub fn check(key_id: &str, allowed: Option<&[String]>) -> Result<(), MachineError> {
170 let Some(list) = allowed else {
171 return Ok(());
172 };
173 let fp = fingerprint_for(key_id)?;
174 if list.iter().any(|x| x == &fp) {
175 Ok(())
176 } else {
177 Err(MachineError::Mismatch)
178 }
179}
180
181#[cfg(test)]
182mod tests;