Skip to main content

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 / 其他平台: 当前没有 raw machine-id reader;未启用绑定时不限,
10//!   一旦 key 配置了 `allowed_machines`,`Unsupported` 会 fail closed
11//!
12//! 能挡住:
13//! - 把 `keys.json` 整个拷到别的开发机 / VM / Docker 镜像里直接用
14//! - 密钥不小心进了 git,clone 到别的机器也跑不起来
15//!
16//! 挡不住:
17//! - 攻击者已经登上目标机器 → 他能读到 machine-id,就能伪造指纹
18//! - 真要强绑定得走 TPM / Secure Enclave,需要独立安全设计,不属于当前软绑定模型
19//!
20//! ## 指纹公式
21//!
22//! `SHA-256("futu-machine-bind:v1:" || key_id || ":" || raw_machine_id)` → 64 位 hex
23//!
24//! key_id 混入哈希的目的是:同一台机器上不同 key 的指纹不同,泄漏一个 key 的指纹
25//! 不会暴露别的 key 绑定的是不是同一台机器。
26
27use std::sync::OnceLock;
28
29use sha2::{Digest, Sha256};
30
31#[cfg(any(target_os = "macos", test))]
32// macOS system ioreg binary. Use the absolute path to avoid PATH hijack in the
33// local soft-machine-binding reader; this is OS integration, not Futu protocol.
34const MACOS_IOREG_PATH: &str = "/usr/sbin/ioreg";
35#[cfg(any(target_os = "macos", test))]
36// ioreg output for IOPlatformExpertDevice is normally tiny. Keep a generous
37// local cap so a malformed/hijacked subprocess cannot feed unbounded stdout.
38const 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
53/// 获取本机原始 machine-id(每进程只调用一次,后续走缓存)
54pub 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            // 格式: "IOPlatformUUID" = "XXXX-XXXX-..."
104            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
147/// 计算指定 key_id 在本机的指纹哈希(64 位 hex)
148pub 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/// 纯函数版本(方便单元测试)
154#[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
164/// 检查本机指纹是否在白名单里
165///
166/// - `allowed` 为 `None` → 视为未启用机器绑定,始终通过
167/// - `allowed` 为空列表 → 视为禁止所有机器(用于"临时冻结"某 key)
168/// - 取不到 machine-id(平台不支持 / 文件缺失)→ 视为 Mismatch(宁紧勿松)
169pub 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;