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//! 登录 identity 字段 `account` / `uid` / `user_id` / `customer_id` /
27//! `real_uid` / `origin_uid` 只保留稳定 fingerprint。其他非身份上下文字段
28//! `device_alias` / `device_type` / `os_ver` / `sens_state` / `svr_time` /
29//! `user_attribution` / `region_no` / `is_phone` 继续保留便于 debug。
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    futu_core::log_redact::account_log_fingerprint(account)
52}
53
54/// v1.4.106 F2: device_id (16-hex) -> 12-char fingerprint `dev-{8-hex}`.
55pub fn device_id_log_fingerprint(device_id: &str) -> String {
56    let digest = md5::compute(device_id.as_bytes());
57    let hex = format!("{:x}", digest);
58    format!("dev-{}", &hex[..8])
59}
60
61/// v1.4.106 F3: uid (u64) -> 12-char fingerprint `uid-{8-hex}`.
62pub fn uid_log_fingerprint(uid: u64) -> String {
63    futu_core::log_redact::uid_log_fingerprint(uid)
64}
65
66/// v1.4.84 SEC-001: 敏感字段名清单 (case-sensitive, 匹配 JSON key 或 URL param).
67///
68/// 按字母顺序 + 长 name 优先(避免 `tgtgt_new` 被 `tgtgt` 先 match 误 redact 成
69/// `<REDACTED len=X>_new` 这种怪格式).
70pub const SENSITIVE_FIELDS: &[&str] = &[
71    // 长 name 优先 (sub-string 避免)
72    "device_verify_sig",
73    "device_code_sig",
74    "device_code",
75    "device_sig_new",
76    "device_sig",
77    "device_id",
78    "moomoo_web_sig_new",
79    "moomoo_client_sig",
80    "moomoo_client_key",
81    "tgtgt_new",
82    "tgtgt",
83    "rand_key_new",
84    "rand_key",
85    "client_sig",
86    "client_key",
87    "web_sig_new",
88    "web_sig",
89    "ci_sig",
90    "salt32",
91    "salt",
92    "pwd_md5",
93    "password",
94    "pwd",
95    "auth_token",
96    "auth_code",
97    "client_token",
98    "session_id",
99    // Phase 4 补强 (v1.4.84 增)
100    "tgtgt_b64",
101    "aes_key",
102    "s2", // S2 key 派生中间状态 (auth_cryptor)
103    "s3", // S3 key 派生中间状态
104    "pass_wd",
105];
106
107const ACCOUNT_IDENTITY_FIELDS: &[&str] = &["account"];
108const UID_IDENTITY_FIELDS: &[&str] = &["uid", "user_id", "customer_id", "real_uid", "origin_uid"];
109
110/// v1.4.84 SEC-001: 给一段 JSON-like / key=value text 做 redaction.
111///
112/// 支持两种格式:
113/// 1. JSON body: `{"tgtgt":"abc","account":"xxx"}` → credential redaction
114///    plus an `acc-*` identity fingerprint.
115/// 2. URL-encoded form: `tgtgt=abc&account=xxx` → credential redaction plus
116///    an `acc-*` identity fingerprint.
117/// 3. 自由格式 (key=value 空格分隔): `salt=abc svr_time=123` → `salt=<REDACTED
118///    len=3> svr_time=123`
119///
120/// Login identity fields are fingerprinted; non-identity context remains.
121pub fn redact_auth_body(body: &str) -> String {
122    let mut result = body.to_string();
123    for field in SENSITIVE_FIELDS {
124        // JSON style: "field":"value"
125        result = redact_json_field(&result, field);
126        // URL / form style: field=value& or field=value (end/whitespace)
127        result = redact_kv_field(&result, field);
128    }
129    for field in ACCOUNT_IDENTITY_FIELDS {
130        result = redact_json_identity_field(&result, field, false);
131        result = redact_kv_identity_field(&result, field, false);
132    }
133    for field in UID_IDENTITY_FIELDS {
134        result = redact_json_identity_field(&result, field, true);
135        result = redact_kv_identity_field(&result, field, true);
136    }
137    result
138}
139
140fn identity_fingerprint(value: &str, uid_namespace: bool) -> String {
141    if uid_namespace {
142        futu_core::log_redact::uid_text_log_fingerprint(value)
143    } else {
144        futu_core::log_redact::account_log_fingerprint(value)
145    }
146}
147
148fn redact_json_identity_field(s: &str, field: &str, uid_namespace: bool) -> String {
149    let quoted_pattern = format!(r#""{}"\s*:\s*"((?:[^"\\]|\\.)*)""#, regex::escape(field));
150    let quoted = match Regex::new(&quoted_pattern) {
151        Ok(re) => re,
152        Err(err) => return fail_closed_redacted_body(s, field, "json-identity", err),
153    };
154    let result = quoted
155        .replace_all(s, |caps: &regex::Captures| {
156            let value = caps.get(1).map(|matched| matched.as_str()).unwrap_or("");
157            let fingerprint = identity_fingerprint(value, uid_namespace);
158            format!(r#""{field}":"{fingerprint}""#)
159        })
160        .to_string();
161
162    let numeric_pattern = format!(r#"("{}"\s*:\s*)(-?[0-9]+)"#, regex::escape(field));
163    let numeric = match Regex::new(&numeric_pattern) {
164        Ok(re) => re,
165        Err(err) => return fail_closed_redacted_body(&result, field, "json-identity", err),
166    };
167    numeric
168        .replace_all(&result, |caps: &regex::Captures| {
169            let prefix = caps.get(1).map(|matched| matched.as_str()).unwrap_or("");
170            let value = caps.get(2).map(|matched| matched.as_str()).unwrap_or("");
171            let fingerprint = identity_fingerprint(value, uid_namespace);
172            format!(r#"{prefix}"{fingerprint}""#)
173        })
174        .to_string()
175}
176
177fn redact_kv_identity_field(s: &str, field: &str, uid_namespace: bool) -> String {
178    let pattern = format!(r"(^|[\s&?]){}=([^&\s,\n\r]+)", regex::escape(field));
179    let re = match Regex::new(&pattern) {
180        Ok(re) => re,
181        Err(err) => return fail_closed_redacted_body(s, field, "kv-identity", err),
182    };
183    re.replace_all(s, |caps: &regex::Captures| {
184        let prefix = caps.get(1).map(|matched| matched.as_str()).unwrap_or("");
185        let value = caps.get(2).map(|matched| matched.as_str()).unwrap_or("");
186        let fingerprint = identity_fingerprint(value, uid_namespace);
187        format!("{prefix}{field}={fingerprint}")
188    })
189    .to_string()
190}
191
192/// Redact JSON style: `"field":"value"` → `"field":"<REDACTED len=N>"`.
193///
194/// Greedy match value until closing quote (escaped quotes handled by regex
195/// `\\.`). Re-uses compiled regex via OnceLock per field.
196fn redact_json_field(s: &str, field: &str) -> String {
197    // Escape field name for regex literal
198    let pattern = format!(r#""{}"\s*:\s*"((?:[^"\\]|\\.)*)""#, regex::escape(field));
199    let re = match Regex::new(&pattern) {
200        Ok(re) => re,
201        Err(err) => return fail_closed_redacted_body(s, field, "json", err),
202    };
203    re.replace_all(s, |caps: &regex::Captures| {
204        let value_len = caps.get(1).map(|m| m.as_str().len()).unwrap_or(0);
205        format!(r#""{field}":"<REDACTED len={value_len}>""#)
206    })
207    .to_string()
208}
209
210/// Redact key=value style (URL query / key=value text):
211/// `field=value&` or `field=value(end)` or `field=value(whitespace)` →
212/// `field=<REDACTED len=N>`.
213fn redact_kv_field(s: &str, field: &str) -> String {
214    // Match: (^|[\s&?]) field = value(终止:& 或 空白 或 EOL)
215    // value 截止到 `&` / space / tab / newline / `,` / EOL
216    let pattern = format!(r"(^|[\s&?]){}=([^&\s,\n\r]+)", regex::escape(field));
217    let re = match Regex::new(&pattern) {
218        Ok(re) => re,
219        Err(err) => return fail_closed_redacted_body(s, field, "kv", err),
220    };
221    re.replace_all(s, |caps: &regex::Captures| {
222        let prefix = caps.get(1).map(|m| m.as_str()).unwrap_or("");
223        let value_len = caps.get(2).map(|m| m.as_str().len()).unwrap_or(0);
224        format!("{prefix}{field}=<REDACTED len={value_len}>")
225    })
226    .to_string()
227}
228
229fn fail_closed_redacted_body(s: &str, field: &str, style: &str, err: regex::Error) -> String {
230    tracing::error!(
231        field,
232        style,
233        error = %err,
234        "auth redaction regex compile failed; redacting whole body"
235    );
236    format!("<REDACTED auth body len={}>", s.len())
237}
238
239/// v1.4.84 SEC-001: 单例 shared emit 打开 `--log-level debug` 时 stderr warn.
240///
241/// Caller 在 `auth::authenticate` 入口第一次进来时调. OnceLock 保证只打一次
242/// 避免每次 auth retry 重复 spam.
243static DEBUG_WARN_EMITTED: OnceLock<()> = OnceLock::new();
244
245pub fn emit_debug_log_security_warn_once() {
246    DEBUG_WARN_EMITTED.get_or_init(|| {
247        // Check if debug-level tracing is enabled. Use tracing LevelFilter check
248        // through a runtime hook. For simplicity we just always emit — auth
249        // path 只在 startup 走一次, cost negligible.
250        eprintln!(
251            "⚠️  SECURITY (v1.4.84 SEC-001): auth debug log 包含 redacted \
252             credentials (tgtgt / salt / device_sig / pwd_md5 等). Log 文件\n\
253             可能被 malware / 恶意 agent skill 读取. 建议:\n\
254             1. 生产环境使用 --log-level info (不 debug)\n\
255             2. 永远不要分享 debug log (GitHub issue / Slack)\n\
256             3. Log 路径放 0700 目录 (默认 /tmp 是 1777 world-readable)\n\
257             v1.4.84 起 auth body 字段中的 credential 已 redact 为 <REDACTED \
258             len=N>,登录 identity 仅保留 fingerprint. 但 log 分享仍可能泄 IP 等上下文.\n"
259        );
260    });
261}
262
263#[cfg(test)]
264mod tests;