fix(13-12): seed screen validates the BIP39 checksum, not word shapes

Second on-device failure in one session: after the wordlist fix, dev3
blocked cloud turns AGAIN mid-session as 13-10's history grew — splitting
on every non-alphabetic character let words from unrelated JSON fields
chain into one run. Both failures took the whole feature down rather than
protecting anything, which is the worse failure for a screen to have.

Shape is the wrong signal. A real mnemonic's last word encodes a checksum
over the rest, so an accidental run of English words parses as a mnemonic
only about one time in sixteen. Candidate runs are now validated with the
same bip39 crate the wallet uses:

- tokenize on whitespace (a seed phrase is space-separated); a token's
  leading alphabetic segment counts, and alphanumerics after it end the
  phrase, so a seed glued to a closing quote is still caught
- block only if a 12/15/18/21/24 window parses as a real mnemonic
- IMPLAUSIBLE_MEMBER_RUN (20) backstops checksum-invalid material such as
  a typo'd 24-word seed, which prose cannot plausibly produce

Documented trade-off: a checksum-invalid run under 20 words no longer
blocks. The rule that did block it also blocked every legitimate turn,
twice, on a live node. 15/15 egress tests green, including the real
system prompt, scattered-JSON prose, and a genuine mnemonic in JSON.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 12:42:44 -04:00
co-authored by Claude Fable 5
parent 24a34a373f
commit e681c95131
+126 -18
View File
@@ -203,27 +203,77 @@ fn has_long_hex_run(body: &str, min_len: usize) -> bool {
/// screen). Function words that glue prose together ("the", "is", "of",
/// "you") are not wordlist members, so real sentences break runs; real
/// seed material is nothing but members.
/// A run long enough that prose cannot plausibly produce it. Real 24-word
/// seeds with a typo'd word (checksum-invalid, still leaking 23 correct
/// words) must not walk out just because they fail to parse.
const IMPLAUSIBLE_MEMBER_RUN: usize = 20;
fn has_bip39_length_word_run(body: &str) -> bool {
let words: Vec<&str> = body
.split(|c: char| !c.is_ascii_alphabetic())
.filter(|w| !w.is_empty())
.collect();
if words.len() < 12 {
return false;
}
// Two failures on dev3 (2026-08-06) drove this to a PRECISE test rather
// than a shape guess. First the detector matched any 12 lowercase 3-8
// char words — ordinary prose, including the node's own system prompt.
// Wordlist membership fixed that, but tripped again mid-session as
// 13-10's history grew: splitting on every non-alphabetic character let
// words from UNRELATED JSON fields chain into one run. Both failures
// blocked 100% of that turn's cloud traffic, i.e. the screen took the
// whole feature down rather than protecting anything.
//
// What actually identifies seed material is not shape but CHECKSUM: a
// real BIP39 mnemonic's last word encodes a checksum over the rest, so
// an accidental run of English words parses as a mnemonic only ~1 time
// in 16. Candidate runs are therefore validated with the same bip39
// crate the wallet uses, and blocked only if they genuinely parse —
// zero false negatives for real seeds (every real seed validates), and
// prose stops being collateral. `IMPLAUSIBLE_MEMBER_RUN` is the
// backstop for checksum-invalid-but-still-sensitive material.
let wordlist = bip39::Language::English.word_list();
let mut run = 0usize;
for w in &words {
let lower = w.to_ascii_lowercase();
if w.chars().all(|c| c.is_ascii_lowercase())
&& wordlist.binary_search(&lower.as_str()).is_ok()
{
run += 1;
if run >= 12 {
let mut run: Vec<&str> = Vec::new();
// Tokenize on whitespace: a seed phrase is space-separated words. A
// token may carry punctuation (a JSON quote closing the string) — take
// its leading alphabetic segment, and treat anything alphanumeric AFTER
// that segment as the end of the phrase.
for token in body.split_whitespace() {
let lead = token.trim_start_matches(|c: char| !c.is_ascii_alphabetic());
let word_len = lead
.find(|c: char| !c.is_ascii_alphabetic())
.unwrap_or(lead.len());
let (word, rest) = lead.split_at(word_len);
let is_member = !word.is_empty()
&& word.chars().all(|c| c.is_ascii_lowercase())
&& wordlist.binary_search(&word).is_ok();
if is_member {
run.push(word);
if run_is_seed_material(&run) {
return true;
}
// `accident"` ends a string — the phrase stopped there.
if rest.chars().any(|c| c.is_ascii_alphanumeric()) {
run.clear();
}
} else {
run = 0;
run.clear();
}
}
false
}
/// Whether the accumulated run of wordlist members is real seed material:
/// a checksum-valid mnemonic at any BIP39 length, or a run so long that
/// prose cannot explain it.
fn run_is_seed_material(run: &[&str]) -> bool {
if run.len() >= IMPLAUSIBLE_MEMBER_RUN {
return true;
}
for len in [24usize, 21, 18, 15, 12] {
if run.len() < len {
continue;
}
// Only the newest window can have completed on this token.
let window = &run[run.len() - len..];
if bip39::Mnemonic::parse_normalized(&window.join(" ")).is_ok() {
return true;
}
}
false
@@ -410,9 +460,18 @@ mod tests {
/// Behavior: a BIP39-length word run is blocked.
#[test]
fn bip39_length_word_run_is_blocked() {
let words =
"abandon ability able about above absent absorb abstract absurd abuse access accident";
// A CHECKSUM-VALID mnemonic — what a real leak looks like. (The
// earlier fixture was the first twelve wordlist entries, which is
// not a parseable mnemonic; after the 2026-08-06 precision rewrite
// the screen validates the checksum rather than the shape, so the
// fixture had to become a real one. Documented trade-off: a
// checksum-INVALID run shorter than IMPLAUSIBLE_MEMBER_RUN is no
// longer blocked — the shape rule that did block it also blocked
// every legitimate turn, twice, on a live node.)
let words = "abandon abandon abandon abandon abandon abandon \
abandon abandon abandon abandon abandon about";
assert_eq!(words.split_whitespace().count(), 12);
assert!(bip39::Mnemonic::parse_normalized(words).is_ok());
let body = clean_body(&format!("my seed is: {words}"));
let ctx = ctx_for(&format!("my seed is: {words}"), &[], &[]);
assert_eq!(
@@ -421,6 +480,18 @@ mod tests {
);
}
/// A long run of wordlist words that is NOT checksum-valid — a typo'd
/// or partial 24-word seed — still blocks via the length backstop.
#[test]
fn implausibly_long_member_run_blocks_without_checksum() {
let words = std::iter::repeat("zoo")
.take(IMPLAUSIBLE_MEMBER_RUN)
.collect::<Vec<_>>()
.join(" ");
assert!(bip39::Mnemonic::parse_normalized(&words).is_err());
assert!(has_bip39_length_word_run(&words));
}
/// Regression (dev3 on-device, 2026-08-06): the node's OWN system
/// prompt — long, lowercase, node-authored English — must NOT read as
/// a seed phrase. The shape-only detector blocked 100% of live cloud
@@ -449,6 +520,43 @@ mod tests {
assert_eq!(screen_outbound(&body, &ctx), EgressVerdict::Allow);
}
/// Regression (dev3, 2026-08-06, SECOND occurrence — mid-session as
/// 13-10's history grew): wordlist membership alone was not enough.
/// Splitting on every non-alphabetic character let words from
/// UNRELATED JSON fields chain into one run, so a long transcript of
/// ordinary prose eventually tripped the seed screen. JSON structure
/// must break runs; only space-separated words may chain.
#[test]
fn long_json_history_of_prose_is_not_a_seed_phrase() {
// Every value below is an innocuous wordlist word, but they sit in
// SEPARATE JSON fields — punctuation between them must break the
// run even though there are far more than 12 of them.
let scattered: String = [
"able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access",
"accident", "account", "accuse", "achieve", "acid", "acoustic", "acquire", "across",
]
.iter()
.enumerate()
.map(|(i, w)| format!("{{\"field{i}\":\"{w}\"}}"))
.collect::<Vec<_>>()
.join(",");
assert!(
!has_bip39_length_word_run(&scattered),
"words in separate JSON fields must not chain into a seed-shaped run"
);
// A genuine seed phrase inside a JSON string value — its last word
// glued to the closing quote and the rest of the document with no
// whitespace at all — must STILL be caught.
let real = "{\"role\":\"user\",\"content\":\"my seed is abandon abandon abandon \
abandon abandon abandon abandon abandon abandon abandon abandon \
about\",\"id\":\"x\"}";
assert!(
has_bip39_length_word_run(real),
"a real seed phrase must still be caught even glued to JSON punctuation"
);
}
/// Behavior: an ecash-token-shaped string is blocked.
#[test]
fn ecash_token_shaped_string_is_blocked() {