Skip to main content

futu_auth/
key.rs

1//! KeyRecord: 单条 API Key 的配置 + SHA-256 校验
2
3use std::collections::HashSet;
4
5use chrono::{DateTime, NaiveTime, Utc};
6use rand::RngCore;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::limits::Limits;
11use crate::scope::Scope;
12
13/// 单条 key 的持久化格式
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct KeyRecord {
16    /// 人读 ID(审计日志用)
17    pub id: String,
18    /// "sha256:<64 hex>"
19    pub hash: String,
20    pub scopes: HashSet<Scope>,
21
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub allowed_markets: Option<HashSet<String>>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub allowed_symbols: Option<HashSet<String>>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub max_order_value: Option<f64>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub max_daily_value: Option<f64>,
30    /// "HH:MM-HH:MM" 服务器本地时区;跨午夜用 "22:00-04:00"
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub hours_window: Option<String>,
33    /// 每 60s 最多下单次数(滑动窗口,None 表示不限)
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub max_orders_per_minute: Option<u32>,
36    /// 允许的交易方向,例如 `["SELL"]`;None 不限
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub allowed_trd_sides: Option<HashSet<String>>,
39    /// v1.4.35:per-key acc_id 白名单。详见 [`Limits::allowed_acc_ids`]。
40    /// None / 空集 → 不限(向后兼容老 key)。
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub allowed_acc_ids: Option<HashSet<u64>>,
43    /// v1.4.103 (B10): per-key card_num 白名单 (string format).
44    ///
45    /// 字符串格式接受:
46    /// - 完整 16 位 card_num: `"1001100100800000"` (与 backend `card_number` 字段一致)
47    /// - 末 4 位 suffix: `"7680"` (App 显示的 "保证金综合账户(7680)" 末 4 位)
48    ///
49    /// daemon 启动后调 GetAccList → 把 card_num 列表 resolve 成 acc_id, **合并**
50    /// 进 `allowed_acc_ids` (内部 storage 仍 u64). 多 suffix 撞 → log warn + skip
51    /// 该条 (loud, 不静默接受). 找不到 → log warn + skip (用户后续 cache load
52    /// 后可补回, 不 abort 防影响其他 keys).
53    ///
54    /// **设计动机** (用户 2026-04-29 反馈): App 显示的是 card_num 末 4 位
55    /// "保证金综合账户(7680)" / 完整 16 位 `1001100100800000`, 用户看不到内部
56    /// `acc_id` (e.g. 281756455983133952). 若 keys.json 只接受 acc_id, 用户必须
57    /// 先调 /api/accounts 拿映射 → 认知负担. 加 allowed_card_nums 后用户可直接
58    /// 写 App 看到的 4 位 / 16 位.
59    ///
60    /// **None / 空 → 不限** (与 allowed_acc_ids 一致语义, 向后兼容).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub allowed_card_nums: Option<Vec<String>>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub expires_at: Option<DateTime<Utc>>,
65    pub created_at: DateTime<Utc>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub note: Option<String>,
68    /// 机器绑定指纹列表(软绑定,见 `machine` 模块)
69    ///
70    /// - `None` → 未启用绑定,所有机器可用(向后兼容 v0.7.0 ~ v0.7.x 早期 key)
71    /// - `Some(vec![])` → 强制锁定(无机器能通过),可用于临时冻结
72    /// - `Some(vec!["<fingerprint_hex>", ...])` → 只允许这些机器
73    ///
74    /// 指纹由 `machine::fingerprint_for(key_id)` 生成,和 key_id 强耦合:
75    /// 同一台机器上不同 key 的指纹不同。
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub allowed_machines: Option<Vec<String>>,
78
79    /// **v1.4.106 F-P2-D**: runtime-only — keys.json 文件源里**原始**
80    /// `allowed_acc_ids` 集合 (load_file 后立即 snapshot, sentinel 注入 + card_num
81    /// expansion 都不影响本字段).
82    ///
83    /// 用途: `expand_allowed_card_nums` 每次重新计算 resolved set 时从本字段
84    /// 起步, 而不是从已 mutate 的 `allowed_acc_ids` 起步. 这样若 keys.json
85    /// 没动但 cache 里某个 acc 不再可见, 重 expand 会自然不再含旧 resolved acc_id.
86    ///
87    /// `None` = 文件源没设 `allowed_acc_ids` (与 file `Option<HashSet>` 区分:
88    /// `Some(empty_set)` 表示文件源显式空, `None` 表示文件源没字段).
89    ///
90    /// `#[serde(skip)]` 不序列化进 keys.json (纯运行时), 不影响文件 wire format.
91    #[serde(skip)]
92    pub raw_explicit_acc_ids: Option<HashSet<u64>>,
93}
94
95impl KeyRecord {
96    /// 生成新 key:返回 (plaintext, record)
97    ///
98    /// plaintext 只会返回给调用方一次,必须立即展示给用户;record 落盘。
99    #[must_use = "丢弃生成结果会丢失 plaintext; 调用方必须立即展示给用户"]
100    pub fn generate(
101        id: impl Into<String>,
102        scopes: HashSet<Scope>,
103        limits: Option<Limits>,
104        expires_at: Option<DateTime<Utc>>,
105        note: Option<String>,
106    ) -> (String, KeyRecord) {
107        Self::generate_with_machines(id, scopes, limits, expires_at, note, None)
108    }
109
110    /// 同 [`generate`],但允许一次性设置 `allowed_machines`
111    #[must_use = "丢弃生成结果会丢失 plaintext; 调用方必须立即展示给用户"]
112    pub fn generate_with_machines(
113        id: impl Into<String>,
114        scopes: HashSet<Scope>,
115        limits: Option<Limits>,
116        expires_at: Option<DateTime<Utc>>,
117        note: Option<String>,
118        allowed_machines: Option<Vec<String>>,
119    ) -> (String, KeyRecord) {
120        let mut bytes = [0u8; 32];
121        rand::thread_rng().fill_bytes(&mut bytes);
122        let plaintext = hex::encode(bytes);
123        let hash = format!(
124            "sha256:{}",
125            hex::encode(Sha256::digest(plaintext.as_bytes()))
126        );
127        let limits = limits.unwrap_or_default();
128        // v1.4.106 F-P2-D: snapshot 原始 allowed_acc_ids 作 raw 起步集合
129        let raw_explicit_acc_ids = limits.allowed_acc_ids.clone();
130        let record = KeyRecord {
131            id: id.into(),
132            hash,
133            scopes,
134            allowed_markets: limits.allowed_markets,
135            allowed_symbols: limits.allowed_symbols,
136            max_order_value: limits.max_order_value,
137            max_daily_value: limits.max_daily_value,
138            hours_window: limits.hours_window,
139            max_orders_per_minute: limits.max_orders_per_minute,
140            allowed_trd_sides: limits.allowed_trd_sides,
141            allowed_acc_ids: limits.allowed_acc_ids,
142            allowed_card_nums: limits.allowed_card_nums,
143            expires_at,
144            created_at: Utc::now(),
145            note,
146            allowed_machines,
147            raw_explicit_acc_ids,
148        };
149        (plaintext, record)
150    }
151
152    /// 检查本机是否在 `allowed_machines` 白名单里(None 时始终通过)
153    pub fn check_machine(&self) -> Result<(), crate::machine::MachineError> {
154        crate::machine::check(&self.id, self.allowed_machines.as_deref())
155    }
156
157    /// 校验明文与当前记录的 hash 是否一致
158    #[must_use]
159    pub fn matches(&self, plaintext: &str) -> bool {
160        let computed = hash_plaintext(plaintext);
161        // 常量时间对比(防时序攻击)
162        let a = self.hash.as_bytes();
163        let b = computed.as_bytes();
164        if a.len() != b.len() {
165            return false;
166        }
167        let mut acc: u8 = 0;
168        for (x, y) in a.iter().zip(b.iter()) {
169            acc |= x ^ y;
170        }
171        acc == 0
172    }
173
174    /// 是否已过期
175    #[must_use]
176    pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
177        self.expires_at.map(|t| now >= t).unwrap_or(false)
178    }
179
180    /// 解析 hours_window 为 (start, end);None 表示不限
181    pub fn hours_range(&self) -> Result<Option<(NaiveTime, NaiveTime)>, String> {
182        let Some(s) = &self.hours_window else {
183            return Ok(None);
184        };
185        let (l, r) = s
186            .split_once('-')
187            .ok_or_else(|| format!("invalid hours_window {s:?}: expect HH:MM-HH:MM"))?;
188        let parse = |p: &str| {
189            NaiveTime::parse_from_str(p.trim(), "%H:%M")
190                .map_err(|e| format!("invalid time {p:?}: {e}"))
191        };
192        Ok(Some((parse(l)?, parse(r)?)))
193    }
194
195    /// 导出为 [`Limits`]
196    #[must_use]
197    pub fn limits(&self) -> Limits {
198        Limits {
199            allowed_markets: self.allowed_markets.clone(),
200            allowed_symbols: self.allowed_symbols.clone(),
201            max_order_value: self.max_order_value,
202            max_daily_value: self.max_daily_value,
203            hours_window: self.hours_window.clone(),
204            max_orders_per_minute: self.max_orders_per_minute,
205            allowed_trd_sides: self.allowed_trd_sides.clone(),
206            allowed_acc_ids: self.allowed_acc_ids.clone(),
207            allowed_card_nums: self.allowed_card_nums.clone(),
208        }
209    }
210}
211
212/// 计算 "sha256:<hex>" 摘要
213#[must_use]
214pub fn hash_plaintext(plaintext: &str) -> String {
215    format!(
216        "sha256:{}",
217        hex::encode(Sha256::digest(plaintext.as_bytes()))
218    )
219}
220
221#[cfg(test)]
222mod tests;