Skip to main content

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: &regex::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: &regex::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;