diff --git a/src/modules/trezor/callbacks.rs b/src/modules/trezor/callbacks.rs index c80d39a..01be706 100644 --- a/src/modules/trezor/callbacks.rs +++ b/src/modules/trezor/callbacks.rs @@ -175,6 +175,10 @@ pub trait TrezorTransportCallback: Send + Sync { /// debug UI (e.g., TrezorDebugLog on Android) so they are visible /// alongside the Kotlin-level logs. /// + /// Both arguments arrive redacted and length-capped: anything resembling + /// key material is already ``, so consumers need no scrubbing + /// pass of their own. See `log_sanitizer` for the policy. + /// /// # Arguments /// * `tag` - Short tag identifying the subsystem (e.g., "HANDSHAKE", "THP") /// * `message` - Human-readable debug message diff --git a/src/modules/trezor/implementation.rs b/src/modules/trezor/implementation.rs index ea97d89..ac06c98 100644 --- a/src/modules/trezor/implementation.rs +++ b/src/modules/trezor/implementation.rs @@ -307,9 +307,11 @@ impl TransportCallback for CallbackAdapter { self.callback.load_thp_credential(device_id.to_string()) } + /// Redacts here, the last point bitkit-core controls, because the upstream + /// stream is not trusted to be secret-free. fn log_debug(&self, tag: &str, message: &str) { - self.callback - .log_debug(tag.to_string(), message.to_string()); + let (tag, message) = super::log_sanitizer::sanitize_debug_log(tag, message); + self.callback.log_debug(tag, message); } } diff --git a/src/modules/trezor/log_sanitizer.rs b/src/modules/trezor/log_sanitizer.rs new file mode 100644 index 0000000..904a45a --- /dev/null +++ b/src/modules/trezor/log_sanitizer.rs @@ -0,0 +1,317 @@ +//! Redaction of Trezor transport debug output. +//! +//! `TransportCallback::log_debug` forwards diagnostics produced by +//! trezor-connect-rs. That stream belongs to the dependency and its contents +//! change on every bump, so it cannot be assumed free of key material, and +//! consumer-side scrubbing is not a security boundary (see +//! synonymdev/bitkit-android#1067). +//! +//! [`sanitize_debug_log`] therefore runs over everything before it crosses the +//! FFI boundary. Anything resembling a secret becomes ``; only +//! provably harmless values (booleans, counts, byte lengths, `None`) survive +//! under a sensitive label. Value-free diagnostics such as tags, state names +//! and error strings pass through untouched. + +use lazy_regex::{lazy_regex, Lazy}; +use regex::Regex; + +const REDACTED: &str = ""; + +const MAX_TAG_CHARS: usize = 32; + +/// Caps the per-chunk BLE spam that floods the native debug buffer, and bounds +/// anything the passes below fail to recognise as a secret. +const MAX_MESSAGE_CHARS: usize = 512; + +/// How far redaction descends into nested values before it stops looking and +/// blanks the rest. Real diagnostics nest a level or two; a long chain of them +/// is only ever a way to drive this module off the stack. +const MAX_NESTING_DEPTH: usize = 8; + +/// Key fragments whose value is never forwarded, whatever it looks like: a PIN +/// or pairing code is a short integer, so the count exemption must not reach it. +const ALWAYS_SENSITIVE_KEY_FRAGMENTS: &[&str] = &[ + "entropy", + "mnemonic", + "pairingcode", + "passphrase", + "password", + "pin", + "privkey", + "recovery", + "secret", + "seed", +]; + +/// Key fragments that make a bare integer provably a count or a size rather +/// than a numeric token. +const COUNT_KEY_FRAGMENTS: &[&str] = &["count", "index", "len", "num", "offset", "size"]; + +/// Key fragments whose value is forwarded only when [`is_harmless_value`] +/// clears it, keeping diagnostics like `has_credentials=true`. +const SENSITIVE_KEY_FRAGMENTS: &[&str] = &[ + "ciphertext", + "credential", + "key", + "nonce", + "payload", + "plaintext", + "psbt", + "salt", + "session", + "signature", + "token", + "transaction", + "xprv", + "xpub", +]; + +/// Sanitize a `(tag, message)` pair before it is handed to the native +/// `log_debug` callback. +/// +/// Truncation runs first so the redaction passes only ever see a bounded input. +pub fn sanitize_debug_log(tag: &str, message: &str) -> (String, String) { + ( + sanitize(&truncate(tag, MAX_TAG_CHARS)), + sanitize(&truncate(message, MAX_MESSAGE_CHARS)), + ) +} + +fn sanitize(text: &str) -> String { + redact_bare_secrets(&redact_labeled_values(text, 0)) +} + +/// Redact `key=value`, `key: value` and `"key": value` pairs whose key names a +/// secret, unless the value is self-evidently harmless. +fn redact_labeled_values(text: &str, depth: usize) -> String { + static LABELED_VALUE: Lazy = lazy_regex!( + r#"(?x) + (?P[A-Za-z_][A-Za-z0-9_.\-]*) # label, optionally JSON-quoted + "? + \s*[:=]\s* + (?P + "(?:[^"\\]|\\.)*" # quoted string, escapes included + | "(?:[^"\\]|\\.)* # ... or one upstream left unclosed + | \[[^\]]*\] # array + | \{[^}]*\} # object + # count with a spaced-out unit + | -?[0-9]+\s*(?i:bytes|byte|bits|bit|chars|char|kb|mb|ms|b|s)\b + | [^\s,;)\]}"]+ # bare token + )"# + ); + + let mut out = String::with_capacity(text.len()); + let mut cursor = 0; + + for caps in LABELED_VALUE.captures_iter(text) { + let whole = caps.get(0).expect("group 0 always matches"); + if whole.start() < cursor { + continue; // swallowed by the multi-word redaction of an earlier pair + } + let key = &caps["key"]; + let value = &caps["value"]; + let separator = &whole.as_str()[key.len()..whole.len() - value.len()]; + // `seed phrase: ...` is one label written as two words, and the pattern + // above can only capture the last of them. + let label = format!("{}{}", preceding_words(text, whole.start()), key); + + out.push_str(&text[cursor..whole.start()]); + cursor = whole.end(); + + if !is_always_sensitive_key(&label) { + if !is_sensitive_key(&label) { + // Innocuous label, but the value can still nest a pair that is + // not: `context={"token": "hunter2"}`. + out.push_str(key); + out.push_str(separator); + out.push_str(&redact_nested_values(value, depth)); + continue; + } + if is_harmless_value(&label, value) { + out.push_str(whole.as_str()); + continue; + } + if depth < MAX_NESTING_DEPTH && is_sensitive_labeled_pair(value) { + // `credential: host_key=32bytes`, so descend into the inner + // pair instead of blanking the lot. + out.push_str(key); + out.push_str(separator); + out.push_str(&redact_labeled_values(value, depth + 1)); + continue; + } + } + + out.push_str(key); + out.push_str(separator); + out.push_str(&placeholder_for(value)); + cursor = end_of_multiword_value(text, value, cursor); + } + + out.push_str(&text[cursor..]); + out +} + +/// The one or two plain words written immediately before a label. Anything but +/// a bare word ends the run, so only words reading as part of the label count. +fn preceding_words(text: &str, start: usize) -> &str { + static PRECEDING_WORDS: Lazy = lazy_regex!(r"(?:[A-Za-z_][A-Za-z0-9_.\-]*[\t ]+){1,2}$"); + PRECEDING_WORDS + .find(&text[..start]) + .map_or("", |words| words.as_str()) +} + +/// Redact inside the value of a label that is not itself sensitive: the parser +/// above sees flat text and would otherwise forward a nested `{"token": ...}` +/// whole. Past [`MAX_NESTING_DEPTH`] the value is blanked instead of descended +/// into, so an adversarial chain of pairs cannot exhaust the stack. +fn redact_nested_values(value: &str, depth: usize) -> String { + static LABELED_PAIR: Lazy = lazy_regex!(r#"^[A-Za-z_][A-Za-z0-9_.\-]*"?\s*[:=]\s*\S"#); + + if depth >= MAX_NESTING_DEPTH { + return placeholder_for(value); + } + match delimiters(value) { + Some((open, close)) => format!( + "{}{}{}", + open, + redact_labeled_values(&value[1..value.len() - 1], depth + 1), + close + ), + None if LABELED_PAIR.is_match(value) => redact_labeled_values(value, depth + 1), + None => value.to_string(), + } +} + +/// The delimiter pair enclosing a value, if it is enclosed at all. An unclosed +/// quote is not, and must not have its last character mistaken for one. +fn delimiters(value: &str) -> Option<(char, char)> { + let bytes = value.as_bytes(); + let (open, close) = match bytes.first()? { + b'"' => ('"', '"'), + b'[' => ('[', ']'), + b'{' => ('{', '}'), + _ => return None, + }; + (value.len() >= 2 && bytes[value.len() - 1] == close as u8).then_some((open, close)) +} + +/// End of a redacted value, extended past the words the capture left behind. +/// +/// An unquoted secret can be several words long (`mnemonic=abandon ability +/// ...`) and the value pattern stops at the first space. Trailing words are +/// absorbed up to the next delimiter or `key=value` pair, so unrelated +/// diagnostics on the same line stay intact. +fn end_of_multiword_value(text: &str, value: &str, end: usize) -> usize { + static TRAILING_WORD: Lazy = lazy_regex!(r#"^[\t ]+[^\s,;:=(){}\[\]"]+"#); + + if delimiters(value).is_some() { + return end; + } + + let mut end = end; + while let Some(word) = TRAILING_WORD.find(&text[end..]) { + let next = end + word.end(); + // `passphrase=hunter2 pin = 1234`: the separator may be spaced out, and + // absorbing `pin` would leave its own value behind unredacted. + if text[next..] + .trim_start_matches([' ', '\t']) + .starts_with([':', '=']) + { + break; + } + end = next; + } + end +} + +/// Redact secrets carrying no label at all. +fn redact_bare_secrets(text: &str) -> String { + // A base64-encoded PSBT always starts with the `psbt\xff` magic. + static PSBT: Lazy = lazy_regex!(r"\bcHNidP[A-Za-z0-9+/]+=*"); + // xpub/xprv and the ypub/zpub/tpub/upub/vpub variants, mainnet or testnet. + static EXTENDED_KEY: Lazy = + lazy_regex!(r"\b[xyztuvXYZTUV](?:pub|prv)[1-9A-HJ-NP-Za-km-z]{40,}"); + // 16 bytes or more of contiguous hex: frame payloads, txids, serialized txs. + static LONG_HEX: Lazy = lazy_regex!(r"\b[0-9a-fA-F]{32,}\b"); + // The same payloads once a debug formatter has split them into groups: + // `04, 20, 00, ff, ...`, `04 20 00 ff ...`, `04:20:00:ff:...`. + static GROUPED_HEX: Lazy = + lazy_regex!(r"(?i)\b[0-9a-f]{2}(?:[\s,:_-]+[0-9a-f]{2}){7,}\b"); + // `[4, 32, 0, 255, ...]`, Rust's `Debug` for a slice of bytes. + static BYTE_ARRAY: Lazy = lazy_regex!(r"\[\s*[0-9]{1,3}(?:\s*,\s*[0-9]{1,3}){7,}\s*\]"); + // Any other long unbroken base64 run: serialized credentials and the like. + static LONG_BASE64: Lazy = lazy_regex!(r"[A-Za-z0-9+/]{64,}=*"); + + let text = PSBT.replace_all(text, REDACTED); + let text = EXTENDED_KEY.replace_all(&text, REDACTED); + let text = LONG_HEX.replace_all(&text, REDACTED); + let text = GROUPED_HEX.replace_all(&text, REDACTED); + let text = BYTE_ARRAY.replace_all(&text, format!("[{}]", REDACTED)); + LONG_BASE64.replace_all(&text, REDACTED).into_owned() +} + +fn is_sensitive_key(key: &str) -> bool { + matches_fragment(key, SENSITIVE_KEY_FRAGMENTS) || is_always_sensitive_key(key) +} + +fn is_always_sensitive_key(key: &str) -> bool { + matches_fragment(key, ALWAYS_SENSITIVE_KEY_FRAGMENTS) +} + +/// Fold a label down to its letters and digits, then look for any fragment in +/// it, so `host_static_key`, `hostStaticKey` and `"host-static-key"` all hit +/// `key`. Over-matching only costs diagnostic detail; under-matching leaks. +fn matches_fragment(key: &str, fragments: &[&str]) -> bool { + let normalized: String = key + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect(); + fragments + .iter() + .any(|fragment| normalized.contains(fragment)) +} + +fn is_sensitive_labeled_pair(value: &str) -> bool { + static LABELED_PAIR: Lazy = + lazy_regex!(r#"^(?P[A-Za-z_][A-Za-z0-9_.\-]*)"?\s*[:=]\s*\S"#); + LABELED_PAIR + .captures(value) + .is_some_and(|captures| is_sensitive_key(&captures["key"])) +} + +/// Whether a value is safe to forward even under a sensitive label. +/// +/// A number only counts when it says so, by carrying a unit or by its label +/// naming a length. A bare integer under any other sensitive label is as +/// likely to be a numeric token or a code, so it is redacted. +fn is_harmless_value(key: &str, value: &str) -> bool { + static SIZE: Lazy = + lazy_regex!(r"(?i)^-?[0-9]+\s*(b|kb|mb|bit|bits|byte|bytes|char|chars|ms|s)$"); + static BARE_NUMBER: Lazy = lazy_regex!(r"^-?[0-9]+$"); + + if matches!( + value.to_ascii_lowercase().as_str(), + "true" | "false" | "none" | "null" | "nil" | "n/a" | "unknown" | r#""""# | "[]" | "{}" + ) { + return true; + } + SIZE.is_match(value) + || (BARE_NUMBER.is_match(value) && matches_fragment(key, COUNT_KEY_FRAGMENTS)) +} + +/// A placeholder preserving the shape of the value it replaces, so quoted +/// fields stay quoted and arrays stay arrays. +fn placeholder_for(value: &str) -> String { + match delimiters(value) { + Some((open, close)) => format!("{}{}{}", open, REDACTED, close), + None => REDACTED.to_string(), + } +} + +fn truncate(text: &str, max_chars: usize) -> String { + match text.char_indices().nth(max_chars) { + Some((byte_index, _)) => format!("{}…", &text[..byte_index]), + None => text.to_string(), + } +} diff --git a/src/modules/trezor/mod.rs b/src/modules/trezor/mod.rs index 2fc271e..2e1f71c 100644 --- a/src/modules/trezor/mod.rs +++ b/src/modules/trezor/mod.rs @@ -7,6 +7,10 @@ pub mod account_info; mod callbacks; mod errors; mod implementation; +/// Mobile-only, like the callback transport that is its only caller. Also +/// built under `test` so the redaction rules can be exercised on the host. +#[cfg(any(target_os = "android", target_os = "ios", test))] +pub(crate) mod log_sanitizer; #[cfg(test)] mod tests; mod types; diff --git a/src/modules/trezor/tests.rs b/src/modules/trezor/tests.rs index 961e99c..431b2d5 100644 --- a/src/modules/trezor/tests.rs +++ b/src/modules/trezor/tests.rs @@ -938,4 +938,310 @@ mod tests { let _ = adapter.on_passphrase_request(true); assert_eq!(*mock.last_passphrase_on_device.lock().unwrap(), Some(true)); } + + mod log_sanitizer { + use crate::modules::trezor::log_sanitizer::sanitize_debug_log; + + /// Literal fixtures, so the regression test below can grep for them. + const TEST_CREDENTIAL: &str = + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; + const TEST_XPUB: &str = "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL"; + const TEST_PSBT: &str = "cHNidP8BAHUCAAAAASaBcTce3/KF6Tet7qSze3gADAVmy7OtZGQXE8pCFxv2AAAAAAD+////AtPf9QUAAAAAGXapFQ=="; + const TEST_FRAME_HEX: &str = + "042000ff0a20d3f1e5c7b9a84206ff1e2d3c4b5a69788796a5b4c3d2e1f00112233445566778899"; + const TEST_FRAME_GROUPS: &str = "04, 20, 00, ff, 0a, 20, d3, f1, e5, c7, b9, a8"; + const TEST_MNEMONIC: &str = + "abandon ability able about above absent absorb abstract absurd abuse access accident"; + + fn sanitized(message: &str) -> String { + let (_, message) = sanitize_debug_log("THP", message); + message + } + + #[test] + fn test_labeled_credential_is_redacted() { + let output = sanitized(&format!("Loaded credential={}", TEST_CREDENTIAL)); + assert_eq!(output, "Loaded credential="); + } + + #[test] + fn test_labeled_pair_like_secret_is_redacted() { + for (message, expected) in [ + ("credential=SGVsbG8gV29ybGQ=", "credential="), + ( + "host_static_key=c2VjcmV0a2V5bWF0ZXJpYWwxMjM0NTY3ODkw=", + "host_static_key=", + ), + ("session_id=trezor:abcd1234", "session_id="), + ("token=user:hunter2", "token="), + ] { + assert_eq!(sanitized(message), expected); + } + } + + #[test] + fn test_nested_secret_under_innocuous_label_is_redacted() { + for (message, expected) in [ + ( + r#"context={"token":"hunter2"}"#, + r#"context={"token":""}"#, + ), + ( + "request=[passphrase=hunter2]", + "request=[passphrase=]", + ), + ("detail=\"pin=1234\"", "detail=\"pin=\""), + ("state=token:hunter2", "state=token:"), + ] { + assert_eq!(sanitized(message), expected); + } + } + + #[test] + fn test_two_word_label_is_redacted() { + let output = sanitized(&format!("seed phrase: {}", TEST_MNEMONIC)); + assert_eq!(output, "seed phrase: "); + + let output = sanitized("wallet passphrase: correct horse battery"); + assert_eq!(output, "wallet passphrase: "); + } + + #[test] + fn test_spaced_separator_after_multiword_value_is_redacted() { + let output = sanitized("passphrase=hunter2 pin = 1234"); + assert_eq!(output, "passphrase= pin = "); + } + + #[test] + fn test_escaped_quote_does_not_end_a_quoted_secret() { + let output = sanitized(r#"passphrase="hunter\"tail""#); + assert_eq!(output, r#"passphrase="""#); + } + + #[test] + fn test_unclosed_quote_does_not_defeat_redaction() { + // An unclosed quote used to match no value at all, forwarding the + // whole pair verbatim. + assert_eq!(sanitized(r#"passphrase="hunter2"#), "passphrase="); + assert_eq!( + sanitized(&format!(r#"{{"credential": "{}"#, TEST_CREDENTIAL)), + r#"{"credential": "# + ); + // An innocuous value must not lose its last character to a closing + // delimiter that was never there. + assert_eq!(sanitized(r#"state="paired"#), r#"state="paired"#); + } + + #[test] + fn test_labeled_psbt_is_redacted() { + let output = sanitized(&format!("signing psbt={}", TEST_PSBT)); + assert_eq!(output, "signing psbt="); + } + + #[test] + fn test_unexpected_secret_label_is_redacted() { + // Matching on key fragments catches labels nobody enumerated. + let output = sanitized(&format!( + "thp_credential={} master_key={}", + TEST_CREDENTIAL, TEST_XPUB + )); + assert_eq!(output, "thp_credential= master_key="); + } + + #[test] + fn test_json_string_and_array_values_are_redacted() { + let output = sanitized(&format!( + r#"{{"host_static_key": "{}", "credential": [1, 2, 3]}}"#, + TEST_CREDENTIAL + )); + assert_eq!( + output, + r#"{"host_static_key": "", "credential": []}"# + ); + } + + #[test] + fn test_bare_xpub_is_redacted() { + let output = sanitized(&format!("account descriptor {} at m/84'/0'/0'", TEST_XPUB)); + assert_eq!(output, "account descriptor at m/84'/0'/0'"); + } + + #[test] + fn test_bare_frame_hex_is_redacted() { + let output = sanitized(&format!("wrote frame {}", TEST_FRAME_HEX)); + assert_eq!(output, "wrote frame "); + } + + #[test] + fn test_grouped_frame_bytes_are_redacted() { + // Debug formatters split a frame into byte groups, which the + // contiguous-hex pass alone does not recognise. + let output = sanitized( + "wrote frame [04, 20, 00, ff, 0a, 20, d3, f1, e5, c7, b9, a8, 42, 06, ff, 1e]", + ); + assert_eq!(output, "wrote frame []"); + + let output = sanitized("<< 04 20 00 ff 0a 20 d3 f1 e5 c7 b9 a8"); + assert_eq!(output, "<< "); + + let output = sanitized("read [4, 32, 0, 255, 10, 32, 211, 241, 229, 199]"); + assert_eq!(output, "read []"); + } + + #[test] + fn test_multiword_secret_is_redacted_in_full() { + let output = + sanitized("mnemonic=abandon ability able about above absent absorb abstract abuse"); + assert_eq!(output, "mnemonic="); + + // Neighbouring diagnostics still survive. + let output = sanitized("passphrase=correct horse battery staple, state=paired"); + assert_eq!(output, "passphrase=, state=paired"); + + let output = sanitized("passphrase=correct horse battery device=trezor"); + assert_eq!(output, "passphrase= device=trezor"); + } + + #[test] + fn test_bare_numeric_secret_is_redacted() { + let output = sanitized("token=1234567890 session_id=4815162342"); + assert_eq!(output, "token= session_id="); + + let output = sanitized("credential_count=3, key_len=32"); + assert_eq!(output, "credential_count=3, key_len=32"); + } + + #[test] + fn test_bare_psbt_is_redacted() { + let output = sanitized(&format!("tx {}", TEST_PSBT)); + assert_eq!(output, "tx "); + } + + #[test] + fn test_connection_state_passes_through() { + let message = "trezor_state=1 (0=needs pairing, 1=paired, 2=autoconnect)"; + assert_eq!(sanitized(message), message); + } + + #[test] + fn test_error_codes_pass_through() { + let message = "Attempt 2 FAILED: THP Error: DecryptionFailed (error_code: 17)"; + assert_eq!(sanitized(message), message); + } + + #[test] + fn test_byte_lengths_and_booleans_pass_through() { + // Counts and flags under a sensitive label are the diagnostics + // worth keeping, so they must survive redaction. + let message = "Completion payload: 48 bytes (credential_sent=true)"; + assert_eq!(sanitized(message), message); + + let message = "try_to_unlock=false, has_credentials=true"; + assert_eq!(sanitized(message), message); + + let message = "Parsed credential: host_key=32bytes, credential=139bytes"; + assert_eq!(sanitized(message), message); + } + + #[test] + fn test_short_hex_metadata_passes_through() { + let message = "Channel allocated: a1b2"; + assert_eq!(sanitized(message), message); + } + + #[test] + fn test_tag_passes_through() { + let (tag, _) = sanitize_debug_log("HANDSHAKE", "Creating THP session..."); + assert_eq!(tag, "HANDSHAKE"); + } + + #[test] + fn test_long_message_is_truncated() { + let output = sanitized(&"chunk ".repeat(200)); + assert!(output.ends_with("\u{2026}")); + assert!(output.chars().count() < 530); + } + + #[test] + fn test_multibyte_message_truncation_does_not_panic() { + let output = sanitized(&"\u{e9}".repeat(1000)); + assert!(output.ends_with("\u{2026}")); + } + + #[test] + fn test_deeply_chained_pairs_do_not_exhaust_the_stack() { + // Redaction descends into nested values, so an unbounded input + // used to recurse once per link and abort the process. Run on a + // thread with a small stack to catch a regression on mobile. + let worker = std::thread::Builder::new() + .stack_size(256 * 1024) + .spawn(|| sanitized(&("a=".repeat(20_000) + "1"))) + .expect("spawn"); + assert!(worker.join().expect("no stack overflow").len() < 600); + } + + #[test] + fn test_no_fixture_secret_survives_sanitization() { + let fixtures = [ + format!("credential={}", TEST_CREDENTIAL), + format!( + "Stored credential {} for ble:AA:BB:CC:DD:EE:FF", + TEST_CREDENTIAL + ), + format!(r#"{{"credential":"{}"}}"#, TEST_CREDENTIAL), + format!("thp_credential={}", TEST_CREDENTIAL), + format!("xpub={}", TEST_XPUB), + format!("derived {} for account 0", TEST_XPUB), + format!("psbt={}", TEST_PSBT), + format!("Signing {}", TEST_PSBT), + format!("frame={}", TEST_FRAME_HEX), + format!("<< {}", TEST_FRAME_HEX), + format!("frame=[{}]", TEST_FRAME_GROUPS), + format!("<< {}", TEST_FRAME_GROUPS), + format!("mnemonic={}", TEST_MNEMONIC), + format!("recovery: {} (12 words)", TEST_MNEMONIC), + "passphrase=hunter2 pin=1234 mnemonic=[a, b, c]".to_string(), + "session_token=4815162342".to_string(), + "passphrase=hunter2 pin = 1234".to_string(), + r#"passphrase="hunter2\"1234""#.to_string(), + r#"passphrase="hunter2"#.to_string(), + format!( + r#"context={{"passphrase":"hunter2","seed":"{}"}}"#, + TEST_MNEMONIC + ), + format!("seed phrase: {}", TEST_MNEMONIC), + format!("recovery seed = {} pin = 1234", TEST_MNEMONIC), + ]; + + for fixture in fixtures { + for (tag, message) in [ + sanitize_debug_log("THP", &fixture), + sanitize_debug_log(&fixture, "THP"), + ] { + let output = format!("{} {}", tag, message); + for secret in [ + TEST_CREDENTIAL, + TEST_XPUB, + TEST_PSBT, + TEST_FRAME_HEX, + "hunter2", + "1234", + "4815162342", + // Tails, so a redaction covering only the first word or + // byte group still fails the test. + "ability", + "d3, f1", + ] { + assert!( + !output.contains(secret), + "leaked {:?} from fixture {:?}: {:?}", + secret, + fixture, + output + ); + } + } + } + } + } }