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..73b8f07d --- /dev/null +++ b/crates/tw-guard/src/redact/secrets/blockchain.rs @@ -0,0 +1,775 @@ +//! 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.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())) + || 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..0b50861d --- /dev/null +++ b/crates/tw-guard/src/redact/secrets/blockchain/tests.rs @@ -0,0 +1,513 @@ +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); +} + +#[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()); +} 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)