futu_auth/
machine.rs

1//! 软机器绑定(soft machine binding)
2//!
3//! 将 API Key 与一个稳定的机器指纹绑定,防止 keys.json 被整体复制到别的机器后仍可用。
4//!
5//! ## 强度说明 — 这是"软"绑定,不是硬件锁
6//!
7//! - Linux: 读 `/etc/machine-id`(world-readable,只需文件读权限即可拿到)
8//! - macOS: 解析 `ioreg` 输出拿 `IOPlatformUUID`(任何用户都能跑 ioreg)
9//! - Windows: 暂不支持,跳过校验
10//!
11//! 能挡住:
12//! - 把 `keys.json` 整个拷到别的开发机 / VM / Docker 镜像里直接用
13//! - 密钥不小心进了 git,clone 到别的机器也跑不起来
14//!
15//! 挡不住:
16//! - 攻击者已经登上目标机器 → 他能读到 machine-id,就能伪造指纹
17//! - 真要强绑定得走 TPM / Secure Enclave,那是未来版本的事
18//!
19//! ## 指纹公式
20//!
21//! `SHA-256("futu-machine-bind:v1:" || key_id || ":" || raw_machine_id)` → 64 位 hex
22//!
23//! key_id 混入哈希的目的是:同一台机器上不同 key 的指纹不同,泄漏一个 key 的指纹
24//! 不会暴露别的 key 绑定的是不是同一台机器。
25
26use 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
42/// 获取本机原始 machine-id(每进程只调用一次,后续走缓存)
43pub 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            // 格式: "IOPlatformUUID" = "XXXX-XXXX-..."
87            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
106/// 计算指定 key_id 在本机的指纹哈希(64 位 hex)
107pub 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
112/// 纯函数版本(方便单元测试)
113pub 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
122/// 检查本机指纹是否在白名单里
123///
124/// - `allowed` 为 `None` → 视为未启用机器绑定,始终通过
125/// - `allowed` 为空列表 → 视为禁止所有机器(用于"临时冻结"某 key)
126/// - 取不到 machine-id(平台不支持 / 文件缺失)→ 视为 Mismatch(宁紧勿松)
127pub 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        // 在 macOS/Linux 下应该能拿到 machine-id 并校验失败;其他平台会返回 Unsupported
179        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        // 这台跑 CI / dev 的机器应该都能拿到 id
191        let id = raw_machine_id().expect("machine id");
192        assert!(!id.is_empty());
193    }
194}