futu_backend/auth/redact.rs
1//! v1.4.84 SEC-001 fix: Auth credential redaction for debug log output.
2//!
3//! **背景**: external reviewer security report SEC-001 证实 daemon `--log-level debug` 时
4//! `/tmp/*.log` 明文写入 `tgtgt` / `salt` / `device_sig` 等完整 auth credentials.
5//! 80+ 历史 log 文件暴露 tgtgt. agent 时代, 恶意 skill 一行 `cat /tmp/*.log
6//! | grep tgtgt` 就能拿凭据.
7//!
8//! **修法**: 这个模块提供 `redact_auth_body(&str) -> String` / `redact_kv(&str,
9//! key) -> String` / `redact_auth_json_value(&mut Value)` 三个 helper, 替换
10//! 敏感字段 value 为 `"<REDACTED len=N>"`. 所有 auth debug / info log 打印
11//! body / response 前必须过这些 helper.
12//!
13//! **敏感字段清单** (redact 时替换 value):
14//! - `tgtgt` / `tgtgt_new` — 172 byte base64 AES-256 auth payload
15//! - `salt` / `salt32` — 16/32 char server-provided nonce (TGTGT key 派生)
16//! - `client_sig` / `client_key` / `rand_key` / `rand_key_new` — session keys
17//! - `device_sig` / `device_sig_new` — device 签名 (credentials 持久化)
18//! - `device_id` — 设备标识 (持久化 secret 文件,日志只保留 fingerprint)
19//! - `device_verify_sig` / `device_code` / `device_code_sig` — SMS 2FA 凭据
20//! - `pwd` / `pwd_md5` / `password` — 密码 / MD5
21//! - `auth_token` / `client_token` / `session_id` — session-level tokens
22//! - `web_sig_new` / `ci_sig` — web session tokens
23//! - `auth_code` / `moomoo_client_*` / `moomoo_web_sig_new` — broker / moomoo
24//! auth credentials returned by `/authority/verify_device_code`
25//!
26//! **上下文字段**保留明文: account / device_alias / device_type / os_ver /
27//! sens_state / uid / svr_time / user_attribution / region_no / is_phone —
28//! 这些是 identity / context, 保留方便 debug. `device_id` 虽也是 identity,
29//! 但已按 secret-file 持久化,日志不再保留 raw 值。
30//!
31//! **何时调用**:
32//! - `crates/futu-backend/src/auth/mod.rs`: L765 salt / L883 raw response /
33//! L1071 verify_response / L1203 POST body
34//! - 其他任何 `tracing::debug!` / `info!` 打印 response / body / header 的
35//! log point — 改为 `redact_auth_body` 包装
36
37use regex::Regex;
38use std::sync::OnceLock;
39
40// ============================================================================
41// v1.4.106 codex 0558 F2+F3: PII fingerprint helpers for log fields
42// ============================================================================
43//
44// SEC-001 (v1.4.84) 已 redact auth body 中的 credentials, 但**结构化
45// log 字段** (e.g. `tracing::info!(account = %config.account, ...)`,
46// `device_id = %device_id`, `uid = cred.uid`) 仍以**明文**写入 stdout / 文件.
47// 这些 helper 把 raw PII 哈希成短指纹, 让 log 仍能跨行关联但不可反推真值.
48
49/// v1.4.106 F2: account 字符串 -> 12-char fingerprint `acc-{8-hex}`.
50pub fn account_log_fingerprint(account: &str) -> String {
51 let digest = md5::compute(account.as_bytes());
52 let hex = format!("{:x}", digest);
53 format!("acc-{}", &hex[..8])
54}
55
56/// v1.4.106 F2: device_id (16-hex) -> 12-char fingerprint `dev-{8-hex}`.
57pub fn device_id_log_fingerprint(device_id: &str) -> String {
58 let digest = md5::compute(device_id.as_bytes());
59 let hex = format!("{:x}", digest);
60 format!("dev-{}", &hex[..8])
61}
62
63/// v1.4.106 F3: uid (u64) -> 12-char fingerprint `uid-{8-hex}`.
64pub fn uid_log_fingerprint(uid: u64) -> String {
65 let digest = md5::compute(uid.to_string().as_bytes());
66 let hex = format!("{:x}", digest);
67 format!("uid-{}", &hex[..8])
68}
69
70/// v1.4.84 SEC-001: 敏感字段名清单 (case-sensitive, 匹配 JSON key 或 URL param).
71///
72/// 按字母顺序 + 长 name 优先(避免 `tgtgt_new` 被 `tgtgt` 先 match 误 redact 成
73/// `<REDACTED len=X>_new` 这种怪格式).
74pub const SENSITIVE_FIELDS: &[&str] = &[
75 // 长 name 优先 (sub-string 避免)
76 "device_verify_sig",
77 "device_code_sig",
78 "device_code",
79 "device_sig_new",
80 "device_sig",
81 "device_id",
82 "moomoo_web_sig_new",
83 "moomoo_client_sig",
84 "moomoo_client_key",
85 "tgtgt_new",
86 "tgtgt",
87 "rand_key_new",
88 "rand_key",
89 "client_sig",
90 "client_key",
91 "web_sig_new",
92 "web_sig",
93 "ci_sig",
94 "salt32",
95 "salt",
96 "pwd_md5",
97 "password",
98 "pwd",
99 "auth_token",
100 "auth_code",
101 "client_token",
102 "session_id",
103 // Phase 4 补强 (v1.4.84 增)
104 "tgtgt_b64",
105 "aes_key",
106 "s2", // S2 key 派生中间状态 (auth_cryptor)
107 "s3", // S3 key 派生中间状态
108 "pass_wd",
109];
110
111/// v1.4.84 SEC-001: 给一段 JSON-like / key=value text 做 redaction.
112///
113/// 支持两种格式:
114/// 1. JSON body: `{"tgtgt":"abc","account":"xxx"}` → `{"tgtgt":"<REDACTED
115/// len=3>","account":"xxx"}`
116/// 2. URL-encoded form: `tgtgt=abc&account=xxx` → `tgtgt=<REDACTED len=3>&account=xxx`
117/// 3. 自由格式 (key=value 空格分隔): `salt=abc svr_time=123` → `salt=<REDACTED
118/// len=3> svr_time=123`
119///
120/// 上下文字段 (account / os_ver / uid / svr_time / ...) 保留原值不动;
121/// `device_id` 除外,按 secret-file 处理并 redact。
122pub fn redact_auth_body(body: &str) -> String {
123 let mut result = body.to_string();
124 for field in SENSITIVE_FIELDS {
125 // JSON style: "field":"value"
126 result = redact_json_field(&result, field);
127 // URL / form style: field=value& or field=value (end/whitespace)
128 result = redact_kv_field(&result, field);
129 }
130 result
131}
132
133/// Redact JSON style: `"field":"value"` → `"field":"<REDACTED len=N>"`.
134///
135/// Greedy match value until closing quote (escaped quotes handled by regex
136/// `\\.`). Re-uses compiled regex via OnceLock per field.
137fn redact_json_field(s: &str, field: &str) -> String {
138 // Escape field name for regex literal
139 let pattern = format!(r#""{}"\s*:\s*"((?:[^"\\]|\\.)*)""#, regex::escape(field));
140 let re = match Regex::new(&pattern) {
141 Ok(re) => re,
142 Err(err) => return fail_closed_redacted_body(s, field, "json", err),
143 };
144 re.replace_all(s, |caps: ®ex::Captures| {
145 let value_len = caps.get(1).map(|m| m.as_str().len()).unwrap_or(0);
146 format!(r#""{field}":"<REDACTED len={value_len}>""#)
147 })
148 .to_string()
149}
150
151/// Redact key=value style (URL query / key=value text):
152/// `field=value&` or `field=value(end)` or `field=value(whitespace)` →
153/// `field=<REDACTED len=N>`.
154fn redact_kv_field(s: &str, field: &str) -> String {
155 // Match: (^|[\s&?]) field = value(终止:& 或 空白 或 EOL)
156 // value 截止到 `&` / space / tab / newline / `,` / EOL
157 let pattern = format!(r"(^|[\s&?]){}=([^&\s,\n\r]+)", regex::escape(field));
158 let re = match Regex::new(&pattern) {
159 Ok(re) => re,
160 Err(err) => return fail_closed_redacted_body(s, field, "kv", err),
161 };
162 re.replace_all(s, |caps: ®ex::Captures| {
163 let prefix = caps.get(1).map(|m| m.as_str()).unwrap_or("");
164 let value_len = caps.get(2).map(|m| m.as_str().len()).unwrap_or(0);
165 format!("{prefix}{field}=<REDACTED len={value_len}>")
166 })
167 .to_string()
168}
169
170fn fail_closed_redacted_body(s: &str, field: &str, style: &str, err: regex::Error) -> String {
171 tracing::error!(
172 field,
173 style,
174 error = %err,
175 "auth redaction regex compile failed; redacting whole body"
176 );
177 format!("<REDACTED auth body len={}>", s.len())
178}
179
180/// v1.4.84 SEC-001: 单例 shared emit 打开 `--log-level debug` 时 stderr warn.
181///
182/// Caller 在 `auth::authenticate` 入口第一次进来时调. OnceLock 保证只打一次
183/// 避免每次 auth retry 重复 spam.
184static DEBUG_WARN_EMITTED: OnceLock<()> = OnceLock::new();
185
186pub fn emit_debug_log_security_warn_once() {
187 DEBUG_WARN_EMITTED.get_or_init(|| {
188 // Check if debug-level tracing is enabled. Use tracing LevelFilter check
189 // through a runtime hook. For simplicity we just always emit — auth
190 // path 只在 startup 走一次, cost negligible.
191 eprintln!(
192 "⚠️ SECURITY (v1.4.84 SEC-001): auth debug log 包含 redacted \
193 credentials (tgtgt / salt / device_sig / pwd_md5 等). Log 文件\n\
194 可能被 malware / 恶意 agent skill 读取. 建议:\n\
195 1. 生产环境使用 --log-level info (不 debug)\n\
196 2. 永远不要分享 debug log (GitHub issue / Slack)\n\
197 3. Log 路径放 0700 目录 (默认 /tmp 是 1777 world-readable)\n\
198 v1.4.84 起 auth body 字段中的 credential 已 redact 为 <REDACTED \
199 len=N>. 但 log 分享仍可能泄 account / device_id / IP.\n"
200 );
201 });
202}
203
204#[cfg(test)]
205mod tests;