Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions scripts/at-rest-harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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

Expand Down
85 changes: 78 additions & 7 deletions tinycloud-node-server/tests/at_rest_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
let mut findings = BTreeSet::new();
for (place, text) in strings(doc) {
let views = byte_views(&text);
let views: Vec<Vec<u8>> = std::iter::once(text.as_str())
.chain(encoded_runs(&text))
.flat_map(byte_views)
.collect();
let texts: Vec<String> = std::iter::once(text.clone())
.chain(armor_payload(&text).map(|p| String::from_utf8_lossy(&p).into_owned()))
.collect();
Expand All @@ -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<Item = &str> {
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
Expand Down Expand Up @@ -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(())
}
Loading