From c2c8aa8161d97f4fe445fb2c1b8779cc26c90bce Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sat, 17 Oct 2026 16:12:28 +0000 Subject: [PATCH] test(TC-887): base32 runs end at their own alphabet; name 3+ salt pieces as out of scope Approval nits on #340: - Encoded runs are now taken per alphabet (all decoders together, and base32, hex and base64url each on their own), in block bodies and names and in mirror strings, so a base32-encoded secret followed by a character outside base32 (9, 1) is still decoded at every alignment. Self-test covers a block body and a mirror string, and fails with the previous single-alphabet runs. - README: the out-of-scope list names a secret split into 3 or more pieces across salts. --- scripts/at-rest-harness/README.md | 18 ++-- .../tests/at_rest_harness.rs | 85 +++++++++++++++++-- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/scripts/at-rest-harness/README.md b/scripts/at-rest-harness/README.md index 0fec8db5..364b3c36 100644 --- a/scripts/at-rest-harness/README.md +++ b/scripts/at-rest-harness/README.md @@ -172,10 +172,13 @@ Every one must pass all of these, or the check is RED: hex in any case and base64/base64url at any alignment (padded or not), and decoded as hex, base64, base64url (padded or not), base32 and bech32 data after removing whitespace and a `0x`, `\x`, short `label:` or - `data:…;base64,` prefix, shifted by 0 to 7 characters. This decoding - is applied to the whole text and to every run of 32 or more characters - from those alphabets (so a key encoded inside ciphertext or other text - is found). + `data:…;base64,` prefix, shifted by 0 to 7 characters. + + This decoding is applied to the whole text and to every run of 32 or more + characters, in mirror strings as well as in blocks. Runs are taken from all + those alphabets together and from base32, hex and base64url each on their + own, so a run ends where its own alphabet ends: a base32 key followed by `9` + or `1` is still found inside ciphertext or other text. - **No piece of a secret in a TCE1 salt.** The salt (header bytes 20..52) is the only free field of a sealed block. No block's salt may hold any 16-byte window of any of those secrets: a truncated key (31 bytes and one other @@ -199,6 +202,8 @@ Out of scope for a regression harness, one line each: - A key split across two commitments, or XOR'd with anything, isn't found. - A piece of a secret (fewer than 32 bytes) is only looked for in TCE1 salts; everywhere else only whole secrets are matched. +- A secret split into 3 or more pieces across salts (each under 16 bytes) + isn't found. - Separators other than `-`/`_` inside holder names, shifts of more than 7 characters, and splits across fields other than holder names aren't rejoined. @@ -215,8 +220,9 @@ bypass), `selftest_key_mirror_refuses_escrow_contents_and_derived_keys`, `selftest_key_mirror_bounds_absurd_generations`, `selftest_key_mirror_requires_mirrors_in_encrypt_mode`, `selftest_blocks_open_under_their_mirrored_dek`, -`selftest_blocks_carry_no_derived_secret` and -`selftest_salts_hold_no_piece_of_a_secret` run in plain `cargo test`. +`selftest_blocks_carry_no_derived_secret`, +`selftest_salts_hold_no_piece_of_a_secret` and +`selftest_base32_secret_before_other_characters` run in plain `cargo test`. ## Not covered diff --git a/tinycloud-node-server/tests/at_rest_harness.rs b/tinycloud-node-server/tests/at_rest_harness.rs index 13c0105d..fea768f9 100644 --- a/tinycloud-node-server/tests/at_rest_harness.rs +++ b/tinycloud-node-server/tests/at_rest_harness.rs @@ -1898,11 +1898,15 @@ mod key_mirror { /// Findings for any string of `doc` that carries one of `secrets`: in its /// text as hex, base64 or base64url (any alignment), or in any byte view - /// of it ([`byte_views`]), including as text inside a decoded payload. + /// ([`byte_views`]) of it or of one of its [`encoded_runs`], including + /// as text inside a decoded payload. pub fn secret_findings(what: &str, doc: &Value, secrets: &[Secret]) -> Vec { let mut findings = BTreeSet::new(); for (place, text) in strings(doc) { - let views = byte_views(&text); + let views: Vec> = std::iter::once(text.as_str()) + .chain(encoded_runs(&text)) + .flat_map(byte_views) + .collect(); let texts: Vec = std::iter::once(text.clone()) .chain(armor_payload(&text).map(|p| String::from_utf8_lossy(&p).into_owned())) .collect(); @@ -1928,12 +1932,35 @@ mod key_mirror { c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-') } - /// Maximal runs of 32 or more characters from the hex, base64, - /// base64url, base32 and bech32 alphabets: where an encoded key would - /// sit inside other text or among ciphertext bytes. + fn is_base32_char(c: char) -> bool { + c.is_ascii_alphabetic() || ('2'..='7').contains(&c) + } + + fn is_hex_char(c: char) -> bool { + c.is_ascii_hexdigit() + } + + fn is_base64url_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' || c == '_' + } + + /// Maximal runs of 32 or more characters where an encoded key would sit + /// inside other text or among ciphertext bytes: runs of all the decoders' + /// characters together, and runs of base32, hex and base64url characters + /// each on their own, so a run ends where its own alphabet ends (a base32 + /// key followed by `9` or `1` is still a whole base32 run). Every run then + /// goes through [`byte_views`], at every alignment it tries. fn encoded_runs(text: &str) -> impl Iterator { - text.split(|c: char| !is_encoding_char(c)) - .filter(|run| run.len() >= 32) + let alphabets: [fn(char) -> bool; 4] = [ + is_encoding_char, + is_base32_char, + is_hex_char, + is_base64url_char, + ]; + alphabets.into_iter().flat_map(move |in_alphabet| { + text.split(move |c: char| !in_alphabet(c)) + .filter(|run| run.len() >= 32) + }) } /// Round 6 ruling 1 / round 7: a block's name and bytes carry no @@ -4949,3 +4976,47 @@ fn selftest_salts_hold_no_piece_of_a_secret() -> Result<()> { ); Ok(()) } + +/// Round 8, offline: a base32-encoded derived secret is found when the next +/// character is outside base32 but inside another encoding's alphabet (`9`, +/// `1`), in a block body and in a mirror string. +#[test] +fn selftest_base32_secret_before_other_characters() -> Result<()> { + let fx = MirrorFixture::new(); + let all = fx.with_edit("ark-", &|_, _| None); + let (good, put) = fx.sealed_block(0, b"the probe value"); + let puts = [put]; + let kek_1 = key_mirror::kek(&fx.keys.ark_1); + let mut missed = Vec::new(); + for suffix in ["9", "1"] { + let mut body = good.body.clone(); + body.push(b'\n'); + body.extend_from_slice(base32_lower(&kek_1).to_uppercase().as_bytes()); + body.extend_from_slice(suffix.as_bytes()); + let block = StoredObject { + key: good.key.clone(), + body, + }; + let findings = fx.check_with(&all, &[block], &puts, key_mirror::Policy::default()); + let expected = "bytes as text carry the harness-derived KEK of generation 1"; + if !any_contains(&findings, expected) { + missed.push(format!("block body, base32 then {suffix:?}: {findings:?}")); + } + let note = format!("{}{suffix}", base32_lower(&fx.keys.ark_1)); + let findings = fx.check(&fx.with_edit("dek-", &|_, v| { + v["note"] = json!(note); + None + })); + if !any_contains(&findings, "carries the harness-derived ARK of generation 1") { + missed.push(format!( + "mirror string, base32 then {suffix:?}: {findings:?}" + )); + } + } + ensure!( + missed.is_empty(), + "a base32 secret before another character was missed:\n{}", + missed.join("\n") + ); + Ok(()) +}