1use std::sync::OnceLock;
28
29use sha2::{Digest, Sha256};
30
31#[derive(Debug, thiserror::Error)]
32#[non_exhaustive]
33pub enum MachineError {
34 #[error("platform not supported for machine binding (only macOS/Linux)")]
35 Unsupported,
36 #[error("failed to read machine id: {0}")]
37 Io(String),
38 #[error("machine id empty or malformed")]
39 Malformed,
40 #[error("key bound to different machine")]
41 Mismatch,
42}
43
44pub fn raw_machine_id() -> Result<String, MachineError> {
46 static CACHE: OnceLock<Result<String, String>> = OnceLock::new();
47 match CACHE.get_or_init(|| read_raw_machine_id().map_err(|e| e.to_string())) {
48 Ok(s) => Ok(s.clone()),
49 Err(e) => Err(map_cached_err(e)),
50 }
51}
52
53fn map_cached_err(s: &str) -> MachineError {
54 if s == "platform not supported for machine binding (only macOS/Linux)" {
55 MachineError::Unsupported
56 } else if s == "machine id empty or malformed" {
57 MachineError::Malformed
58 } else {
59 MachineError::Io(s.to_string())
60 }
61}
62
63#[cfg(target_os = "linux")]
64fn read_raw_machine_id() -> Result<String, MachineError> {
65 for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
66 if let Ok(s) = std::fs::read_to_string(path) {
67 let trimmed = s.trim();
68 if !trimmed.is_empty() {
69 return Ok(trimmed.to_string());
70 }
71 }
72 }
73 Err(MachineError::Io("/etc/machine-id not readable".to_string()))
74}
75
76#[cfg(target_os = "macos")]
77fn read_raw_machine_id() -> Result<String, MachineError> {
78 let out = std::process::Command::new("ioreg")
79 .args(["-rd1", "-c", "IOPlatformExpertDevice"])
80 .output()
81 .map_err(|e| MachineError::Io(format!("ioreg: {e}")))?;
82 if !out.status.success() {
83 return Err(MachineError::Io(format!("ioreg exit {}", out.status)));
84 }
85 let text = String::from_utf8_lossy(&out.stdout);
86 for line in text.lines() {
87 if let Some((_before, after)) = line.split_once("\"IOPlatformUUID\"") {
88 let after_eq = after.split_once('=').map(|x| x.1).unwrap_or("");
90 let start = after_eq.find('"').map(|i| i + 1);
91 let end = start.and_then(|s| after_eq[s..].find('"').map(|e| s + e));
92 if let (Some(s), Some(e)) = (start, end) {
93 let uuid = &after_eq[s..e];
94 if !uuid.is_empty() {
95 return Ok(uuid.to_string());
96 }
97 }
98 }
99 }
100 Err(MachineError::Malformed)
101}
102
103#[cfg(not(any(target_os = "linux", target_os = "macos")))]
104fn read_raw_machine_id() -> Result<String, MachineError> {
105 Err(MachineError::Unsupported)
106}
107
108pub fn fingerprint_for(key_id: &str) -> Result<String, MachineError> {
110 let raw = raw_machine_id()?;
111 Ok(fingerprint_from_raw(key_id, &raw))
112}
113
114#[must_use]
116pub fn fingerprint_from_raw(key_id: &str, raw: &str) -> String {
117 let mut h = Sha256::new();
118 h.update(b"futu-machine-bind:v1:");
119 h.update(key_id.as_bytes());
120 h.update(b":");
121 h.update(raw.as_bytes());
122 hex::encode(h.finalize())
123}
124
125pub fn check(key_id: &str, allowed: Option<&[String]>) -> Result<(), MachineError> {
131 let Some(list) = allowed else {
132 return Ok(());
133 };
134 let fp = fingerprint_for(key_id)?;
135 if list.iter().any(|x| x == &fp) {
136 Ok(())
137 } else {
138 Err(MachineError::Mismatch)
139 }
140}
141
142#[cfg(test)]
143mod tests;