From 1ee0b15bdadfaaea3a445c059ebc452ce2e8f261 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:26:32 +0800 Subject: [PATCH 1/2] feat(redact): cover UUIDs and blockchain credentials Protect UUIDs, raw wallet key and hash shapes, validated wallet encodings, and named trading credentials before forwarding model requests. Preserve JSON boundaries, reversible placeholders, rule actions and exceptions, and keep public identifiers out of credential exfiltration evidence. --- Cargo.lock | 2 + Cargo.toml | 2 + crates/tw-guard/Cargo.toml | 2 + crates/tw-guard/data/redact.yaml | 87 +- crates/tw-guard/src/redact/engine.rs | 4 +- crates/tw-guard/src/redact/flow.rs | 33 + crates/tw-guard/src/redact/rules.rs | 32 +- crates/tw-guard/src/redact/rules/oracle.rs | 1 + .../tw-guard/src/redact/secrets/blockchain.rs | 774 ++++++++++++++++++ .../src/redact/secrets/blockchain/tests.rs | 496 +++++++++++ crates/tw-guard/src/redact/secrets/crypto.rs | 48 +- crates/tw-guard/src/redact/secrets/mod.rs | 1 + .../src/redact/secrets/tests/samples.rs | 3 +- crates/tw-guard/src/tools/net.rs | 32 +- crates/tw-guard/src/trial.rs | 7 +- docs/config.md | 11 + docs/config.zh-CN.md | 11 + docs/security/blockchain-redaction.zh-CN.md | 70 ++ 18 files changed, 1592 insertions(+), 24 deletions(-) create mode 100644 crates/tw-guard/src/redact/secrets/blockchain.rs create mode 100644 crates/tw-guard/src/redact/secrets/blockchain/tests.rs create mode 100644 docs/security/blockchain-redaction.zh-CN.md diff --git a/Cargo.lock b/Cargo.lock index d18da011..1999b0a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3860,7 +3860,9 @@ version = "0.87.1" dependencies = [ "aho-corasick", "base64 0.22.1", + "bech32", "bytes", + "curve25519-dalek", "memchr", "regex", "regex-syntax", diff --git a/Cargo.toml b/Cargo.toml index 8aae482e..70ceddb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -168,6 +168,8 @@ arc-swap = "1" blake3 = "1" # PKCE 的 S256。版本跟着 reqwest 的传递依赖走,不引新的树 sha2 = "0.11" +bech32 = "0.9.1" +curve25519-dalek = { version = "4.1.3", default-features = false, features = ["alloc", "precomputed-tables"] } uuid = { version = "1", features = ["v4", "serde"] } rand = "0.10" # 发给 Codex 后端的请求体用 zstd 压缩(Codex 直连它时就这么发)。只要流式编码, diff --git a/crates/tw-guard/Cargo.toml b/crates/tw-guard/Cargo.toml index fe77160e..28eb12d4 100644 --- a/crates/tw-guard/Cargo.toml +++ b/crates/tw-guard/Cargo.toml @@ -20,6 +20,8 @@ regex-syntax = { workspace = true } memchr = { workspace = true } aho-corasick = { workspace = true } sha2 = { workspace = true } +bech32 = { workspace = true } +curve25519-dalek = { workspace = true } thiserror = { workspace = true } serde_yaml_ng = { workspace = true } tw-dialect = { workspace = true } diff --git a/crates/tw-guard/data/redact.yaml b/crates/tw-guard/data/redact.yaml index 5b105f4c..a3889875 100644 --- a/crates/tw-guard/data/redact.yaml +++ b/crates/tw-guard/data/redact.yaml @@ -44,8 +44,8 @@ # **前缀类的规则不写 `about`**:说明由 build.rs 按前缀和长度生成,和判据不会说两样。 # 加一条前缀类的规则只改这个文件。 # -# 每一条内置规则都要求「前缀明确」或者「结构上无法误认」。代码里的 hash、base64 的图片、 -# UUID 全都长得像「一长串随机字符」,按那个判据匹配会疯狂误报。 +# 凭据规则按前缀、结构或字段位置识别。UUID、裸十六进制和链上地址另作隐私规则, +# 不把这些无从区分用途的格式单独当成密钥外传证据。 kinds: - slug: saved @@ -869,8 +869,87 @@ rules: on_by_default: true name: Extended private key name_zh: 扩展私钥 - about: 'Starts with `xprv`, `tprv`, `yprv` or `zprv` and passes Base58Check' - about_zh: '`xprv`、`tprv`、`yprv`、`zprv` 开头,Base58Check 校验通过' + about: 'BIP32 / SLIP-0132 extended private keys: `xprv`, `tprv`, `yprv`, `Yprv`, `zprv`, `Zprv`, `uprv`, `Uprv`, `vprv` or `Vprv`; valid Base58Check, private marker and secp256k1 scalar' + about_zh: 'BIP32 / SLIP-0132 扩展私钥:`xprv`、`tprv`、`yprv`、`Yprv`、`zprv`、`Zprv`、`uprv`、`Uprv`、`vprv`、`Vprv`;Base58Check、私钥标记及 secp256k1 标量均有效' + + + - id: uuid + kind: device + on_by_default: true + name: UUID / compact identifier + name_zh: UUID/紧凑标识符 + about: 'Hyphenated UUIDs and 32 hexadecimal characters, ignoring case and without version restrictions, including order and trace IDs; compact hashes of the same shape are also replaced' + about_zh: '带连字符的 UUID 和 32 位十六进制串,不区分大小写、不限制版本,包括订单号和追踪 ID;同形状的紧凑哈希也替换' + - id: crypto-hex-key + kind: crypto + on_by_default: true + name: Raw wallet key / 32-byte hash + name_zh: 裸钱包私钥/32 字节哈希 + about: '64 hexadecimal characters with an optional `0x` prefix; covers EVM and TRON private keys as well as indistinguishable transaction hashes and digests' + about_zh: '64 位十六进制串,可带 `0x` 前缀;覆盖 EVM、TRON 裸私钥以及格式无法区分的交易哈希和摘要' + - id: crypto-solana-key + kind: crypto + on_by_default: true + name: Solana keypair + name_zh: Solana 密钥对 + about: '64-byte Ed25519 seed and public key encoded as Base58 or a pasted JSON byte array; the public half must match the seed, excluding signatures of the same length' + about_zh: 'Base58 编码或作为文本粘贴的 JSON 字节数组,含 64 字节 Ed25519 种子与公钥;由种子派生的公钥须一致,排除同长度签名' + - id: crypto-sui-key + kind: crypto + on_by_default: true + name: Sui private key + name_zh: Sui 私钥 + about: '`suiprivkey1` Bech32 encoding with a valid checksum, a supported signature scheme and 32 key bytes' + about_zh: '`suiprivkey1` Bech32 编码,校验和有效,含支持的签名方案和 32 字节密钥' + - id: crypto-stellar-key + kind: crypto + on_by_default: true + name: Stellar secret seed + name_zh: Stellar 私密种子 + about: 'Stellar `S` StrKey seed with the correct version, 32-byte payload and CRC16-XModem checksum' + about_zh: 'Stellar `S` StrKey 种子,版本、32 字节载荷和 CRC16-XModem 校验均有效' + - id: crypto-xrp-seed + kind: crypto + on_by_default: true + name: XRP family seed + name_zh: XRP 账户种子 + about: 'XRP-alphabet Base58Check family seeds, including secp256k1 and Ed25519 version encodings' + about_zh: 'XRP 字母表的 Base58Check 账户种子,包括 secp256k1 和 Ed25519 版本' + - id: crypto-address + kind: financial + on_by_default: true + name: Blockchain account / extended public key + name_zh: 区块链账户/扩展公钥 + about: 'EVM hex addresses, checksummed Bitcoin/TRON/XRP/Stellar addresses, Bitcoin witness addresses and BIP32 / SLIP-0132 extended public keys; also 32-byte Base58 account shapes such as Solana, which lack a checksum' + about_zh: 'EVM 十六进制地址,经校验的 Bitcoin/TRON/XRP/Stellar 地址、Bitcoin 隔离见证地址和 BIP32/SLIP-0132 扩展公钥;也覆盖 Solana 等无校验和的 32 字节 Base58 账户格式' + - id: crypto-secret-field + kind: in-context + on_by_default: true + name: Wallet secret in a field + name_zh: 字段中的钱包机密 + about: 'Literal private keys, seeds, mnemonic/recovery phrases, passphrases, recovery codes and MPC/backup shares in named fields, including `pk`, `privateKey`, `PRIVY_WALLET_PRIVATE_KEY` and Chinese labels; only the value is replaced, with no guessed key length' + about_zh: '按字段识别私钥、种子、助记词、恢复短语、口令、恢复码和 MPC/备份份额,包括 `pk`、`privateKey`、`PRIVY_WALLET_PRIVATE_KEY` 和中文标签;只换值,不猜测密钥长度' + - id: exchange-credential + kind: in-context + on_by_default: true + name: Trading API / signed authentication credential + name_zh: 交易 API/签名鉴权凭证 + about: 'Literal API keys, secrets, passphrases, signing memos, OTPs and authentication signatures in SDK/config fields or trading headers; Binance, OKX, Bybit, Coinbase, Kraken, KuCoin, Bitget, Gate, MEXC, Gemini, Bitfinex, HTX, BitMart, Phemex, Bitstamp, Polymarket and predict.fun; only the value is replaced' + about_zh: 'SDK/配置字段或交易请求头中的 API key、secret、passphrase、签名 Memo、OTP 和鉴权签名;覆盖 Binance、OKX、Bybit、Coinbase、Kraken、KuCoin、Bitget、Gate、MEXC、Gemini、Bitfinex、HTX、BitMart、Phemex、Bitstamp、Polymarket 和 predict.fun;只换值' + - id: crypto-keystore + kind: in-context + on_by_default: true + name: Wallet keystore / ciphertext + name_zh: 钱包密钥库/密文 + about: 'Literal `keystore` or `ciphertext` fields, including Ethereum V3 encrypted key material; pasted JSON objects are protected as text and only the value is replaced' + about_zh: '字面量 `keystore` 或 `ciphertext` 字段,包括 Ethereum V3 加密密钥材料;作为文本粘贴的 JSON 对象也保护,只换值' + - id: crypto-rpc-key + kind: in-context + on_by_default: true + name: Blockchain RPC URL credential + name_zh: 区块链 RPC 地址凭证 + about: 'API credentials in Infura `/v3/`, Alchemy `/v2/`, QuickNode token paths and Helius `api-key` URLs; only the credential part is replaced' + about_zh: 'Infura `/v3/`、Alchemy `/v2/`、QuickNode 令牌路径及 Helius `api-key` URL 中的 API 凭据;只换凭据部分' # ---- 扩充的个人信息规则(判据在 src/redact/personal/) - id: cn-resident-id-15 diff --git a/crates/tw-guard/src/redact/engine.rs b/crates/tw-guard/src/redact/engine.rs index 6fa1ddba..bb481176 100644 --- a/crates/tw-guard/src/redact/engine.rs +++ b/crates/tw-guard/src/redact/engine.rs @@ -748,6 +748,7 @@ pub(super) fn scan_tokens( let mut personal = super::personal::Want::new(set); let on = enabled(set, &secrets, &personal); if on == 0 { + super::secrets::blockchain::scan(text, set, json, None, out); personal.text(text, json, None, out); return None; } @@ -755,7 +756,7 @@ pub(super) fn scan_tokens( text, json, on, - json && (personal.labeled_on() || set.has_custom()), + json && (personal.labeled_on() || set.has_custom() || super::secrets::blockchain::on(set)), ); let spans = (json && !pre.quotes.is_empty()).then(|| pre.string_spans(text.len())); // 整段文本过一遍的(中文助记词;护照机读区、住址……)在按 token 认的前面,和老写法一样 @@ -837,6 +838,7 @@ pub(super) fn scan_tokens( secrets.word_run(text, r.clone(), out); } spaced.finish(text, out); + super::secrets::blockchain::scan(text, set, json, spans.as_deref(), out); secrets.finish(text, set, out); personal.finish(text, out); spans diff --git a/crates/tw-guard/src/redact/flow.rs b/crates/tw-guard/src/redact/flow.rs index 504cd5b7..9ba7662d 100644 --- a/crates/tw-guard/src/redact/flow.rs +++ b/crates/tw-guard/src/redact/flow.rs @@ -621,6 +621,39 @@ mod tests { assert!(ledger.is_empty()); } + #[test] + fn uuid_redaction_observes_or_replaces_written_ids_and_restores_them() { + let value = "7b1e2d60-6f9a-4dd7-8f3e-21b8f94c77a2"; + let input = serde_json::json!({ + "id": value, + "model": value, + "messages": [{"role": "user", "content": format!("order {value}; key {value}")}] + }) + .to_string(); + let rules = RuleSet::defaults(); + for mode in [Mode::Observe, Mode::Enforce] { + let (found, ledger) = look(mode, &rules, input.as_bytes(), S); + assert_eq!(found.len(), 1); + assert_eq!(found[0].rule.id(), "uuid"); + let (out, ledger) = replace(mode, &rules, input.clone().into(), &ledger, S); + let out = String::from_utf8(out.to_vec()).unwrap(); + if mode == Mode::Observe { + assert_eq!(out, input); + assert!(ledger.is_empty()); + } else { + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["id"], value); + assert_eq!(parsed["model"], value); + assert_eq!( + parsed["messages"][0]["content"], + "order <>; key <>" + ); + assert_eq!(ledger.len(), 1); + assert_eq!(crate::redact::replace::restore(&out, &ledger), input); + } + } + } + #[test] fn off_does_not_even_look() { assert!(find(Mode::Off, &RuleSet::defaults(), &body(), S).is_empty()); diff --git a/crates/tw-guard/src/redact/rules.rs b/crates/tw-guard/src/redact/rules.rs index 58f2bffb..6294cc4a 100644 --- a/crates/tw-guard/src/redact/rules.rs +++ b/crates/tw-guard/src/redact/rules.rs @@ -335,8 +335,7 @@ pub const CARD_NETWORKS: &[CardNetwork] = &[ /// 全部内置规则,从 `data/redact.yaml` 编译期生成(见 build.rs)。界面上先按 [`Kind`] 的 /// 顺序分组,组内按这里的顺序(见 `view::redact`);前缀表也按这里的顺序比。 /// -/// API 密钥那一段**只收前缀明确、长度有下限的**。代码里的 hash、base64 的 -/// 图片、UUID 全都长得像「一长串随机字符」,按那个判据匹配会疯狂误报。 +/// API 密钥按前缀、结构或上下文识别;UUID、链上地址和裸十六进制另作隐私规则。 pub const BUILTINS: &[Builtin] = include!(concat!(env!("OUT_DIR"), "/redact_builtins.rs")); /// 一个分组叫什么(`data/redact.yaml` 的 `kinds`)。 @@ -410,6 +409,17 @@ const DETECTORS: &[(&str, Matcher)] = &[ ("crypto-mnemonic", Matcher::Code), ("crypto-wif-key", Matcher::Code), ("crypto-extended-key", Matcher::Code), + ("uuid", Matcher::Code), + ("crypto-hex-key", Matcher::Code), + ("crypto-solana-key", Matcher::Code), + ("crypto-sui-key", Matcher::Code), + ("crypto-stellar-key", Matcher::Code), + ("crypto-xrp-seed", Matcher::Code), + ("crypto-address", Matcher::Code), + ("crypto-secret-field", Matcher::Code), + ("exchange-credential", Matcher::Code), + ("crypto-keystore", Matcher::Code), + ("crypto-rpc-key", Matcher::Code), // 扩充的个人信息规则(personal/) ("cn-resident-id-15", Matcher::Code), ("passport-mrz", Matcher::Code), @@ -2398,6 +2408,16 @@ pub fn masked(rule: &Rule, value: &str) -> String { { return mask_email(value); } + if matches!( + rule.id(), + "uuid" + | "crypto-secret-field" + | "exchange-credential" + | "crypto-keystore" + | "crypto-rpc-key" + ) { + return "…".to_string(); + } match rule.kind() { Kind::Internal => value.to_string(), k if k.is_personal() => last_four(value), @@ -2406,7 +2426,7 @@ pub fn masked(rule: &Rule, value: &str) -> String { // 按位置认的多半是口令,没有前缀:留头五个就是留了大半个口令。只留最后四位,短的全打掉 Kind::InContext => masked_unknown(value), // 助记词的头几个字母就是头一个词 - Kind::Crypto if rule.id() == "crypto-mnemonic" => "…".to_string(), + Kind::Crypto => "…".to_string(), _ => mask(value), } } @@ -2658,8 +2678,6 @@ mod tests { // **宁可漏,不可吵。**一个天天误报的安全功能,用户第二天就关了。 for t in [ "把 sk-test 换成你自己的 key", - "const hash = 'a3f5c9e1b7d2f4a6c8e0b2d4f6a8c0e2';", - "UUID 是 550e8400-e29b-41d4-a716-446655440000", "版本 1.2.3,路径 a.b.c", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==", // 全字母或全数字的一长串是 hash / id,不是 key @@ -3154,6 +3172,10 @@ mod tests { } Matcher::Code if b.kind.is_personal() => { let text = super::super::personal::tests::sample(b.id) + .or_else(|| { + super::super::secrets::tests::samples::sample(b.id) + .map(|s| s.hits[0].0.clone()) + }) .unwrap_or_else(|| panic!("{} 没有样本", b.id)); let hits = scan_plain(&text, &only); assert!(!hits.is_empty(), "{} 认不出自己的样本:{text}", b.id); diff --git a/crates/tw-guard/src/redact/rules/oracle.rs b/crates/tw-guard/src/redact/rules/oracle.rs index 6f30079d..03548e75 100644 --- a/crates/tw-guard/src/redact/rules/oracle.rs +++ b/crates/tw-guard/src/redact/rules/oracle.rs @@ -86,6 +86,7 @@ pub(crate) fn scan_unresolved(text: &str, set: &RuleSet) -> Vec { } }); spaced.finish(text, &mut out); + super::super::secrets::blockchain::scan(text, set, json, None, &mut out); secrets.finish(text, set, &mut out); personal.finish(text, &mut out); custom_hits(text, set, json, &mut out); diff --git a/crates/tw-guard/src/redact/secrets/blockchain.rs b/crates/tw-guard/src/redact/secrets/blockchain.rs new file mode 100644 index 00000000..ca718fbe --- /dev/null +++ b/crates/tw-guard/src/redact/secrets/blockchain.rs @@ -0,0 +1,774 @@ +//! Wallet encodings, opaque trading credentials, and blockchain identifiers. +//! +//! Format candidates are validated before use. UUIDs, raw hex and public addresses +//! deliberately cover identifiers as well as keys; they are not evidence of theft. +//! Context rules replace literal values, including recovery material without a +//! recognisable encoding. All spans stay inside JSON strings. + +use std::ops::Range; +use std::sync::OnceLock; + +use bech32::FromBase32; +use regex::Regex; +use sha2::{Digest, Sha256, Sha512}; + +use super::super::rules::{Hit, RuleSet, builtin_hit, json_string_spans}; +use super::crypto::{base58, base58_alphabet, base58check}; + +pub(in crate::redact) const IDS: &[&str] = &[ + "uuid", + "crypto-hex-key", + "crypto-solana-key", + "crypto-sui-key", + "crypto-stellar-key", + "crypto-xrp-seed", + "crypto-address", + "crypto-secret-field", + "exchange-credential", + "crypto-keystore", + "crypto-rpc-key", +]; + +pub(in crate::redact) fn on(set: &RuleSet) -> bool { + IDS.iter().any(|id| set.is_on(id)) +} + +fn shapes() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(concat!( + r"(?i:[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}|(?:0x)?[0-9a-f]{64}|0x[0-9a-f]{40}|[0-9a-f]{32})", + r"|(?i:suiprivkey1[023456789acdefghjklmnpqrstuvwxyz]{59}|(?:bc1|tb1)[023456789acdefghjklmnpqrstuvwxyz]{11,87})", + r"|[SG][A-Z2-7]{55}|[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{25,112}" + )).expect("wallet candidate regex")) +} + +fn names() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(&format!("(?P{})(?:$|[^a-zA-Z0-9_])", concat!( + r"(?i:[a-z0-9_.-]*(?:private[_-]?key(?:[_-]?(?:bytes|hex))?|secret[_-]?key(?:[_-]?(?:bytes|hex))?|secret[_-]?uri|keypair|api[_-]?key|api[_-]?secret|access[_-]?key(?:[_-]?id)?|wallet[_-]?secret|key[_-]?secret|passphrase|mnemonic|seed(?:[_-]?(?:phrase|hex|bytes))?|recovery[_-]?(?:phrase|seed|codes?|shares?)|backup[_-]?(?:codes?|shares?)|mpc[_-]?share|totp[_-]?secret|signature|ciphertext|keystore|api[_-]?memo|api[_-]?sign|password|secret|otp|key|sign|memo|pk))", + r"|(?i:x-mbx-apikey|ok-access-(?:key|passphrase|sign)|x-bapi-(?:api-key|sign)|cb-access-(?:key|passphrase|sign)|kc-api-(?:key|passphrase|sign)|access-(?:key|passphrase|sign)|x-mexc-apikey|x-gemini-(?:apikey|payload|signature)|x-bm-(?:key|sign)|x-phemex-access-token|x-auth(?:-signature)?|x-wallet-auth|poly_(?:api_key|passphrase|signature|builder_api_key|builder_passphrase|builder_signature)|relayer_api_key|api-sign)", + r"|助记词密码|私钥|助记词|种子短语|恢复短语|恢复码|备份份额" + ))).expect("credential field regex")) +} + +/// Context parsing is also run on decoded JSON strings. This recognises escaped +/// labels and nested JSON without ever replacing a JSON number or delimiter. +/// Raw JSON field assignments are scanned separately and limited to string values. +pub(in crate::redact) fn scan( + text: &str, + set: &RuleSet, + json: bool, + spans: Option<&[Range]>, + out: &mut Vec, +) { + if !on(set) { + return; + } + let mut context_hits = Vec::new(); + if json { + let owned; + let ss = if let Some(spans) = spans { + spans + } else { + owned = json_string_spans(text); + &owned + }; + // Real JSON fields. Their values can be strings; the textual scan below + // also covers JSON/config documents pasted inside a chat string. + context(text, set, Some(ss), &mut context_hits); + for s in ss { + let raw = &text[s.clone()]; + let mut hits = Vec::new(); + if !raw.contains('\\') { + scan_shapes(raw, set, &mut hits); + if set.is_on("crypto-solana-key") { + arrays(raw, None, &mut hits); + } + for mut h in hits.drain(..) { + h.bytes = s.start + h.bytes.start..s.start + h.bytes.end; + out.push(h); + } + context(raw, set, None, &mut hits); + for mut h in hits { + h.bytes = s.start + h.bytes.start..s.start + h.bytes.end; + context_hits.push(h); + } + } else { + let literal = crate::locate::Literal::new(text, s.clone()); + let decoded = crate::locate::unescape(raw); + // A JSON key may itself use Unicode escapes. Match its decoded + // spelling while keeping the value span in the original JSON. + if text + .get(s.end + 1..) + .is_some_and(|tail| tail.trim_start().starts_with(':')) + && names() + .captures(&decoded) + .and_then(|c| c.name("name")) + .is_some_and(|m| m.start() == 0 && m.end() == decoded.len()) + && let Some(id) = field_id(&decoded).filter(|id| set.is_on(id)) + && let Some((r, _, true)) = value(text, s.end) + && inside(ss, r.clone()) + && literal_value(&crate::locate::unescape(&text[r.clone()])) + { + context_hits.push(builtin_hit(id, r)); + } + scan_shapes(&decoded, set, &mut hits); + if set.is_on("crypto-solana-key") { + arrays(&decoded, None, &mut hits); + } + for mut h in hits.drain(..) { + h.bytes = literal.raw_at(h.bytes.start)..literal.raw_at(h.bytes.end); + out.push(h); + } + context(&decoded, set, None, &mut hits); + for mut h in hits { + h.bytes = literal.raw_at(h.bytes.start)..literal.raw_at(h.bytes.end); + context_hits.push(h); + } + } + } + } else { + scan_shapes(text, set, out); + if set.is_on("crypto-solana-key") { + arrays(text, None, out); + } + context(text, set, None, &mut context_hits); + } + // Prefer a complete, replacing format/saved-secret hit over its field hit. + // Partial matches and record-only rules must never expose the rest of a value. + if context_hits.is_empty() { + return; + } + let mut cover: Vec> = out + .iter() + .filter(|h| !set.records(&h.rule)) + .map(|h| h.bytes.clone()) + .collect(); + cover.sort_by_key(|r| r.start); + let mut merged: Vec> = Vec::new(); + for r in cover { + if let Some(last) = merged.last_mut() + && r.start <= last.end + { + last.end = last.end.max(r.end); + } else { + merged.push(r); + } + } + for h in context_hits { + let k = merged.partition_point(|r| r.start <= h.bytes.start); + if k == 0 || merged[k - 1].end < h.bytes.end { + out.push(h); + } + } +} + +fn scan_shapes(text: &str, set: &RuleSet, out: &mut Vec) { + if !IDS[..7].iter().any(|id| set.is_on(id)) { + return; + } + for m in shapes().find_iter(text) { + if !boundary(text.as_bytes(), m.start(), m.end()) { + continue; + } + let t = m.as_str(); + let id = if uuid(t) { + "uuid" + } else if hex_key(t) { + "crypto-hex-key" + } else if set.is_on("crypto-solana-key") && solana(t) { + "crypto-solana-key" + } else if set.is_on("crypto-sui-key") + && t.get(..11) + .is_some_and(|p| p.eq_ignore_ascii_case("suiprivkey1")) + && sui(t) + { + "crypto-sui-key" + } else if set.is_on("crypto-stellar-key") && stellar(t, 18 << 3) { + "crypto-stellar-key" + } else if set.is_on("crypto-xrp-seed") && xrp_seed(t) { + "crypto-xrp-seed" + } else if set.is_on("crypto-address") && address(t) { + "crypto-address" + } else { + continue; + }; + if set.is_on(id) { + out.push(builtin_hit(id, m.range())); + } + } +} + +fn inside(ss: &[Range], r: Range) -> bool { + let k = ss.partition_point(|s| s.start <= r.start); + k > 0 && r.end <= ss[k - 1].end +} + +fn word(c: u8) -> bool { + c.is_ascii_alphanumeric() || c == b'_' +} +fn boundary(b: &[u8], s: usize, e: usize) -> bool { + (s == 0 || !word(b[s - 1])) && (e == b.len() || !word(b[e])) +} + +fn uuid(t: &str) -> bool { + let b = t.as_bytes(); + (b.len() == 32 && b.iter().all(u8::is_ascii_hexdigit)) + || (b.len() == 36 + && b.iter().enumerate().all(|(i, c)| { + if matches!(i, 8 | 13 | 18 | 23) { + *c == b'-' + } else { + c.is_ascii_hexdigit() + } + })) +} +fn hex_key(t: &str) -> bool { + let t = t + .strip_prefix("0x") + .or_else(|| t.strip_prefix("0X")) + .unwrap_or(t); + t.len() == 64 && t.bytes().all(|c| c.is_ascii_hexdigit()) +} + +/// A Solana keypair is seed || public key. Signatures have the same length and +/// alphabet, so derive the Ed25519 public half instead of trusting length. +fn solana(t: &str) -> bool { + if !(86..=88).contains(&t.len()) { + return false; + } + let Some(v) = base58(t.as_bytes()).filter(|v| v.len() == 64) else { + return false; + }; + solana_bytes(&v) +} + +fn solana_bytes(v: &[u8]) -> bool { + if v.len() != 64 { + return false; + } + let h = Sha512::digest(&v[..32]); + let mut scalar = [0u8; 32]; + scalar.copy_from_slice(&h[..32]); + let public = curve25519_dalek::edwards::EdwardsPoint::mul_base_clamped(scalar).compress(); + public.as_bytes()[..] == v[32..] +} +fn arrays(text: &str, strings: Option<&[Range]>, out: &mut Vec) { + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| { + Regex::new(r"\[\s*[0-9]{1,3}(?:\s*,\s*[0-9]{1,3}){63}\s*\]").expect("Solana array regex") + }); + for m in re.find_iter(text) { + if strings.is_some_and(|ss| !inside(ss, m.range())) { + continue; + } + if serde_json::from_str::>(m.as_str()).is_ok_and(|v| solana_bytes(&v)) { + out.push(builtin_hit("crypto-solana-key", m.range())); + } + } +} + +fn sui(t: &str) -> bool { + let Ok((hrp, data, variant)) = bech32::decode(t) else { + return false; + }; + if hrp != "suiprivkey" || variant != bech32::Variant::Bech32 { + return false; + } + Vec::::from_base32(&data).is_ok_and(|v| v.len() == 33 && matches!(v[0], 0..=2)) +} +fn stellar(t: &str, version: u8) -> bool { + if t.len() != 56 { + return false; + } + let mut v = Vec::new(); + let (mut acc, mut bits) = (0u32, 0u32); + for c in t.bytes() { + let n = match c { + b'A'..=b'Z' => c - b'A', + b'2'..=b'7' => c - b'2' + 26, + _ => return false, + }; + acc = (acc << 5) | u32::from(n); + bits += 5; + if bits >= 8 { + bits -= 8; + v.push((acc >> bits) as u8); + } + } + if v.len() != 35 || v[0] != version { + return false; + } + let mut crc = 0u16; + for &b in &v[..33] { + crc ^= u16::from(b) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { + (crc << 1) ^ 0x1021 + } else { + crc << 1 + }; + } + } + v[33..] == crc.to_le_bytes() +} +const XRP: &[u8] = b"rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"; +fn xrp(t: &str) -> Option> { + let mut v = base58_alphabet(t.as_bytes(), XRP)?; + if v.len() < 5 { + return None; + } + let sum = v.split_off(v.len() - 4); + (Sha256::digest(Sha256::digest(&v))[..4] == sum).then_some(v) +} +fn xrp_seed(t: &str) -> bool { + (t.starts_with('s') && (29..=31).contains(&t.len())) + && xrp(t).is_some_and(|v| { + (v.len() == 17 && v[0] == 0x21) || (v.len() == 19 && v[..3] == [0x01, 0xe1, 0x4b]) + }) +} +fn address(t: &str) -> bool { + if t.len() == 42 + && (t.starts_with("0x") || t.starts_with("0X")) + && t.as_bytes()[2..].iter().all(u8::is_ascii_hexdigit) + { + return true; + } + if stellar(t, 6 << 3) { + return true; + } + if t.starts_with('r') + && (25..=35).contains(&t.len()) + && xrp(t).is_some_and(|v| v.len() == 21 && v[0] == 0) + { + return true; + } + if t.get(..3) + .is_some_and(|p| p.eq_ignore_ascii_case("bc1") || p.eq_ignore_ascii_case("tb1")) + && let Ok((hrp, data, variant)) = bech32::decode(t) + && (hrp == "bc" || hrp == "tb") + && !data.is_empty() + { + let ver = data[0].to_u8(); + if let Ok(p) = Vec::::from_base32(&data[1..]) { + return (ver == 0 && variant == bech32::Variant::Bech32 && matches!(p.len(), 20 | 32)) + || ((1..=16).contains(&ver) + && variant == bech32::Variant::Bech32m + && (2..=40).contains(&p.len())); + } + } + if t.len() == 111 + && let Some(v) = base58check(t.as_bytes()) + { + const V: [u32; 10] = [ + 0x0488b21e, 0x043587cf, 0x049d7cb2, 0x0295b43f, 0x04b24746, 0x02aa7ed3, 0x044a5262, + 0x024289ef, 0x045f1cf6, 0x02575483, + ]; + if v.len() == 78 + && V.contains(&u32::from_be_bytes(v[..4].try_into().unwrap())) + && matches!(v[45], 2 | 3) + { + return true; + } + } + if (26..=35).contains(&t.len()) + && base58check(t.as_bytes()) + .is_some_and(|v| v.len() == 21 && matches!(v[0], 0 | 5 | 0x6f | 0xc4 | 0x41)) + { + return true; + } + // Solana/Sui legacy 32-byte public keys have no checksum. This is an + // intentionally broad privacy shape, not a verified account or private key. + (32..=44).contains(&t.len()) && base58(t.as_bytes()).is_some_and(|v| v.len() == 32) +} + +fn normalized(t: &str) -> String { + t.chars() + .filter(|&c| c != '_' && c != '-') + .flat_map(char::to_lowercase) + .collect() +} + +fn field_id(name: &str) -> Option<&'static str> { + let n = normalized(name); + let leaf = n.rsplit('.').next().unwrap_or(&n); + if [ + "okaccess", "cbaccess", "kcapi", "access", "xbapi", "xgemini", "xbm", "poly", + ] + .iter() + .any(|s| leaf.starts_with(s)) + || matches!( + leaf, + "xauth" | "xauthsignature" | "xwalletauth" | "xphemexaccesstoken" + ) + { + return Some("exchange-credential"); + } + if leaf == "keystore" { + return Some("crypto-keystore"); + } + if leaf == "ciphertext" { + return Some("crypto-keystore"); + } + if [ + "privatekey", + "privatekeybytes", + "privatekeyhex", + "secretkey", + "secretkeybytes", + "secretkeyhex", + "secreturi", + "keypair", + "seed", + "seedhex", + "seedbytes", + "pk", + "walletsecret", + "keysecret", + "passphrase", + "mnemonic", + "seedphrase", + "recoveryphrase", + "recoverycode", + "recoverycodes", + "recoveryshare", + "recoveryshares", + "backupcode", + "backupcodes", + "backupshare", + "backupshares", + "mpcshare", + "totpsecret", + ] + .iter() + .any(|s| leaf.ends_with(s)) + || matches!( + leaf, + "pk" | "seed" + | "私钥" + | "助记词" + | "种子短语" + | "恢复短语" + | "助记词密码" + | "恢复码" + | "备份份额" + ) + { + return Some("crypto-secret-field"); + } + // Opaque API keys/secrets and signed authentication values have no reliable + // universal length, prefix or alphabet. Literal assignment is the evidence. + if [ + "apikey", + "apisecret", + "accesskey", + "accesskeyid", + "secret", + "signature", + "apisign", + "apimemo", + ] + .iter() + .any(|s| leaf.ends_with(s)) + || matches!( + leaf, + "key" + | "sign" + | "otp" + | "memo" + | "password" + | "xauth" + | "xauthsignature" + | "xwalletauth" + ) + || [ + "okaccess", "cbaccess", "kcapi", "access", "xbapi", "xgemini", "xbm", "poly", + ] + .iter() + .any(|s| leaf.starts_with(s)) + || leaf == "xphemexaccesstoken" + { + return Some("exchange-credential"); + } + None +} + +fn context(text: &str, set: &RuleSet, strings: Option<&[Range]>, out: &mut Vec) { + if !IDS[7..10].iter().any(|id| set.is_on(id)) { + if set.is_on("crypto-rpc-key") { + rpc(text, strings, out); + } + return; + } + let b = text.as_bytes(); + let mut seen = 0; + for cap in names().captures_iter(text) { + let m = cap.name("name").expect("field capture"); + if m.start() < seen || !boundary(b, m.start(), m.end()) { + continue; + } + let Some(id) = field_id(m.as_str()).filter(|id| set.is_on(id)) else { + continue; + }; + if m.start() > 0 && b[m.start() - 1] == b'$' { + continue; + } + if let Some(ss) = strings { + let k = ss.partition_point(|s| s.start <= m.start()); + if k == 0 + || ss[k - 1].start != m.start() + || ss[k - 1].end != m.end() + || b.get(m.end()) != Some(&b'"') + || !text + .get(m.end() + 1..) + .is_some_and(|tail| tail.trim_start().starts_with(':')) + { + continue; + } + } + let Some((mut r, end, quoted)) = value(text, m.end()) else { + continue; + }; + // Bitstamp's X-Auth value has a scheme followed by the API key. + if normalized(m.as_str()) == "xauth" + && !quoted + && text[r.clone()].eq_ignore_ascii_case("BITSTAMP") + { + let from = b[r.end..] + .iter() + .position(|c| !c.is_ascii_whitespace()) + .map(|n| r.end + n); + if let Some(from) = from { + let to = b[from..] + .iter() + .position(|c| c.is_ascii_whitespace() || b"\"'`\\,;".contains(c)) + .map_or(b.len(), |n| from + n); + r = from..to; + } + } + // Plain recovery phrases commonly appear without quotes. + let n = normalized(m.as_str()); + if !quoted + && !text[r.clone()].starts_with(['[', '{']) + && (n.ends_with("mnemonic") + || n.ends_with("phrase") + || matches!(n.as_str(), "助记词" | "恢复短语" | "种子短语")) + { + r.end = text[r.start..] + .find(['\n', '\r']) + .map_or(text.len(), |e| r.start + e); + } + if strings.is_some_and(|ss| !inside(ss, r.clone())) { + continue; + } + seen = end.max(r.end); + let header = m.as_str().contains('-') + || m.as_str().starts_with("POLY_") + || m.as_str().starts_with("RELAYER_"); + if literal_value(&text[r.clone()]) && (quoted || header || bare_literal(&text[r.clone()])) { + out.push(builtin_hit(id, r)); + } + } + if set.is_on("crypto-rpc-key") { + rpc(text, strings, out); + } +} + +/// A literal following a field/header name. Quoted values may contain spaces, +/// Unicode and escaped characters. Arrays/objects are allowed only when the +/// caller proves the entire span is inside a JSON string (text pasted into chat). +fn value(text: &str, mut i: usize) -> Option<(Range, usize, bool)> { + let b = text.as_bytes(); + if matches!(b.get(i), Some(b'"' | b'\'' | b'`')) { + i += 1; + } + while b.get(i).is_some_and(u8::is_ascii_whitespace) { + i += 1; + } + if b.get(i) == Some(&b']') { + i += 1; + } + while b.get(i).is_some_and(u8::is_ascii_whitespace) { + i += 1; + } + match b.get(i) { + _ if text[i..].starts_with(':') => i += ":".len(), + Some(b':' | b'=' | b',') => i += 1, + _ => return None, + } + if matches!(b.get(i), Some(b'=' | b'~' | b'>')) { + return None; + } + while b.get(i).is_some_and(u8::is_ascii_whitespace) { + i += 1; + } + if matches!(b.get(i), Some(b'|' | b'>')) { + return yaml_block(text, i).map(|r| { + let end = r.end; + (r, end, true) + }); + } + let start; + let end; + let quoted = matches!(b.get(i), Some(b'"' | b'\'' | b'`')); + if let Some(&q @ (b'"' | b'\'' | b'`')) = b.get(i) { + start = i + 1; + i = start; + while i < b.len() && b[i] != q { + if b[i] == b'\\' { + i += 1; + } + i += 1; + } + if i >= b.len() { + return None; + } + end = i; + i += 1; + } else if matches!(b.get(i), Some(b'[' | b'{')) { + start = i; + // serde verifies the structure and a bounded lexical walk finds the end. + let mut stream = + serde_json::Deserializer::from_slice(&b[i..]).into_iter::(); + stream.next()?.ok()?; + end = i + stream.byte_offset(); + i = end; + } else { + start = i; + while b + .get(i) + .is_some_and(|c| !c.is_ascii_whitespace() && !b",;\"'`\\]}&#".contains(c)) + { + i += 1; + } + end = i; + if text[start..end].contains(['(', ')', '[', '{']) { + return None; + } + } + let mut after = i; + while b.get(after).is_some_and(|c| matches!(c, b' ' | b'\t')) { + after += 1; + } + // Concatenations, calls, templates, property expressions are code. + if b.get(after).is_some_and(|c| b"+%([.$".contains(c)) { + return None; + } + Some((start..end, i, quoted)) +} + +/// Preserve the first content line's indentation so a pasted YAML block remains +/// valid after replacement. Stop at the next field rather than hiding its value. +fn yaml_block(text: &str, marker: usize) -> Option> { + let line_start = text[..marker].rfind('\n').map_or(0, |i| i + 1); + let indent = text[line_start..marker] + .bytes() + .take_while(|b| matches!(b, b' ' | b'\t')) + .count(); + let newline = marker + text[marker..].find('\n')?; + if !text[marker + 1..newline] + .trim() + .bytes() + .all(|b| b"+-123456789".contains(&b)) + { + return None; + } + let mut at = newline + 1; + let mut first = None; + let mut end = at; + while at < text.len() { + let next = text[at..].find('\n').map_or(text.len(), |n| at + n); + let line = &text[at..next]; + let spaces = line + .bytes() + .take_while(|b| matches!(b, b' ' | b'\t')) + .count(); + if !line.trim().is_empty() { + if spaces <= indent { + break; + } + first.get_or_insert(at + spaces); + end = next; + } + at = next.saturating_add(1); + } + first.map(|start| start..end) +} + +fn placeholder(v: &str) -> bool { + let lower = v.to_ascii_lowercase(); + matches!( + lower.as_str(), + "changeme" + | "change_me" + | "example" + | "placeholder" + | "redacted" + | "dummy" + | "sample" + | "todo" + ) || lower.starts_with("your_") + || lower.starts_with("your-") + || (v.len() >= 3 && v.bytes().all(|c| matches!(c, b'x' | b'X' | b'*'))) +} + +fn literal_value(v: &str) -> bool { + let v = v.trim(); + if v.is_empty() || placeholder(v) { + return false; + } + if v.starts_with('$') + || (v.starts_with('<') && v.ends_with('>')) + || v.contains("process.env") + || v.contains("os.getenv") + || v.contains("${") + || v.contains("{{") + { + return false; + } + if matches!( + v, + "null" | "None" | "true" | "false" | "string" | "str" | "undefined" + ) { + return false; + } + // Numeric arrays/keystores are validated by `value`; literal phrases and + // all-letter passphrases must be protected as well as random-looking keys. + !v.is_empty() && !v.contains("env(") && !v.contains("getenv(") +} + +fn bare_literal(v: &str) -> bool { + // An unquoted identifier is a variable reference. Actual opaque keys normally + // mix character classes; quoted passphrases have no alphabet restriction. + v.starts_with(['[', '{']) + || v.contains(' ') + || !v.is_ascii() + || (v.bytes().any(|c| c.is_ascii_digit()) && v.bytes().any(|c| c.is_ascii_alphabetic())) + || v.bytes().any(|c| b"+/=!@#^".contains(&c)) +} + +fn rpc(text: &str, strings: Option<&[Range]>, out: &mut Vec) { + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| Regex::new(concat!( + r"(?i:https?://(?:[a-z0-9-]+\.)?(?:infura\.io/v3/|g\.alchemy\.com/v2/|quiknode\.pro/)[a-z0-9_%+~.-]+", + r"|https?://(?:[a-z0-9-]+\.)?helius-rpc\.com/\?api-key=[a-z0-9_%+~.-]+)" + )).expect("RPC key URL regex")); + for m in re.find_iter(text) { + let t = m.as_str(); + let at = if let Some(i) = t.find("api-key=") { + i + 8 + } else { + t.rfind('/').unwrap() + 1 + }; + let r = m.start() + at..m.end(); + if !r.is_empty() + && strings.is_none_or(|ss| inside(ss, r.clone())) + && literal_value(&text[r.clone()]) + { + out.push(builtin_hit("crypto-rpc-key", r)); + } + } +} + +#[cfg(test)] +pub(in crate::redact) mod tests; diff --git a/crates/tw-guard/src/redact/secrets/blockchain/tests.rs b/crates/tw-guard/src/redact/secrets/blockchain/tests.rs new file mode 100644 index 00000000..4a6dd63c --- /dev/null +++ b/crates/tw-guard/src/redact/secrets/blockchain/tests.rs @@ -0,0 +1,496 @@ +use super::*; +use crate::redact::replace::{Ledger, Scheme, redact, restore}; +use crate::redact::rules::{Rule, masked, scan, scan_plain}; +use crate::redact::secrets::tests::samples::{HEX, Sample, base58_encode, check_encode, rnd}; +use bech32::ToBase32; + +fn pair(seed: u8) -> Vec { + let seed: Vec = (0..32).map(|i| seed.wrapping_add(i * 7)).collect(); + let h = Sha512::digest(&seed); + let public = + curve25519_dalek::edwards::EdwardsPoint::mul_base_clamped(h[..32].try_into().unwrap()) + .compress(); + [seed, public.as_bytes().to_vec()].concat() +} +fn sui_key(n: u8) -> String { + bech32::encode( + "suiprivkey", + [vec![n % 3], vec![n; 32]].concat().to_base32(), + bech32::Variant::Bech32, + ) + .unwrap() +} +fn stellar_key(n: u8, ver: u8) -> String { + let mut v = [vec![ver], vec![n; 32]].concat(); + let mut crc = 0u16; + for &b in &v { + crc ^= u16::from(b) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { + (crc << 1) ^ 0x1021 + } else { + crc << 1 + }; + } + } + v.extend(crc.to_le_bytes()); + const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let (mut acc, mut bits) = (0u32, 0); + let mut s = String::new(); + for b in v { + acc = (acc << 8) | u32::from(b); + bits += 8; + while bits >= 5 { + bits -= 5; + s.push(A[((acc >> bits) & 31) as usize] as char); + } + } + s +} +fn xrp_key(n: u8, ed: bool) -> String { + let p = [ + if ed { vec![1, 0xe1, 0x4b] } else { vec![0x21] }, + vec![n; 16], + ] + .concat(); + let s = check_encode(&p); + const BTC: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + s.bytes() + .map(|c| XRP[BTC.iter().position(|&b| b == c).unwrap()] as char) + .collect() +} +fn same(s: String) -> (String, String) { + (s.clone(), s) +} +fn assignments(names: &[&str]) -> Sample { + Sample { + hits: names + .iter() + .enumerate() + .map(|(i, n)| { + let v = format!("opaqueCredential{i}Alpha"); + (format!("\"{n}\": \"{v}\""), v) + }) + .collect(), + misses: vec![ + "privateKey = process.env.KEY".into(), + "apiKey: string".into(), + "key = ''".into(), + "key = getSecret()".into(), + ], + } +} +pub(in crate::redact) fn sample(id: &str) -> Option { + Some(match id { + "uuid" => Sample { + hits: vec![ + same("550e8400-e29b-41d4-a716-446655440000".into()), + same(rnd(42, 32, HEX)), + same("ABCDEF12-3456-7890-ABCD-EF1234567890".into()), + ], + misses: vec![ + "x550e8400-e29b-41d4-a716-446655440000".into(), + "a".repeat(64), + "abcdef1234".into(), + ], + }, + "crypto-hex-key" => Sample { + hits: vec![ + same(rnd(4, 64, HEX)), + same(format!("0x{}", rnd(5, 64, HEX))), + same(format!("0X{}", rnd(6, 64, HEX).to_uppercase())), + ], + misses: vec![ + rnd(3, 63, HEX), + rnd(4, 65, HEX), + format!("x{}", rnd(5, 64, HEX)), + ], + }, + "crypto-solana-key" => Sample { + hits: (1..=3).map(|n| same(base58_encode(&pair(n)))).collect(), + misses: vec![ + base58_encode(&[7; 64]), + base58_encode(&pair(4)[..32]), + base58_encode(&[0; 64]), + ], + }, + "crypto-sui-key" => Sample { + hits: (1..=3).map(|n| same(sui_key(n))).collect(), + misses: vec![ + sui_key(1).replacen('1', "2", 1), + bech32::encode( + "suiprivkey", + vec![3; 33].to_base32(), + bech32::Variant::Bech32, + ) + .unwrap(), + bech32::encode( + "suiprivkey", + vec![1; 32].to_base32(), + bech32::Variant::Bech32, + ) + .unwrap(), + ], + }, + "crypto-stellar-key" => Sample { + hits: (1..=3).map(|n| same(stellar_key(n, 18 << 3))).collect(), + misses: vec![stellar_key(1, 6 << 3), "S".repeat(56), "S0".repeat(28)], + }, + "crypto-xrp-seed" => Sample { + hits: vec![ + same(xrp_key(1, false)), + same(xrp_key(2, true)), + same(xrp_key(3, false)), + ], + misses: vec![ + "s".repeat(29), + xrp_key(4, true)[..29].into(), + "s0".repeat(15), + ], + }, + "crypto-address" => Sample { + hits: vec![ + same(format!("0x{}", rnd(13, 40, HEX))), + same(stellar_key(4, 6 << 3)), + same(base58_encode(&pair(4)[..32])), + ], + misses: vec![ + format!("0x{}", rnd(5, 39, HEX)), + "G".repeat(56), + "bc1badchecksum".into(), + ], + }, + "crypto-secret-field" => { + assignments(&["PRIVY_WALLET_PRIVATE_KEY", "mnemonic", "passphrase"]) + } + "exchange-credential" => { + assignments(&["POLY_API_KEY", "PREDICT_API_KEY", "OK-ACCESS-PASSPHRASE"]) + } + "crypto-keystore" => assignments(&["keystore", "ciphertext", "crypto.ciphertext"]), + "crypto-rpc-key" => Sample { + hits: vec![ + ( + "https://mainnet.infura.io/v3/opaqueKeyAlpha".into(), + "opaqueKeyAlpha".into(), + ), + ( + "https://eth-mainnet.g.alchemy.com/v2/opaqueKeyBeta".into(), + "opaqueKeyBeta".into(), + ), + ( + "https://mainnet.helius-rpc.com/?api-key=opaqueKeyGamma".into(), + "opaqueKeyGamma".into(), + ), + ], + misses: vec![ + "https://api.example/v3/opaqueKeyAlpha".into(), + "https://infura.io.evil.example/v3/opaqueKeyAlpha".into(), + "https://mainnet.infura.io/v3/".into(), + ], + }, + _ => return None, + }) +} + +#[test] +fn every_shape_and_context_round_trips_in_chat_json() { + for id in IDS { + let samples = sample(id).unwrap(); + for (text, value) in samples.hits { + let body = serde_json::json!({"messages":[{"role":"user","content":text}]}).to_string(); + let set = RuleSet::only(&[id]); + let hits = scan(&body, &set); + assert_eq!(hits.len(), 1, "{id}: {text} {hits:?}"); + let encoded = serde_json::to_string(&value).unwrap(); + assert_eq!( + &body[hits[0].bytes.clone()], + &encoded[1..encoded.len() - 1], + "{id}" + ); + let r = redact(&body, &set, Ledger::new(Scheme::SECRET)); + serde_json::from_str::(&r.text).unwrap(); + assert_eq!(restore(&r.text, &r.ledger), body, "{id}"); + assert!(!r.text.contains(&value), "{id}"); + let mut stream = crate::redact::stream::Restorer::new(&r.ledger); + let chars: Vec<_> = r.text.chars().collect(); + let mut restored = String::new(); + for chunk in chars.chunks(7) { + restored.push_str(&stream.process(&chunk.iter().collect::())); + } + restored.push_str(&stream.flush()); + assert_eq!(restored, body, "{id}"); + } + } +} + +#[test] +fn trading_headers_and_sdk_fields_only_replace_values() { + let fields = [ + "X-MBX-APIKEY", + "OK-ACCESS-KEY", + "OK-ACCESS-PASSPHRASE", + "OK-ACCESS-SIGN", + "X-BAPI-API-KEY", + "X-BAPI-SIGN", + "CB-ACCESS-KEY", + "CB-ACCESS-PASSPHRASE", + "API-Key", + "API-Sign", + "KC-API-KEY", + "KC-API-PASSPHRASE", + "ACCESS-KEY", + "ACCESS-PASSPHRASE", + "KEY", + "SIGN", + "X-MEXC-APIKEY", + "X-GEMINI-APIKEY", + "X-GEMINI-PAYLOAD", + "X-GEMINI-SIGNATURE", + "API-SECRET", + "AccessKeyId", + "X-BM-KEY", + "X-BM-SIGN", + "API_MEMO", + "x-phemex-access-token", + "X-Auth", + "X-Auth-Signature", + "X-Wallet-Auth", + "POLY_API_KEY", + "POLY_SECRET", + "POLY_PASSPHRASE", + "POLY_SIGNATURE", + "POLY_BUILDER_API_KEY", + "POLY_BUILDER_PASSPHRASE", + "POLY_BUILDER_SIGNATURE", + "POLYMARKET_BUILDER_SECRET", + "RELAYER_API_KEY", + "PREDICT_API_KEY", + "x-api-key", + "apiKey", + "secret", + "passphrase", + "KEY_SECRET", + "WALLET_SECRET", + "WALLET_PRIVATE_KEY", + "PRIVY_WALLET_PRIVATE_KEY", + "SIGNER_PRIVATE_KEY", + "pk", + ]; + for name in fields { + for text in [ + format!("{name}: opaqueValue37Alpha"), + format!("\"{name}\": \"allletters\""), + format!("{name}='a'"), + ] { + let hits = scan_plain(&text, &RuleSet::defaults()); + let vals: Vec<_> = hits.iter().map(|h| &text[h.bytes.clone()]).collect(); + assert_eq!(vals.len(), 1, "{name}: {text} {vals:?}"); + assert!(!vals[0].contains(name), "{text}"); + } + } +} + +#[test] +fn recovery_arrays_unicode_and_escaped_quotes_are_protected() { + for text in [ + "mnemonic: abandon typo partial phrase", + "私钥: '短口令'", + "passphrase: \"correct horse battery staple\"", + "secret: \"punctuation'and\\\"quotes\\nline\"", + "seed = [7, 11, 13, 17]", + "mnemonic = [\"abandon\",\"partial\",\"typo\"]", + "keystore = {\"crypto\":{\"ciphertext\":\"encryptedAlpha37\"},\"version\":3}", + ] { + let body = serde_json::json!({"messages":[{"role":"user","content":text}]}).to_string(); + let r = redact(&body, &RuleSet::defaults(), Ledger::new(Scheme::SECRET)); + assert!(!r.ledger.is_empty(), "{text}"); + serde_json::from_str::(&r.text).unwrap(); + assert_eq!(restore(&r.text, &r.ledger), body, "{text}"); + } + let a = serde_json::to_string(&pair(9)).unwrap(); + let text = format!("Solana keypair: {a}"); + let hits = scan_plain(&text, &RuleSet::only(&["crypto-solana-key"])); + assert_eq!(hits.len(), 1); + assert_eq!(&text[hits[0].bytes.clone()], a); +} + +#[test] +fn numeric_json_and_larger_tokens_are_left_structurally_valid() { + let set = RuleSet::defaults(); + for text in [ + format!( + r#"{{"seed":{},"n":{}}}"#, + serde_json::to_string(&pair(2)).unwrap(), + "1".repeat(32) + ), + format!("x{}y", rnd(42, 64, HEX)), + ] { + assert!(scan(&text, &set).is_empty(), "{text}"); + } +} + +#[test] +fn all_slip132_versions_have_valid_private_payloads() { + for version in [ + 0x0488ade4u32, + 0x04358394, + 0x049d7878, + 0x0295b005, + 0x04b2430c, + 0x02aa7a99, + 0x044a4e28, + 0x024285b5, + 0x045f18bc, + 0x02575048, + ] { + let key = crate::redact::secrets::tests::samples::xprv_of(18, version.to_be_bytes()); + assert_eq!( + scan_plain(&key, &RuleSet::only(&["crypto-extended-key"])).len(), + 1, + "{key}" + ); + let mut payload = base58check(key.as_bytes()).unwrap(); + payload[45] = 2; + assert!( + scan_plain( + &check_encode(&payload), + &RuleSet::only(&["crypto-extended-key"]) + ) + .is_empty() + ); + } +} + +#[test] +fn findings_never_include_wallet_fragments() { + for id in IDS.iter().filter(|id| **id != "crypto-address") { + let (_, v) = sample(id).unwrap().hits.remove(0); + assert_eq!(masked(&Rule::Builtin(id), &v), "…", "{id}"); + } +} + +#[test] +fn negative_samples_and_bitstamp_scheme_are_checked() { + for id in IDS { + for text in sample(id).unwrap().misses { + assert!( + scan_plain(&text, &RuleSet::only(&[id])).is_empty(), + "{id}: {text}" + ); + } + } + let text = "X-Auth: BITSTAMP opaqueValue37Alpha"; + let hits = scan_plain(text, &RuleSet::only(&["exchange-credential"])); + assert_eq!(&text[hits[0].bytes.clone()], "opaqueValue37Alpha"); + let text = "X-MBX-APIKEY: pureletters"; + let hits = scan_plain(text, &RuleSet::only(&["exchange-credential"])); + assert_eq!(&text[hits[0].bytes.clone()], "pureletters"); + let text = "助记词密码:'短口令'"; + assert_eq!( + scan_plain(text, &RuleSet::only(&["crypto-secret-field"])).len(), + 1 + ); +} + +#[test] +fn disabled_recording_and_exceptions_keep_their_meaning() { + let value = "550e8400-e29b-41d4-a716-446655440000"; + assert!(scan_plain(value, &RuleSet::none()).is_empty()); + let set = RuleSet::defaults().recording(Rule::Builtin("uuid")); + assert_eq!(scan_plain(value, &set).len(), 1); + let body = serde_json::json!({"messages":[{"role":"user","content":value}]}).to_string(); + let (r, ledger) = crate::redact::flow::replace( + crate::policy::Mode::Enforce, + &set, + body.clone().into(), + &Ledger::new(Scheme::SECRET), + crate::redact::flow::Shape::Generate(tw_dialect::ir::Dialect::Anthropic), + ); + assert_eq!(r.as_ref(), body.as_bytes()); + assert!(ledger.is_empty()); + let digest = Sha256::digest(value.as_bytes()).into(); + let set = RuleSet::defaults().with_exceptions( + crate::redact::exceptions::Exceptions::new([digest]).map(std::sync::Arc::new), + ); + assert!(scan_plain(value, &set).is_empty()); +} + +#[test] +fn a_non_string_secret_container_does_not_hide_its_nested_string_fields() { + let body = r#"{"api_key":[{"api_key":"opaqueValue37Alpha"}]}"#; + let hits = scan(body, &RuleSet::only(&["exchange-credential"])); + assert_eq!(hits.len(), 1); + assert_eq!(&body[hits[0].bytes.clone()], "opaqueValue37Alpha"); +} + +#[test] +fn base64_and_symbol_first_passphrases_are_literal_secrets() { + for value in ["/base64material+==", "!@#$%", "~allletters", "短", "0000"] { + let text = format!("passphrase: {}", serde_json::to_string(value).unwrap()); + let hits = scan_plain(&text, &RuleSet::only(&["crypto-secret-field"])); + assert_eq!(hits.len(), 1, "{text}"); + } + for text in ["{\"secret", "{\"passphrase", "{\"x-api-key"] { + assert!(scan(text, &RuleSet::defaults()).is_empty(), "{text}"); + } + assert!( + scan_plain( + "X-API-KEY: request.headers[\"key\"]", + &RuleSet::only(&["exchange-credential"]) + ) + .is_empty() + ); +} + +#[test] +fn escaped_json_names_and_uppercase_bech32_are_recognized() { + let body = r#"{"api\u005fkey":"opaqueValue37Alpha"}"#; + let hits = scan(body, &RuleSet::only(&["exchange-credential"])); + assert_eq!(hits.len(), 1); + assert_eq!(&body[hits[0].bytes.clone()], "opaqueValue37Alpha"); + let key = sui_key(1).to_uppercase(); + assert_eq!( + scan_plain(&key, &RuleSet::only(&["crypto-sui-key"])).len(), + 1 + ); + let data = [ + vec![bech32::u5::try_from_u8(0).unwrap()], + vec![7u8; 20].to_base32(), + ] + .concat(); + let addr = bech32::encode("bc", data, bech32::Variant::Bech32) + .unwrap() + .to_uppercase(); + assert_eq!( + scan_plain(&addr, &RuleSet::only(&["crypto-address"])).len(), + 1 + ); +} + +#[test] +fn seed_aliases_and_yaml_blocks_keep_following_fields() { + for name in [ + "WALLET_SEED", + "SOLANA_KEYPAIR", + "privateKeyBytes", + "SUI_SECRET_KEY_HEX", + "secretURI", + "wallet_seed_hex", + "recoverySeed", + "WALLET_PK", + ] { + let text = format!("{name}: 'opaqueAlpha37'"); + let hits = scan_plain(&text, &RuleSet::only(&["crypto-secret-field"])); + assert_eq!(hits.len(), 1, "{text}"); + } + let text = "mnemonic: |\n typo phrase\n additional words\nnext: safe"; + let body = serde_json::to_string(text).unwrap(); + let r = redact( + &body, + &RuleSet::only(&["crypto-secret-field"]), + Ledger::new(Scheme::SECRET), + ); + let sent: String = serde_json::from_str(&r.text).unwrap(); + assert_eq!(sent, "mnemonic: |\n <>\nnext: safe"); + assert_eq!(restore(&r.text, &r.ledger), body); +} diff --git a/crates/tw-guard/src/redact/secrets/crypto.rs b/crates/tw-guard/src/redact/secrets/crypto.rs index 37431dac..963c6f13 100644 --- a/crates/tw-guard/src/redact/secrets/crypto.rs +++ b/crates/tw-guard/src/redact/secrets/crypto.rs @@ -8,7 +8,7 @@ //! - **WIF 私钥**、**扩展私钥**(`xprv` 一类):Base58Check,末四个字节是前面的两次 SHA-256, //! 碰巧对上的机会是四十亿分之一。 //! -//! 64 位十六进制的以太坊私钥和交易哈希、SHA-256 摘要分不开,不收。 +//! 裸十六进制私钥和其他链的编码见 `blockchain`,其中哈希形状作为隐私数据处理。 //! //! 词表取自 BIP39 的官方仓库(bitcoin/bips 的 `bip-0039/`),原样放在 `bip39/` 下,每张 //! 2048 行;测试钉着行数、排序和几组官方测试向量。 @@ -30,6 +30,12 @@ pub(in crate::redact) const KEY_ANCHORS: &[Anchor] = &[ Anchor::Prefix("tprv", 111), Anchor::Prefix("yprv", 111), Anchor::Prefix("zprv", 111), + Anchor::Prefix("Yprv", 111), + Anchor::Prefix("Zprv", 111), + Anchor::Prefix("uprv", 111), + Anchor::Prefix("Uprv", 111), + Anchor::Prefix("vprv", 111), + Anchor::Prefix("Vprv", 111), ]; /// 英文助记词的锚点:一个像 BIP39 词的 token([`qualifies`]),或者一个编号。第一段数的是 @@ -187,15 +193,18 @@ pub(super) fn mnemonic(idx: &[u16]) -> bool { const BASE58: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; /// Base58 解码(比特币的字母表)。前导的 `1` 是前导的零字节 -fn base58(s: &[u8]) -> Option> { - static MAP: OnceLock<[u8; 128]> = OnceLock::new(); - let map = MAP.get_or_init(|| { +pub(super) fn base58(s: &[u8]) -> Option> { + base58_alphabet(s, BASE58) +} + +pub(super) fn base58_alphabet(s: &[u8], alphabet: &[u8]) -> Option> { + let map = { let mut m = [0xFF; 128]; - for (i, &c) in BASE58.iter().enumerate() { + for (i, &c) in alphabet.iter().enumerate() { m[usize::from(c)] = i as u8; } m - }); + }; let mut out: Vec = Vec::with_capacity(s.len()); for &c in s { let d = *map.get(usize::from(c)).filter(|&&d| d != 0xFF)?; @@ -210,7 +219,7 @@ fn base58(s: &[u8]) -> Option> { carry >>= 8; } } - let zeros = s.iter().take_while(|&&c| c == b'1').count(); + let zeros = s.iter().take_while(|&&c| c == alphabet[0]).count(); let mut full = vec![0u8; zeros]; full.extend(out); Some(full) @@ -243,17 +252,25 @@ pub(super) fn wif(tok: &str) -> bool { let Some(v) = base58check(b) else { return false; }; - matches!(v.first(), Some(0x80 | 0xEF)) && (v.len() == 33 || (v.len() == 34 && v[33] == 0x01)) + matches!(v.first(), Some(0x80 | 0xEF)) + && (v.len() == 33 || (v.len() == 34 && v[33] == 0x01)) + && scalar(&v[1..33]) } /// BIP32 扩展私钥:`xprv`(主网)、`tprv`(测试网)、`yprv`、`zprv`(BIP49、BIP84)开头的 /// 111 位,Base58Check 解出来 78 字节、头四个字节是对应的版本号 pub(super) fn extended_key(tok: &str) -> bool { - const VERSIONS: [(&[u8], [u8; 4]); 4] = [ + const VERSIONS: [(&[u8], [u8; 4]); 10] = [ (b"xprv", [0x04, 0x88, 0xAD, 0xE4]), (b"tprv", [0x04, 0x35, 0x83, 0x94]), (b"yprv", [0x04, 0x9D, 0x78, 0x78]), (b"zprv", [0x04, 0xB2, 0x43, 0x0C]), + (b"Yprv", [0x02, 0x95, 0xB0, 0x05]), + (b"Zprv", [0x02, 0xAA, 0x7A, 0x99]), + (b"uprv", [0x04, 0x4A, 0x4E, 0x28]), + (b"Uprv", [0x02, 0x42, 0x85, 0xB5]), + (b"vprv", [0x04, 0x5F, 0x18, 0xBC]), + (b"Vprv", [0x02, 0x57, 0x50, 0x48]), ]; let b = tok.as_bytes(); if b.len() != 111 { @@ -262,7 +279,18 @@ pub(super) fn extended_key(tok: &str) -> bool { let Some((_, version)) = VERSIONS.iter().find(|(p, _)| b.starts_with(p)) else { return false; }; - base58check(b).is_some_and(|v| v.len() == 78 && v[..4] == version[..]) + base58check(b) + .is_some_and(|v| v.len() == 78 && v[..4] == version[..] && v[45] == 0 && scalar(&v[46..])) +} + +/// secp256k1 scalar: 1 <= k < n. +pub(super) fn scalar(v: &[u8]) -> bool { + const N: [u8; 32] = [ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xfe, 0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, + 0x41, 0x41, + ]; + v.len() == 32 && v.iter().any(|&b| b != 0) && v < N.as_slice() } // ---------------------------------------------------------------- 助记词 diff --git a/crates/tw-guard/src/redact/secrets/mod.rs b/crates/tw-guard/src/redact/secrets/mod.rs index f8f17457..374e4d08 100644 --- a/crates/tw-guard/src/redact/secrets/mod.rs +++ b/crates/tw-guard/src/redact/secrets/mod.rs @@ -23,6 +23,7 @@ use super::anchor::Anchor; use super::rules::{Hit, RuleSet}; pub(super) mod assign; +pub(in crate::redact) mod blockchain; pub(super) mod crypto; pub(super) mod place; pub(crate) mod saved; diff --git a/crates/tw-guard/src/redact/secrets/tests/samples.rs b/crates/tw-guard/src/redact/secrets/tests/samples.rs index 520eb35b..6d35ca46 100644 --- a/crates/tw-guard/src/redact/secrets/tests/samples.rs +++ b/crates/tw-guard/src/redact/secrets/tests/samples.rs @@ -80,6 +80,7 @@ pub(crate) fn wif_of(seed: u64, version: u8, compressed: bool) -> String { pub(crate) fn xprv_of(seed: u64, version: [u8; 4]) -> String { let mut p = version.to_vec(); p.extend(bytes(seed, 74)); + p[45] = 0; check_encode(&p) } @@ -547,7 +548,7 @@ pub(crate) fn sample(id: &str) -> Option { format!("xprv{}", "1".repeat(107)), ], }, - _ => return None, + _ => return super::super::blockchain::tests::sample(id), }; Some(s) } diff --git a/crates/tw-guard/src/tools/net.rs b/crates/tw-guard/src/tools/net.rs index fba2ec69..24ca5595 100644 --- a/crates/tw-guard/src/tools/net.rs +++ b/crates/tw-guard/src/tools/net.rs @@ -88,9 +88,11 @@ impl Check { fn credentials() -> &'static RuleSet { static S: OnceLock = OnceLock::new(); S.get_or_init(|| { + // A raw hex value can be a public transaction hash, so its shape alone + // is insufficient evidence of credential exfiltration. let ids: Vec<&str> = BUILTINS .iter() - .filter(|b| b.kind.is_credential()) + .filter(|b| b.kind.is_credential() && b.id != "crypto-hex-key") .map(|b| b.id) .collect(); RuleSet::only(&ids) @@ -508,6 +510,34 @@ mod tests { ))); } + #[test] + fn blockchain_identifiers_are_redacted_without_becoming_theft_evidence() { + for value in [ + "550e8400-e29b-41d4-a716-446655440000".to_string(), + "ab".repeat(32), + format!("0x{}", "ab".repeat(20)), + ] { + assert!( + !fires_a(&format!( + r#"{{"command":"curl https://rpc.example/query?id={value}"}}"# + )), + "{value}" + ); + assert!(!crate::redact::rules::scan_plain(&value, &RuleSet::defaults()).is_empty()); + } + for id in [ + "crypto-wif-key", + "crypto-extended-key", + "crypto-mnemonic", + "crypto-solana-key", + "crypto-sui-key", + "crypto-stellar-key", + "crypto-xrp-seed", + ] { + assert!(credentials().is_on(id), "{id}"); + } + } + #[test] fn commands_that_carry_no_recognised_credential_do_not_fire() { // 这些都带认证,但认证不作为可识别的凭据值出现在参数里 diff --git a/crates/tw-guard/src/trial.rs b/crates/tw-guard/src/trial.rs index fe7b0fd8..d44a2f65 100644 --- a/crates/tw-guard/src/trial.rs +++ b/crates/tw-guard/src/trial.rs @@ -942,14 +942,17 @@ mod tests { Some("Mistral") ); assert_eq!(r.output.as_deref(), Some("key <> ok")); - // 不带就认不出 + // 未保存的同形值仍按 UUID 隐私规则替换,不附带保存项来源 let r = run( Guard::Redact, &s, &req("key 0123456789abcdef0123456789abcdef ok"), ) .unwrap(); - assert!(r.hits.is_empty()); + assert_eq!(r.hits.len(), 1); + assert_eq!(r.hits[0].rule, "uuid"); + assert!(r.hits[0].source.is_none()); + assert_eq!(r.output.as_deref(), Some("key <> ok")); } /// 例外的值标成「例外」,不算命中:不给动作,发出去的样子里原样留着 diff --git a/docs/config.md b/docs/config.md index 65aaa8dd..7d355f4a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1025,6 +1025,17 @@ Built-in rules: | `crypto-mnemonic` | Recovery phrase | on | | `crypto-wif-key` | WIF private key | on | | `crypto-extended-key` | Extended private key | on | +| `uuid` | UUID / compact identifier | on | +| `crypto-hex-key` | Raw wallet key / 32-byte hash | on | +| `crypto-solana-key` | Solana keypair | on | +| `crypto-sui-key` | Sui private key | on | +| `crypto-stellar-key` | Stellar secret seed | on | +| `crypto-xrp-seed` | XRP family seed | on | +| `crypto-address` | Blockchain account / extended public key | on | +| `crypto-secret-field` | Wallet secret in a field | on | +| `exchange-credential` | Trading API / signed authentication credential | on | +| `crypto-keystore` | Wallet keystore / ciphertext | on | +| `crypto-rpc-key` | Blockchain RPC URL credential | on | | `cn-resident-id-15` | 15-digit legacy resident ID number | off | | `passport-mrz` | Passport machine-readable zone | on | | `cn-passport` | Chinese passport number | off | diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index 2b233f66..ccd4e369 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -775,6 +775,17 @@ pricing: | `crypto-mnemonic` | 助记词 | 开 | | `crypto-wif-key` | WIF 私钥 | 开 | | `crypto-extended-key` | 扩展私钥 | 开 | +| `uuid` | UUID/紧凑标识符 | 开 | +| `crypto-hex-key` | 裸钱包私钥/32 字节哈希 | 开 | +| `crypto-solana-key` | Solana 密钥对 | 开 | +| `crypto-sui-key` | Sui 私钥 | 开 | +| `crypto-stellar-key` | Stellar 私密种子 | 开 | +| `crypto-xrp-seed` | XRP 账户种子 | 开 | +| `crypto-address` | 区块链账户/扩展公钥 | 开 | +| `crypto-secret-field` | 字段中的钱包机密 | 开 | +| `exchange-credential` | 交易 API/签名鉴权凭证 | 开 | +| `crypto-keystore` | 钱包密钥库/密文 | 开 | +| `crypto-rpc-key` | 区块链 RPC 地址凭证 | 开 | | `cn-resident-id-15` | 15 位旧身份证号 | 关 | | `passport-mrz` | 护照机读区 | 开 | | `cn-passport` | 中国护照号 | 关 | diff --git a/docs/security/blockchain-redaction.zh-CN.md b/docs/security/blockchain-redaction.zh-CN.md new file mode 100644 index 00000000..ee54ca86 --- /dev/null +++ b/docs/security/blockchain-redaction.zh-CN.md @@ -0,0 +1,70 @@ +# UUID、区块链凭证与账户隐私过滤 + +共享过滤引擎按编码结构和字段位置识别机密。UUID、裸十六进制和链上账户同时作为隐私数据处理:默认启用、默认替换,包括普通订单和追踪 UUID。整体模式为 `observe` 时只记录,`enforce` 时执行规则配置的动作;单条规则仍可关闭、设为仅记录或添加值例外。 + +规则名称、说明和开关由 Core 的双语目录提供,桌面版和企业版直接读取该目录。 + +## 钱包与标识符 + +| 规则 | 范围与校验 | +|---|---| +| `uuid` | 标准 `8-4-4-4-12` 和紧凑 32 位十六进制;大小写均可,不限制版本或 variant;同形 MD5 也替换,保留花括号、URN、URL 路径和文件扩展名 | +| `crypto-hex-key` | 64 位十六进制,可带 `0x`;覆盖 Ethereum、BNB、Polygon、Base、TRON 等裸私钥,以及无法按外观区分的 32 字节交易哈希和摘要 | +| `crypto-mnemonic` | 既有 BIP39 英文、简体中文词表及校验和检测 | +| `crypto-wif-key` | Bitcoin 主网/测试网 WIF;Base58Check、版本、长度、压缩标记和 secp256k1 标量校验 | +| `crypto-extended-key` | BIP32/SLIP-0132 的 xprv、tprv、yprv、Yprv、zprv、Zprv、uprv、Uprv、vprv、Vprv;校验和、私钥标记和标量校验 | +| `crypto-solana-key` | 64 字节 seed+公钥的 Base58 和作为文本粘贴的 JSON 数组;派生 Ed25519 公钥核对后一半,避免将同长度签名识别为私钥 | +| `crypto-sui-key` | suiprivkey Bech32;HRP、校验和、签名方案 0/1/2 和 32 字节密钥 | +| `crypto-stellar-key` | S 开头 StrKey 种子;版本、32 字节载荷及 CRC16-XModem | +| `crypto-xrp-seed` | XRP 字母表 Base58Check;secp256k1 family seed 和 Ed25519 seed 版本 | +| `crypto-address` | EVM、Bitcoin、TRON、XRP、Stellar 地址,Bitcoin 隔离见证地址,BIP32/SLIP-0132 扩展公钥;另覆盖无校验和的 32 字节 Base58 账户格式(如 Solana) | + +`crypto-secret-field` 补充没有稳定编码的机密:privateKey/private_key、secretKey、pk、seed、mnemonic、seedPhrase、recoveryPhrase、passphrase、walletSecret、keySecret、恢复码、TOTP secret、MPC/备份份额及相应中文标签。支持环境变量前缀,例如 WALLET_PRIVATE_KEY、SIGNER_PRIVATE_KEY 和 PRIVY_WALLET_PRIVATE_KEY。按字段识别时不依赖随机度或固定 key 长度,带引号的短口令、纯字母口令、部分/拼错的助记词也保护。 + +`crypto-keystore` 识别 keystore 和 ciphertext 字段,包括 Ethereum V3 加密密钥材料。作为聊天文本粘贴的密钥数组和 keystore 对象可整体替换。 + +完整钱包密钥和新增凭证字段的安全记录完全打码,不保留私钥片段。UUID、哈希和公开地址本身不会触发工具审查中的“密钥发往陌生主机”;有明确私钥编码的材料保留该保护。 + +## 交易平台与签名鉴权 + +不透明 API key 通常没有可通用验证的前缀、长度或字符集。`exchange-credential` 与 `crypto-secret-field` 按 SDK/配置字段、请求头和环境变量识别,只替换值,保留名称和周围结构。 + +| 平台 | 典型字段/请求头 | +|---|---| +| Binance | X-MBX-APIKEY、apiKey、secretKey;RSA/Ed25519 PEM 由既有私钥块规则覆盖 | +| OKX | OK-ACCESS-KEY、OK-ACCESS-PASSPHRASE、OK-ACCESS-SIGN、SecretKey | +| Bybit | X-BAPI-API-KEY、X-BAPI-SIGN、apiSecret | +| Coinbase Exchange/CDP | CB-ACCESS-KEY、CB-ACCESS-PASSPHRASE、CB-ACCESS-SIGN、KEY_SECRET、WALLET_SECRET、X-Wallet-Auth | +| Kraken | API-Key、API-Sign、secret、OTP | +| KuCoin | KC-API-KEY、KC-API-PASSPHRASE、KC-API-SIGN | +| Bitget | ACCESS-KEY、ACCESS-PASSPHRASE、ACCESS-SIGN | +| Gate/Bitfinex/HTX | KEY、SIGN、API-KEY、API-SECRET、AccessKeyId、SecretKey | +| MEXC/Gemini | X-MEXC-APIKEY、X-GEMINI-APIKEY、X-GEMINI-PAYLOAD、X-GEMINI-SIGNATURE | +| BitMart/Phemex/Bitstamp | X-BM-KEY、X-BM-SIGN、API_MEMO、x-phemex-access-token、X-Auth 的 BITSTAMP key、X-Auth-Signature | +| Polymarket CLOB | apiKey、secret、passphrase;POLY_API_KEY、POLY_PASSPHRASE、POLY_SIGNATURE | +| Polymarket Builder/Relayer | key、secret、passphrase;POLY_BUILDER_API_KEY、POLY_BUILDER_PASSPHRASE、POLY_BUILDER_SIGNATURE、POLYMARKET_BUILDER_SECRET、RELAYER_API_KEY | +| predict.fun | x-api-key、PREDICT_API_KEY、WALLET_PRIVATE_KEY、PRIVY_WALLET_PRIVATE_KEY、signature;Bearer JWT 使用既有 JWT 规则 | +| 其他签名交易服务 | Kalshi 的 ACCESS-KEY/SIGNATURE 与 PEM;Hyperliquid、Lighter、dYdX 等使用明确的 secret_key/privateKey/apiKey 字段时由共享字段规则覆盖 | + +Polymarket CLOB 的 UUID key 同时命中 UUID 规则,secret 和 passphrase 分别按字段保护。predict.fun 的 API key 不假定为 UUID。签名材料按字段保护;格式检测不会验证交易是否仍可执行。 + +泛字段 key、secret、password、memo、sign、OTP 中的字面量也会替换,因此公开充值 memo 或代码中的普通 key 字面量可能被隐藏。变量引用、模板、调用和类型声明尽量保留;值例外可用于明确需要发送的公开数据。 + +`crypto-rpc-key` 保护 Infura `/v3/`、Alchemy `/v2/`、QuickNode 令牌路径和 Helius `api-key` URL 中的凭据部分;其他 RPC 服务的命名 API key、Authorization 和 URL token 继续使用通用规则。 + +## 识别边界 + +- 扫描协议定义的文本、工具参数和工具结果范围;协议结构中的模型名、调用 ID、签名字段和媒体载荷沿用既有范围约束。 +- JSON 数值和结构分隔符不直接替换。作为实际工具参数传递的数值型密钥数组不在文本替换范围;作为消息文本粘贴的数组可保护。此约束保证请求仍是合法 JSON。 +- 无标签的短口令、32 字节 Base64 seed、未知格式的密钥、加密备份及未支持语言的助记词,不能保证仅按外观识别。使用明确字段名或保存凭证可补充精确匹配。截图不做 OCR。 +- 过滤是可逆占位符替换;回答回到当前调用方时按账本还原。审计正文落盘保护另受对应产品的存储配置控制。 + +## 格式依据 + +- [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)、[BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki)、[SLIP-0132](https://github.com/satoshilabs/slips/blob/master/slip-0132.md) +- [Solana keypair](https://github.com/anza-xyz/solana-sdk/blob/master/keypair/src/lib.rs)、[Sui SIP-15](https://github.com/sui-foundation/sips/blob/main/sips/sip-15.md) +- [Stellar StrKey](https://github.com/stellar/rs-stellar-strkey/blob/main/src/strkey.rs)、[XRP cryptographic keys](https://xrpl.org/docs/concepts/accounts/cryptographic-keys) +- [Ethereum V3 keystore](https://ethereum.org/en/developers/docs/data-structures-and-encoding/web3-secret-storage/) +- [Polymarket CLOB OpenAPI](https://docs.polymarket.com/api-spec/clob-openapi.yaml)、[predict.fun API](https://api.predict.fun/docs)、[predict.fun SDK](https://github.com/PredictDotFun/sdk/blob/main/README.md) +- [Binance authentication](https://developers.binance.com/docs/binance-spot-api-docs/rest-api/request-security)、[OKX API](https://www.okx.com/docs-v5/en/#overview-rest-authentication)、[Bybit authentication](https://bybit-exchange.github.io/docs/v5/guide) +- [Coinbase Exchange authentication](https://docs.cdp.coinbase.com/exchange/rest-api/authentication)、[Kraken authentication](https://docs.kraken.com/api/docs/guides/spot-rest-auth/)、[KuCoin authentication](https://www.kucoin.com/docs-new/authentication) From 5b37985f266631019c684b9a357c842b3fde0fd4 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:32:30 +0800 Subject: [PATCH 2/2] fix(redact): cover numeric credentials in text fields --- .../tw-guard/src/redact/secrets/blockchain.rs | 1 + .../src/redact/secrets/blockchain/tests.rs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/tw-guard/src/redact/secrets/blockchain.rs b/crates/tw-guard/src/redact/secrets/blockchain.rs index ca718fbe..73b8f07d 100644 --- a/crates/tw-guard/src/redact/secrets/blockchain.rs +++ b/crates/tw-guard/src/redact/secrets/blockchain.rs @@ -741,6 +741,7 @@ fn bare_literal(v: &str) -> bool { // An unquoted identifier is a variable reference. Actual opaque keys normally // mix character classes; quoted passphrases have no alphabet restriction. v.starts_with(['[', '{']) + || v.bytes().all(|c| c.is_ascii_digit()) || v.contains(' ') || !v.is_ascii() || (v.bytes().any(|c| c.is_ascii_digit()) && v.bytes().any(|c| c.is_ascii_alphabetic())) diff --git a/crates/tw-guard/src/redact/secrets/blockchain/tests.rs b/crates/tw-guard/src/redact/secrets/blockchain/tests.rs index 4a6dd63c..0b50861d 100644 --- a/crates/tw-guard/src/redact/secrets/blockchain/tests.rs +++ b/crates/tw-guard/src/redact/secrets/blockchain/tests.rs @@ -494,3 +494,20 @@ fn seed_aliases_and_yaml_blocks_keep_following_fields() { assert_eq!(sent, "mnemonic: |\n <>\nnext: safe"); assert_eq!(restore(&r.text, &r.ledger), body); } + +#[test] +fn numeric_credentials_in_text_are_hidden_without_replacing_json_numbers() { + for text in [ + "OTP: 927416", + "privateKey = 123456789012345678901234567890123456789012345678901234567890123456789012345", + "seed: 42", + ] { + let body = serde_json::json!({"messages":[{"role":"user","content":text}]}).to_string(); + let r = redact(&body, &RuleSet::defaults(), Ledger::new(Scheme::SECRET)); + assert_eq!(r.ledger.len(), 1, "{text}"); + assert_eq!(restore(&r.text, &r.ledger), body); + serde_json::from_str::(&r.text).unwrap(); + } + let body = r#"{"seed":42,"OTP":927416}"#; + assert!(scan(body, &RuleSet::defaults()).is_empty()); +}