diff --git a/scripts/at-rest-harness/README.md b/scripts/at-rest-harness/README.md index f07b1eff..0fec8db5 100644 --- a/scripts/at-rest-harness/README.md +++ b/scripts/at-rest-harness/README.md @@ -154,33 +154,69 @@ Every one must pass all of these, or the check is RED: chain opens under the next, every wrapped DEK opens under KEK_g with its AAD, its tag mapped to a space the harness signed in to (`spaces.log`). An escrow bundle's canary and EFP and a record's MAC are recomputed. A raw key - padded to 60 bytes fails here. ARK generations above 9 (INTERFACES: 1..=9) - are refused outright, and a gap is one finding: a name never sets a loop - bound. + padded to 60 bytes fails here. ARK generations are unbounded in the node + (`rotate_ark` makes g+1; the 1..=9 in INTERFACES is the TCE *format* + generation, not this). The harness applies its own sanity cap of 1,000 so + an absurd name like `ark-4294967295-…` fails RED at once, and a gap is one + finding: a name never sets a loop bound. - **No derived secret anywhere.** No string of any mirror (exempt fields, - escrow commitments, holder names alone, in pairs or all in order, the armored - share text and its decoded payload, record fields) may carry, in any of the - encodings above, a secret the harness derived or unwrapped: the root, W, - each ARK_g, KEK_g, chain, canary and record-MAC key, NK, the DEK mirror tag - key, or any DEK. + escrow commitments, holder names alone, joined in every order with or + without `-`/`_` separators and shifted by up to 7 characters, the armored + share text and its decoded payload as bytes and as text, the data of a + bech32 recipient, record fields) may carry, in any of the encodings above, + a secret the harness derived or unwrapped: the root, W, each ARK_g, KEK_g, + chain, canary and record-MAC key, NK, the DEK mirror tag key, or any DEK. + The same goes for every block, in every mode: + - its raw bytes, searched whole; + - its name (the whole name, every `/` segment) and its bytes read as text: + 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). +- **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 + byte) and a key split across two blocks' salts (one half in each) are both + RED. A random salt matches with probability about 2^-120 per secret. - **Escrow-none.** If `up` started the node with `TC887_AT_REST_ESCROW_NONE=true`, any escrow bundle or record object is RED. -- **Mirrors required (encrypt/strict mode).** An ARK mirror must exist, every - block must be named as sealed (`v1//…`), and its space tag - (from the re-derived NK) must belong to a harness space whose DEK mirror - authenticates. A sealed block or DEK with no mirror behind it is RED. - -Not checkable without the escrow holders: whether an escrow share commitment -or age-encrypted share hides key material that the harness didn't derive. +- **Mirrors required, blocks tied to them (encrypt/strict mode).** An ARK + mirror must exist. Every block must be named + `v1/<22-char space tag>/<43-char object>`; its space tag (from the + re-derived NK) must belong to a harness space with an authenticated DEK + mirror; it must parse as TCE1 with a header `dek_id` that is one of those + DEKs; its name must be the sealed name of a put the harness logged + (`puts.log`: space and content hash); and it must open under that DEK, with + AAD (space, content hash), to bytes whose blake3 is that content hash. + (The TCE1 header carries no ARK generation; the DEK mirror does.) + +Out of scope for a regression harness, one line each: +- Escrow share commitments and age-encrypted shares can't be opened without + the holders' keys, so key material the harness didn't derive can hide there. +- 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. +- Separators other than `-`/`_` inside holder names, shifts of more than 7 + characters, and splits across fields other than holder names aren't + rejoined. +- Secrets in encodings other than hex, base64, base64url, base32 and bech32 + data (e.g. base58, decimal, a custom alphabet) aren't decoded. +- A secret encrypted or hashed under something the harness doesn't know is + indistinguishable from random bytes. `selftest_key_hierarchy_reference_vectors` (RFC 5869 vectors, seal/open round trips, every one-bit flip and wrong binding rejected), `selftest_key_mirror_accepts_s1_shaped_mirrors`, `selftest_key_mirror_flags_every_bypass` (one case per rule and per known bypass), `selftest_key_mirror_refuses_escrow_contents_and_derived_keys`, -`selftest_key_mirror_bounds_absurd_generations` and -`selftest_key_mirror_requires_mirrors_in_encrypt_mode` run in plain -`cargo test`. +`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`. ## Not covered diff --git a/scripts/at-rest-harness/harness.sh b/scripts/at-rest-harness/harness.sh index a41a1c16..a74f7d0a 100755 --- a/scripts/at-rest-harness/harness.sh +++ b/scripts/at-rest-harness/harness.sh @@ -324,6 +324,7 @@ TC887_S3_ACCESS_KEY_ID=$minio_user TC887_S3_SECRET_ACCESS_KEY=$minio_password TC887_NODE_ROOT_FILE=$run/secrets/node-static-secret TC887_SPACES_LOG=$run/spaces.log +TC887_PUTS_LOG=$run/puts.log TC887_BOOT_SNAPSHOT=$run/boot-snapshot EOF # The node's whole environment; sourced inside the child, never on argv. diff --git a/tinycloud-node-server/tests/at_rest_harness.rs b/tinycloud-node-server/tests/at_rest_harness.rs index 09f66306..13c0105d 100644 --- a/tinycloud-node-server/tests/at_rest_harness.rs +++ b/tinycloud-node-server/tests/at_rest_harness.rs @@ -98,6 +98,8 @@ struct Harness { root: Vec, /// Every space any check signed in to on this harness, one per line. spaces_log: std::path::PathBuf, + /// Every KV put any check made: `spacecontent multihash hex`. + puts_log: std::path::PathBuf, /// The bucket as the node left it at boot, before any test write. boot_snapshot: std::path::PathBuf, /// The at-rest mode and escrow choice `up` started the node with. @@ -178,6 +180,7 @@ impl Harness { pg, root, spaces_log: var("TC887_SPACES_LOG")?.into(), + puts_log: var("TC887_PUTS_LOG")?.into(), boot_snapshot: var("TC887_BOOT_SNAPSHOT")?.into(), policy, }) @@ -271,6 +274,22 @@ impl Harness { .collect()) } + /// Every KV put made on this harness, from any check. + fn known_puts(&self) -> Result> { + let log = std::fs::read_to_string(&self.puts_log).unwrap_or_default(); + log.lines() + .collect::>() + .into_iter() + .map(|line| { + let (space, hash) = line.split_once('\t').context("malformed puts log")?; + Ok(key_mirror::Put { + space: space.to_owned(), + multihash: hex::decode(hash).context("malformed puts log")?, + }) + }) + .collect() + } + async fn delegate(&self, authorization: String) -> Result<()> { let response = self .http @@ -1759,8 +1778,9 @@ mod key_mirror { } /// Every string of a mirror document (values and JSON keys) with its - /// path, plus the escrow holder names concatenated pairwise and all in - /// order (a key split across holders). + /// path, plus the escrow holder names joined in every order (pairs and + /// all three), with and without their `-`/`_` separators (a key split + /// across holders). fn strings(doc: &Value) -> Vec<(String, String)> { fn collect(value: &Value, path: &str, out: &mut Vec<(String, String)>) { match value { @@ -1791,15 +1811,31 @@ mod key_mirror { .as_array() .map(|shares| shares.iter().filter_map(|s| s["holder"].as_str()).collect()) .unwrap_or_default(); - for (i, a) in holders.iter().enumerate() { - for (j, b) in holders.iter().enumerate() { - if i != j { - out.push((format!("holders {} + {}", i + 1, j + 1), format!("{a}{b}"))); + let n = holders.len().min(3); + let mut orders: Vec> = Vec::new(); + for i in 0..n { + for j in (0..n).filter(|j| *j != i) { + orders.push(vec![i, j]); + for k in (0..n).filter(|k| *k != i && *k != j) { + orders.push(vec![i, j, k]); } } } - if holders.len() > 2 { - out.push(("all holders in order".into(), holders.concat())); + for order in orders { + let joined: String = order.iter().map(|&i| holders[i]).collect(); + let label = format!( + "holders {} joined", + order + .iter() + .map(|i| (i + 1).to_string()) + .collect::>() + .join("+") + ); + let bare: String = joined.chars().filter(|c| *c != '-' && *c != '_').collect(); + if bare != joined { + out.push((format!("{label} without separators"), bare)); + } + out.push((label, joined)); } out } @@ -1815,26 +1851,69 @@ mod key_mirror { .flatten() } + /// The data bytes of a bech32 string (`hrp 1 data checksum`, as in age + /// recipients), checksum not verified. + pub fn bech32_data(s: &str) -> Option> { + let lower = s.to_ascii_lowercase(); + let (_, data) = lower.rsplit_once('1')?; + let data = data.get(..data.len().checked_sub(6)?)?; + let (mut acc, mut bits, mut out) = (0u32, 0u32, Vec::new()); + for c in data.chars() { + acc = (acc << 5) | BECH32.find(c)? as u32; + bits += 5; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) + } + + /// Every byte string `text` may encode: its own bytes, its decodings + /// (hex, base64, base64url, base32, bech32 data, an armored age + /// payload), and the decodings of it with 1..=7 leading characters + /// dropped (a value shifted inside a longer string). + fn byte_views(text: &str) -> Vec> { + let mut views = vec![text.as_bytes().to_vec()]; + views.extend(armor_payload(text)); + views.extend(bech32_data(text)); + for shift in 0..8.min(text.len()) { + if text.is_char_boundary(shift) { + views.extend(decodings(&text[shift..])); + } + } + views + } + + /// Whether `secret` appears in `text` as hex (any case) or as base64 or + /// base64url characters at any alignment. + fn in_text(text: &str, lower: &str, secret: &[u8]) -> bool { + lower.contains(&hex::encode(secret)) + || [base64::STANDARD_NO_PAD, base64::URL_SAFE_NO_PAD] + .into_iter() + .flat_map(|config| super::base64_cores(secret, config)) + .any(|core| text.contains(&core)) + } + /// Findings for any string of `doc` that carries one of `secrets`: in its - /// text as hex, base64 or base64url (any alignment), or in any decoding - /// of it (hex, base64, base64url, base32, an armored age payload). + /// text as hex, base64 or base64url (any alignment), or in any byte view + /// of it ([`byte_views`]), 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 mut decoded = decodings(&text); - decoded.extend(armor_payload(&text)); - decoded.push(text.as_bytes().to_vec()); - let lower = text.to_ascii_lowercase(); + let views = byte_views(&text); + let texts: Vec = std::iter::once(text.clone()) + .chain(armor_payload(&text).map(|p| String::from_utf8_lossy(&p).into_owned())) + .collect(); for secret in secrets { - let in_text = lower.contains(&hex::encode(&secret.bytes)) - || [base64::STANDARD_NO_PAD, base64::URL_SAFE_NO_PAD] - .into_iter() - .flat_map(|config| super::base64_cores(&secret.bytes, config)) - .any(|core| text.contains(&core)); - let in_bytes = decoded + let carried = texts .iter() - .any(|bytes| super::find(bytes, &secret.bytes).is_some()); - if in_text || in_bytes { + .any(|t| in_text(t, &t.to_ascii_lowercase(), &secret.bytes)) + || views + .iter() + .any(|bytes| super::find(bytes, &secret.bytes).is_some()); + if carried { findings.insert(format!( "{what}: {place} carries the harness-derived {} (raw key material)", secret.label @@ -1845,6 +1924,103 @@ mod key_mirror { findings.into_iter().collect() } + fn is_encoding_char(c: char) -> bool { + 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 encoded_runs(text: &str) -> impl Iterator { + text.split(|c: char| !is_encoding_char(c)) + .filter(|run| run.len() >= 32) + } + + /// Round 6 ruling 1 / round 7: a block's name and bytes carry no + /// harness-derived secret. The body is searched as raw bytes. The name + /// (whole and every `/` segment) and the body read as text are checked + /// as hex (any case) and base64/base64url at any alignment, and through + /// [`byte_views`] of the whole text and of every [`encoded_runs`] run. + pub fn block_secret_findings( + label: &str, + block: &ObjectRef, + secrets: &[Secret], + ) -> Vec { + let body_text = String::from_utf8_lossy(block.body); + let body_lower = body_text.to_ascii_lowercase(); + let body_views: Vec> = std::iter::once(&*body_text) + .chain(encoded_runs(&body_text)) + .flat_map(byte_views) + .collect(); + let name_lower = block.key.to_ascii_lowercase(); + let name_views: Vec> = std::iter::once(block.key) + .chain(block.key.split('/')) + .chain(encoded_runs(block.key)) + .flat_map(byte_views) + .collect(); + let mut findings = Vec::new(); + for secret in secrets { + let needle = &secret.bytes; + let in_views = + |views: &[Vec]| views.iter().any(|v| super::find(v, needle).is_some()); + let mut places = Vec::new(); + if in_text(block.key, &name_lower, needle) || in_views(&name_views) { + places.push("name"); + } + if super::find(block.body, needle).is_some() { + places.push("raw bytes"); + } else if in_text(&body_text, &body_lower, needle) || in_views(&body_views) { + places.push("bytes as text"); + } + if !places.is_empty() { + findings.push(format!( + "[{label}] block {:?}: its {} carry the harness-derived {} (raw key material)", + block.key, + places.join(" and "), + secret.label + )); + } + } + findings + } + + /// TCE1's only free header field: the salt, bytes 20..52 (envelope.rs + /// `Header::build`). Every other header byte is fixed, a length, or the + /// authenticated `dek_id`. + const SALT: std::ops::Range = 20..52; + + /// The salt check looks for pieces of a secret this long. A 31-byte + /// prefix of a key holds one, and so does each half of a key split + /// across two blocks' salts; a random salt holds one of a given secret's + /// 17 pieces with probability about 17 · 17 · 2^-128. + const SECRET_PIECE: usize = 16; + + /// Round 7: no TCE1 block's salt holds any 16-byte piece of a + /// harness-derived secret (a truncated key, or one half of a key split + /// across salts). + pub fn salt_findings(label: &str, block: &ObjectRef, secrets: &[Secret]) -> Vec { + if tinycloud_core::at_rest::Header::parse(block.body).is_err() { + return Vec::new(); + } + let salt = &block.body[SALT]; + let mut findings = Vec::new(); + for secret in secrets { + let found = secret + .bytes + .windows(SECRET_PIECE) + .position(|piece| super::find(salt, piece).is_some()); + if let Some(at) = found { + findings.push(format!( + "[{label}] block {:?}: its TCE1 header salt holds bytes {at}..{} of the harness-derived {} (a piece of raw key material)", + block.key, + at + SECRET_PIECE, + secret.label + )); + } + } + findings + } + // ---- one snapshot ------------------------------------------------------- pub struct ObjectRef<'a> { @@ -1862,9 +2038,12 @@ mod key_mirror { pub escrow_none: bool, } - /// ARK generations beyond this are refused outright (INTERFACES: 1..=9), - /// so an absurd name can't make the generation checks loop or allocate. - pub const MAX_ARK_GENERATION: u32 = 9; + /// A harness-only sanity cap on ARK generations, so an absurd name (say + /// `ark-4294967295-…`) fails RED at once instead of driving any work. + /// ARK generations themselves are unbounded (`rotate_ark` makes g+1); + /// this is not the TCE *format* generation (1..=9, the envelope header + /// field). A fresh harness node never rotates, so 1,000 leaves room. + pub const ARK_GENERATION_SANITY_CAP: u32 = 1000; /// `NK = HKDF(ARK_1, "tinycloud/at-rest/object-names/v1")`. pub fn object_name_key(ark_1: &Key) -> Key { @@ -1880,15 +2059,23 @@ mod key_mirror { base64::encode_config(&hmac_sha256(nk, &[&msg])[..16], base64::URL_SAFE_NO_PAD) } + /// One KV put the harness made: its space and the content multihash. + pub struct Put { + pub space: String, + pub multihash: Vec, + } + /// Every `_tc/` object of one bucket snapshot: an allowed name, an exact /// typed schema, no raw or harness-derived key, and authentic key /// material under the harness's Static `root`, with DEK tags mapped to - /// `spaces`. In encrypt mode, every block in `blocks` must be sealed and - /// belong to a space whose DEK mirror authenticates. + /// `spaces`. No block carries a derived secret. In encrypt mode, every + /// block is sealed for a harness space, names a DEK whose mirror + /// authenticates, and opens under it to the content of one of `puts`. pub fn check_snapshot( label: &str, objects: &[ObjectRef], - blocks: &[&str], + blocks: &[ObjectRef], + puts: &[Put], root: &[u8], spaces: &[String], policy: Policy, @@ -1991,31 +2178,60 @@ mod key_mirror { for (what, doc) in &docs { findings.extend(secret_findings(what, doc, &keys.secrets)); } + for block in blocks { + findings.extend(block_secret_findings(label, block, &keys.secrets)); + findings.extend(salt_findings(label, block, &keys.secrets)); + } if policy.encrypt { - require_mirrors(label, blocks, spaces, &arks, &keys, &mut findings); + require_mirrors(label, blocks, puts, spaces, &arks, &keys, &mut findings); } findings } + /// An authenticated DEK of one space. + pub struct SpaceDek { + pub dek_id: u32, + pub generation: u32, + pub key: Key, + } + /// What authentication recovered: every secret it derived or unwrapped, - /// ARK_1 (if authentic) and the spaces whose DEK mirror authenticated. + /// ARK_1 (if authentic) and, per space, the DEKs whose mirror + /// authenticated. pub struct Recovered { pub secrets: Vec, pub ark_1: Option, - pub spaces_with_dek: BTreeSet, + pub deks: BTreeMap>, } - /// Round 5, ruling 4 (encrypt mode): an ARK mirror exists; every block is - /// sealed (`v1//…`), its tag (from NK) names a harness space, - /// and that space has a DEK mirror that authenticates. + /// Exactly `len` unpadded base64url characters. + fn b64url_of_len(s: &str, len: usize) -> bool { + s.len() == len + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + } + + /// Round 5 ruling 4 and round 6 ruling 3 (encrypt mode): an ARK mirror + /// exists, and every block + /// - is named `v1//`; + /// - has a tag (from NK) that names a harness space with an + /// authenticated DEK mirror; + /// - parses as TCE1 and its header's `dek_id` is one of those DEKs; + /// - is the exact sealed name of a put the harness made in that space, + /// and opens under that DEK (AAD: space, content hash) to bytes whose + /// blake3 is that put's content hash. fn require_mirrors( label: &str, - blocks: &[&str], + blocks: &[ObjectRef], + puts: &[Put], spaces: &[String], arks: &[ArkDoc], keys: &Recovered, findings: &mut Vec, ) { + use tinycloud_core::at_rest::{ + open, sealed_object_name, BlobIdentity, DataKey, DekId, Header, ObjectNameKey, + }; if arks.is_empty() { findings.push(format!( "[{label}] encrypt mode, but no ARK mirror is in the bucket" @@ -2032,29 +2248,83 @@ mod key_mirror { return; }; let nk = object_name_key(&ark_1); + let core_nk = ObjectNameKey::new(nk); let tags: BTreeMap = spaces.iter().map(|s| (space_tag(&nk, s), s)).collect(); for block in blocks { - let what = format!("[{label}] block {block:?}"); - let Some(tag) = block + let what = format!("[{label}] block {:?}", block.key); + let Some((tag, _object)) = block + .key .strip_prefix("v1/") .and_then(|rest| rest.split_once('/')) - .map(|(tag, _)| tag) + .filter(|(tag, object)| b64url_of_len(tag, 22) && b64url_of_len(object, 43)) else { findings.push(format!( - "{what}: encrypt mode, but the block is not named as sealed (v1//…)" + "{what}: encrypt mode, but the block is not named as sealed \ + (v1/<22 b64url space tag>/<43 b64url object>)" )); continue; }; - match tags.get(tag) { - None => findings.push(format!( + let Some(space) = tags.get(tag) else { + findings.push(format!( "{what}: its space tag belongs to none of the {} spaces this harness created", spaces.len() - )), - Some(space) if !keys.spaces_with_dek.contains(*space) => findings.push(format!( + )); + continue; + }; + let Some(space_deks) = keys.deks.get(*space).filter(|d| !d.is_empty()) else { + findings.push(format!( "{what}: its space has no DEK mirror that authenticates" + )); + continue; + }; + let header = match Header::parse(block.body) { + Ok(header) => header, + Err(e) => { + findings.push(format!("{what}: not a TCE1 block ({e})")); + continue; + } + }; + let dek_id = header.dek_id().get(); + let Some(dek) = space_deks.iter().find(|d| d.dek_id == dek_id) else { + findings.push(format!( + "{what}: its header names data key {dek_id}, but its space's authenticated DEK mirror(s) are {:?}", + space_deks.iter().map(|d| d.dek_id).collect::>() + )); + continue; + }; + let put = puts.iter().filter(|p| p.space == **space).find_map(|p| { + let hash = tinycloud_core::hash::Hash::try_from(p.multihash.clone()).ok()?; + (sealed_object_name(&core_nk, space, &hash) == block.key).then_some((p, hash)) + }); + let Some((put, hash)) = put else { + findings.push(format!( + "{what}: its name is not the sealed name of any put the harness made in its space" + )); + continue; + }; + let identity = BlobIdentity::KvBlock { + space, + content_hash: &hash, + }; + match open( + &DataKey::new(DekId::new(dek.dek_id), dek.key), + &identity, + block.body.to_vec(), + ) { + Ok(plaintext) => { + let got: Vec = tinycloud_core::hash::hash(&plaintext).into(); + if got != put.multihash { + findings.push(format!( + "{what}: opens under DEK {dek_id} (generation {}), but not to the content the harness wrote", + dek.generation + )); + } + } + Err(e) => findings.push(format!( + "{what}: does not open under its space's DEK {dek_id} (generation {}) with AAD (space, content hash): {e}", + dek.generation )), - Some(_) => {} } } } @@ -2083,16 +2353,16 @@ mod key_mirror { }, ], ark_1: None, - spaces_with_dek: BTreeSet::new(), + deks: BTreeMap::new(), }; - // Bounded by the ceiling and by the mirrors found, never by a number - // read from a name (round 5, ruling 3). + // Bounded by the sanity cap and by the mirrors found, never by a + // number read from a name. let generations: BTreeSet = arks.iter().map(|d| d.generation).collect(); let newest = generations.last().copied(); if let Some(newest) = newest { - if newest > MAX_ARK_GENERATION { + if newest > ARK_GENERATION_SANITY_CAP { findings.push(format!( - "[{label}] ARK generation {newest} is above the ceiling {MAX_ARK_GENERATION}" + "[{label}] ARK generation {newest} is above the harness's sanity cap {ARK_GENERATION_SANITY_CAP}" )); } if generations.len() as u64 != u64::from(newest) { @@ -2103,7 +2373,10 @@ mod key_mirror { } } let mut opened: BTreeMap = BTreeMap::new(); - for d in arks.iter().filter(|d| d.generation <= MAX_ARK_GENERATION) { + for d in arks + .iter() + .filter(|d| d.generation <= ARK_GENERATION_SANITY_CAP) + { match open_ark_doc(&w, d) { Some(ark) => match opened.get(&d.generation) { Some((id, other)) if *id != d.ark_id || *other != ark => findings.push(format!( @@ -2216,7 +2489,15 @@ mod key_mirror { &d.wrapped, ) { Some(dek) => { - recovered.spaces_with_dek.insert((*space).clone()); + recovered + .deks + .entry((*space).clone()) + .or_default() + .push(SpaceDek { + dek_id: d.dek_id, + generation: d.generation, + key: dek, + }); recovered.secrets.push(Secret { label: format!("DEK {} of a harness space", d.dek_id), bytes: dek.to_vec(), @@ -2343,6 +2624,19 @@ async fn kv_put( path: &str, value: &str, ) -> Result> { + // Logged before the put, so the RED check can open the sealed block + // (round 6, ruling 3): its name and AAD bind the content hash. + let multihash: Vec = tinycloud_core::hash::hash(value.as_bytes()).into(); + writeln!( + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&harness.puts_log) + .with_context(|| format!("open {}", harness.puts_log.display()))?, + "{}\t{}", + account.space, + hex::encode(multihash) + )?; let before = harness.keys().await?; let (status, body) = harness .invoke( @@ -2390,6 +2684,15 @@ async fn kv_put( Ok(objects) } +fn object_refs(list: &[StoredObject]) -> Vec> { + list.iter() + .map(|o| key_mirror::ObjectRef { + key: &o.key, + body: &o.body, + }) + .collect() +} + async fn kv_run(harness: &Harness) -> Result { let marker = marker("KV"); let probes = vec![ @@ -2620,18 +2923,18 @@ async fn red_kv_blocks_are_sealed_in_minio() -> Result<()> { // exact name, an exact typed schema, no raw or harness-derived key, // authentic key material under the harness's Static root and, in // encrypt mode, a mirror behind every sealed block. - let after_blocks: Vec = harness - .keys() - .await? + let mut after_blocks = Vec::new(); + for key in harness.keys().await? { + if !key.starts_with(key_mirror::CONTROL_PREFIX) { + let body = harness.object(&key).await?; + after_blocks.push(StoredObject { key, body }); + } + } + let (boot_blocks, boot): (Vec, Vec) = harness + .boot_objects()? .into_iter() - .filter(|key| !key.starts_with(key_mirror::CONTROL_PREFIX)) - .collect(); - let boot = harness.boot_objects()?; - let boot_blocks: Vec = boot - .iter() - .filter(|o| !o.key.starts_with(key_mirror::CONTROL_PREFIX)) - .map(|o| o.key.clone()) - .collect(); + .partition(|o| !o.key.starts_with(key_mirror::CONTROL_PREFIX)); + let puts = harness.known_puts()?; for (snapshot, objects, blocks) in [ ("boot", boot, boot_blocks), ( @@ -2649,25 +2952,19 @@ async fn red_kv_blocks_are_sealed_in_minio() -> Result<()> { &identities, )); } - let control: Vec = objects - .iter() - .filter(|o| o.key.starts_with(key_mirror::CONTROL_PREFIX)) - .map(|o| key_mirror::ObjectRef { - key: &o.key, - body: &o.body, - }) - .collect(); - let blocks: Vec<&str> = blocks.iter().map(String::as_str).collect(); + let (control, blocks) = (object_refs(&objects), object_refs(&blocks)); println!( - "[{snapshot}] {} control object(s), {} block(s) checked ({:?})", + "[{snapshot}] {} control object(s), {} block(s), {} logged put(s) checked ({:?})", control.len(), blocks.len(), + puts.len(), harness.policy ); findings.extend(key_mirror::check_snapshot( snapshot, &control, &blocks, + &puts, &harness.root, &spaces, harness.policy, @@ -3084,35 +3381,88 @@ impl MirrorFixture { } fn check(&self, objects: &[StoredObject]) -> Vec { - self.check_with(objects, &[], key_mirror::Policy::default()) + self.check_with(objects, &[], &[], key_mirror::Policy::default()) } fn check_with( &self, objects: &[StoredObject], - blocks: &[&str], + blocks: &[StoredObject], + puts: &[key_mirror::Put], policy: key_mirror::Policy, ) -> Vec { - let refs: Vec = objects - .iter() - .map(|o| key_mirror::ObjectRef { - key: &o.key, - body: &o.body, - }) - .collect(); - key_mirror::check_snapshot("selftest", &refs, blocks, &self.root, &self.spaces, policy) + key_mirror::check_snapshot( + "selftest", + &object_refs(objects), + &object_refs(blocks), + puts, + &self.root, + &self.spaces, + policy, + ) } - /// A sealed-shaped block name in `space`, its tag from the fixture's NK. - fn block(&self, space: usize) -> String { - format!( - "v1/{}/{}", - key_mirror::space_tag(&self.keys.nk, &self.spaces[space]), - base64::encode_config(random_bytes(32), base64::URL_SAFE_NO_PAD) + /// `value` sealed the way the S3 store seals a KV block of `space` + /// (main's `tinycloud_core::at_rest::seal`, the space's DEK id 1, + /// identity (space, content hash), name from NK), with its put. + fn sealed_block(&self, space: usize, value: &[u8]) -> (StoredObject, key_mirror::Put) { + use tinycloud_core::at_rest::{ + seal, sealed_object_name, BlobIdentity, DataKey, DekId, ObjectNameKey, + }; + let space_id = &self.spaces[space]; + let hash = tinycloud_core::hash::hash(value); + let body = seal( + &DataKey::new(DekId::new(1), self.keys.deks[space]), + &BlobIdentity::KvBlock { + space: space_id, + content_hash: &hash, + }, + value.to_vec(), + ) + .expect("seal"); + let key = sealed_object_name(&ObjectNameKey::new(self.keys.nk), space_id, &hash); + ( + StoredObject { key, body }, + key_mirror::Put { + space: space_id.clone(), + multihash: hash.into(), + }, ) } } +/// ARK mirrors for generations 1..=n under `root`, each older one chained +/// under the next, as `rotate_ark` leaves them. +fn ark_chain(root: &[u8], n: u32) -> Vec { + use key_mirror::*; + let w = wrap_key(root); + let arks: Vec<(Key, [u8; 16])> = (1..=n).map(|g| (random_key(), [g as u8; 16])).collect(); + (1..=n) + .map(|g| { + let (ark, id) = arks[g as usize - 1]; + let chain = arks.get(g as usize).map(|(newer, newer_id)| { + standard_b64(&seal32( + &chain_key(newer), + &chain_aad(&id, g, newer_id), + &ark, + random_nonce(), + )) + }); + let sealed = seal32(&w, &seal_aad(&id, g, W_LABEL), &ark, random_nonce()); + StoredObject { + key: format!("{PREFIX}ark-{g:010}-{}.json", hex::encode(id)), + body: json!({ + "version": 1, "generation": g, "ark_id": hex::encode(id), + "w_label": W_LABEL, "sealed": standard_b64(&sealed), "chain": chain, + "created_at": "2026-10-17T10:00:00Z", + }) + .to_string() + .into_bytes(), + } + }) + .collect() +} + /// An armored age file whose payload is the age v1 header, then `body`. fn age_armor_of(body: &[u8]) -> String { let mut payload = b"age-encryption.org/v1\n-> X25519 ".to_vec(); @@ -3916,7 +4266,7 @@ fn selftest_key_mirror_refuses_escrow_contents_and_derived_keys() -> Result<()> encrypt: false, escrow_none: true, }; - let findings = fx.check_with(&fx.with_edit("ark-", &|_, _| None), &[], escrow_none); + let findings = fx.check_with(&fx.with_edit("ark-", &|_, _| None), &[], &[], escrow_none); ensure!( findings .iter() @@ -3969,6 +4319,51 @@ fn selftest_key_mirror_refuses_escrow_contents_and_derived_keys() -> Result<()> }), "DEK 1 of a harness space", ), + // Round 6, ruling 4: the cheap free-field bypasses. + ( + "ARK_1 as the data of a bech32 age recipient", + tweak(|k, s| s[1].recipient = bech32_of("age", &k.ark_1)), + "ARK of generation 1", + ), + ( + "hex(KEK_2) as text inside the armored share payload", + tweak(|k, s| { + let mut body = b"-> X25519 ".to_vec(); + body.extend_from_slice(hex::encode(key_mirror::kek(&k.ark_2)).as_bytes()); + s[2].ciphertext = age_armor_of(&body); + }), + "KEK of generation 2", + ), + ( + "base32(W) split across holders with a `-` separator", + tweak(|k, s| { + let b32 = base32_lower(&k.w); + s[0].holder = format!("{}-", &b32[..26]); + s[1].holder = b32[26..].to_owned(); + }), + "wrap key W", + ), + ( + "base32(NK) split across holders, shifted one character", + tweak(|k, s| { + let b32 = base32_lower(&k.nk); + s[0].holder = format!("x{}", &b32[..25]); + s[1].holder = b32[25..].to_owned(); + }), + "object-name key NK", + ), + ( + "base32(ARK_2) split three ways, out of order", + tweak(|k, s| { + let b32 = base32_lower(&k.ark_2); + let (a, rest) = b32.split_at(18); + let (b, c) = rest.split_at(17); + s[0].holder = c.to_owned(); + s[1].holder = a.to_owned(); + s[2].holder = b.to_owned(); + }), + "ARK of generation 2", + ), ]; let mut missed = Vec::new(); for (what, tweak, expected) in &cases { @@ -4019,6 +4414,27 @@ fn selftest_key_mirror_refuses_escrow_contents_and_derived_keys() -> Result<()> Ok(()) } +/// `hrp 1 data checksum` in bech32's alphabet (checksum not computed: the +/// harness never verifies it), like an age recipient. +fn bech32_of(hrp: &str, bytes: &[u8]) -> String { + const ALPHABET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + let (mut bits, mut acc, mut out) = (0u32, 0u32, format!("{hrp}1")); + for &byte in bytes { + acc = (acc << 8) | u32::from(byte); + bits += 8; + while bits >= 5 { + bits -= 5; + out.push(ALPHABET[((acc >> bits) & 31) as usize] as char); + } + acc &= (1 << bits) - 1; + } + if bits > 0 { + out.push(ALPHABET[((acc << (5 - bits)) & 31) as usize] as char); + } + out.push_str("qqqqqq"); + out +} + /// Lowercase unpadded RFC 4648 base32 (a valid holder-name alphabet). fn base32_lower(bytes: &[u8]) -> String { const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; @@ -4038,130 +4454,168 @@ fn base32_lower(bytes: &[u8]) -> String { out } -/// Round 5, ruling 3, offline: an absurd ARK generation in a name fails RED -/// at once (no loop to the number), and a gap is one finding. +/// Round 5 ruling 3 / round 6 ruling 2, offline: ARK generations are not +/// capped at the TCE format's 9: a contiguous chain of 10 (and 9) passes. +/// An absurd generation fails RED at once against the harness's sanity cap +/// (no loop to the number), and a gap is one finding. #[test] fn selftest_key_mirror_bounds_absurd_generations() -> Result<()> { - let fx = MirrorFixture::new(); - let mut objects = fx.with_edit("ark-", &|_, _| None); + let root = random_bytes(32); + let check = |objects: &[StoredObject]| { + key_mirror::check_snapshot( + "selftest", + &object_refs(objects), + &[], + &[], + &root, + &[], + key_mirror::Policy::default(), + ) + }; + for n in [9, 10] { + let findings = check(&ark_chain(&root, n)); + ensure!( + findings.is_empty(), + "a valid chain of {n} ARK generations was flagged: {findings:#?}" + ); + } let absurd = |generation: u32| StoredObject { key: format!( "{}ark-{generation:010}-{}.json", key_mirror::PREFIX, - hex::encode([9u8; 16]) + hex::encode([0xeeu8; 16]) ), body: json!({ - "version": 1, "generation": generation, "ark_id": hex::encode([9u8; 16]), + "version": 1, "generation": generation, "ark_id": hex::encode([0xeeu8; 16]), "w_label": key_mirror::W_LABEL, "sealed": standard_b64(&random_bytes(60)), "chain": null, "created_at": "2026-10-17T10:00:00Z", }) .to_string() .into_bytes(), }; + let mut objects = ark_chain(&root, 2); objects.push(absurd(u32::MAX)); let started = std::time::Instant::now(); - let findings = fx.check(&objects); + let findings = check(&objects); ensure!( started.elapsed() < Duration::from_secs(2), "the generation checks took {:?}", started.elapsed() ); ensure!( - any_contains(&findings, "above the ceiling 9") + any_contains(&findings, "above the harness's sanity cap 1000") && any_contains(&findings, "not every generation 1..=4294967295"), "an absurd generation was not refused: {findings:#?}" ); - let mut with_gap = fx.with_edit("ark-", &|_, _| None); + let mut over_cap = ark_chain(&root, 2); + over_cap.push(absurd(1001)); + ensure!( + any_contains(&check(&over_cap), "above the harness's sanity cap 1000"), + "generation 1001 passed the sanity cap" + ); + let mut with_gap = ark_chain(&root, 2); with_gap.push(absurd(5)); - let gap = fx.check(&with_gap); + let gap = check(&with_gap); ensure!( gap.iter() .filter(|f| f.contains("not every generation")) .count() - == 1, + == 1 + && !any_contains(&gap, "sanity cap"), "a gap must be one finding: {gap:#?}" ); Ok(()) } -/// Round 5, ruling 4, offline: in encrypt mode every block must be sealed -/// and belong (by its NK space tag) to a harness space whose DEK mirror -/// authenticates, and an ARK mirror must exist; outside encrypt mode no -/// mirror is required. +fn copies(objects: &[StoredObject]) -> Vec { + objects + .iter() + .map(|o| StoredObject { + key: o.key.clone(), + body: o.body.clone(), + }) + .collect() +} + +const ENCRYPT: key_mirror::Policy = key_mirror::Policy { + encrypt: true, + escrow_none: false, +}; + +/// Round 5 ruling 4, offline: in encrypt mode an ARK mirror must exist and +/// every block must be sealed-named for a harness space whose DEK mirror +/// authenticates; outside encrypt mode no mirror is required. #[test] fn selftest_key_mirror_requires_mirrors_in_encrypt_mode() -> Result<()> { let fx = MirrorFixture::new(); - let encrypt = key_mirror::Policy { - encrypt: true, - escrow_none: false, - }; let all = fx.with_edit("ark-", &|_, _| None); - let blocks = [fx.block(0), fx.block(0), fx.block(1)]; - let block_refs: Vec<&str> = blocks.iter().map(String::as_str).collect(); - let findings = fx.check_with(&all, &block_refs, encrypt); + let (b0, p0) = fx.sealed_block(0, b"value in space a"); + let (b1, p1) = fx.sealed_block(1, b"value in space b"); + let puts = [p0, p1]; + let findings = fx.check_with(&all, &copies(&[b0, b1]), &puts, ENCRYPT); ensure!( findings.is_empty(), "mirrored sealed blocks were flagged: {findings:#?}" ); let without = |needle: &str| -> Vec { - all.iter() + copies(&all) + .into_iter() .filter(|o| !o.key.contains(needle)) - .map(|o| StoredObject { - key: o.key.clone(), - body: o.body.clone(), - }) .collect() }; let dek_0 = format!("dek-{}", key_mirror::dek_tag(&fx.keys.ark_1, &fx.spaces[0])); - let unknown = format!( - "v1/{}/x", - key_mirror::space_tag( - &fx.keys.nk, - "tinycloud:pkh:eip155:1:0x0000000000000000000000000000000000000001:other" - ) - ); - let plaintext = format!("{}/abc=", fx.spaces[0]); - let cases: Vec<(&str, Vec, Vec<&str>, &str)> = vec![ + let block = |space: usize| fx.sealed_block(space, b"a block").0; + let unknown = StoredObject { + key: format!( + "v1/{}/{}", + key_mirror::space_tag( + &fx.keys.nk, + "tinycloud:pkh:eip155:1:0x0000000000000000000000000000000000000001:other" + ), + base64::encode_config(random_bytes(32), base64::URL_SAFE_NO_PAD) + ), + body: random_bytes(100), + }; + let plaintext = StoredObject { + key: format!("{}/abc=", fx.spaces[0]), + body: b"plain".to_vec(), + }; + let cases: Vec<(&str, Vec, StoredObject, &str)> = vec![ ( "a space's DEK mirror deleted", without(&dek_0), - vec![block_refs[0]], + block(0), "its space has no DEK mirror that authenticates", ), ( "the generation-2 ARK deleted (its DEK can't authenticate)", without("ark-0000000002"), - vec![block_refs[2]], + block(1), "its space has no DEK mirror that authenticates", ), ( "a block of no harness space", - all.iter() - .map(|o| StoredObject { - key: o.key.clone(), - body: o.body.clone(), - }) - .collect(), - vec![unknown.as_str()], + copies(&all), + unknown, "belongs to none of the 2 spaces", ), ( "a plaintext-named block", without("record-"), - vec![plaintext.as_str()], + plaintext, "not named as sealed", ), ( "no ARK mirror at all", without("ark-"), - vec![block_refs[0]], + block(0), "no ARK mirror is in the bucket", ), ]; let mut missed = Vec::new(); - for (what, objects, blocks, expected) in &cases { - let findings = fx.check_with(objects, blocks, encrypt); + for (what, objects, block, expected) in cases { + let findings = fx.check_with(&objects, &[block], &[], ENCRYPT); if !any_contains(&findings, expected) { missed.push(format!("{what}: expected {expected:?}, got {findings:?}")); } @@ -4172,10 +4626,326 @@ fn selftest_key_mirror_requires_mirrors_in_encrypt_mode() -> Result<()> { missed.join("\n") ); - let off = fx.check_with(&[], &[plaintext.as_str()], key_mirror::Policy::default()); + let off = fx.check_with( + &[], + &[StoredObject { + key: format!("{}/abc=", fx.spaces[0]), + body: b"plain".to_vec(), + }], + &[], + key_mirror::Policy::default(), + ); ensure!( off.is_empty(), "mode off must not require mirrors: {off:#?}" ); Ok(()) } + +/// Round 6 ruling 3, offline: each sealed block must parse as TCE1, name a +/// DEK whose mirror authenticated for its space, be the sealed name of a +/// logged put, and open under that DEK to that put's content. The harness's +/// NK space tag matches main's `space_tag`. +#[test] +fn selftest_blocks_open_under_their_mirrored_dek() -> Result<()> { + use tinycloud_core::at_rest::{seal, BlobIdentity, DataKey, DekId, ObjectNameKey}; + let fx = MirrorFixture::new(); + for space in &fx.spaces { + ensure!( + key_mirror::space_tag(&fx.keys.nk, space) + == tinycloud_core::at_rest::space_tag(&ObjectNameKey::new(fx.keys.nk), space), + "the harness's space tag differs from main's names.rs" + ); + } + let all = fx.with_edit("ark-", &|_, _| None); + let (good, put) = fx.sealed_block(0, b"the probe value"); + let (other, other_put) = fx.sealed_block(1, b"another value"); + let puts = [put, other_put]; + ensure!( + fx.check_with( + &all, + &[ + StoredObject { + key: good.key.clone(), + body: good.body.clone(), + }, + other, + ], + &puts, + ENCRYPT + ) + .is_empty(), + "sealed blocks that open were flagged" + ); + + let hash = tinycloud_core::hash::hash(b"the probe value"); + let reseal = |dek_id: u32, key: [u8; 32], space: &str| { + seal( + &DataKey::new(DekId::new(dek_id), key), + &BlobIdentity::KvBlock { + space, + content_hash: &hash, + }, + b"the probe value".to_vec(), + ) + .expect("seal") + }; + let with_body = |body: Vec| StoredObject { + key: good.key.clone(), + body, + }; + let mut dek_id_2 = good.body.clone(); + dek_id_2[8..12].copy_from_slice(&2u32.to_be_bytes()); + let mut flipped = good.body.clone(); + let last = flipped.len() - 1; + flipped[last] ^= 1; + let cases: Vec<(&str, StoredObject, &str)> = vec![ + ( + "header dek_id changed to 2", + with_body(dek_id_2), + "header names data key 2", + ), + ( + "sealed under DEK id 2 with a key of its own", + with_body(reseal(2, random_key(), &fx.spaces[0])), + "header names data key 2", + ), + ( + "sealed under a constant key that is not the space's DEK", + with_body(reseal(1, [7; 32], &fx.spaces[0])), + "does not open under its space's DEK 1", + ), + ( + "space B's DEK and identity under space A's name", + with_body(reseal(1, fx.keys.deks[1], &fx.spaces[1])), + "does not open under its space's DEK 1", + ), + ( + "one ciphertext bit flipped", + with_body(flipped), + "does not open under its space's DEK 1", + ), + ("not TCE1", with_body(random_bytes(200)), "not a TCE1 block"), + ( + "a name that is no logged put", + StoredObject { + key: format!( + "{}/{}", + good.key.rsplit_once('/').expect("v1/tag/obj").0, + base64::encode_config(random_bytes(32), base64::URL_SAFE_NO_PAD) + ), + body: good.body.clone(), + }, + "not the sealed name of any put", + ), + ( + "an empty object name", + StoredObject { + key: format!("{}/", good.key.rsplit_once('/').expect("v1/tag/obj").0), + body: good.body.clone(), + }, + "not named as sealed", + ), + ]; + let mut missed = Vec::new(); + for (what, block, expected) in cases { + let findings = fx.check_with(&all, &[block], &puts, ENCRYPT); + if !any_contains(&findings, expected) { + missed.push(format!("{what}: expected {expected:?}, got {findings:?}")); + } + } + ensure!( + missed.is_empty(), + "block-to-DEK check missed:\n{}", + missed.join("\n") + ); + Ok(()) +} + +/// Round 6 ruling 1 / round 7, offline: a block's name or bytes carrying a +/// harness secret (raw, hex, base64 or base64url, padded or not, base32, +/// bech32 data) is RED, in any mode: the space's DEK as the TCE1 header +/// salt, b64url(DEK) or b64url(NK) as the object name, base32(NK) as a middle +/// name segment, and hex(KEK), base64(W), base32(KEK) and bech32(DEK) text +/// after the ciphertext. +#[test] +fn selftest_blocks_carry_no_derived_secret() -> 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 prefix = good.key.rsplit_once('/').expect("v1/tag/obj").0.to_owned(); + let dek = fx.keys.deks[0]; + let kek_1 = key_mirror::kek(&fx.keys.ark_1); + let mut salt_dek = good.body.clone(); + salt_dek[20..52].copy_from_slice(&dek); + let with_text = |text: String| { + let mut body = good.body.clone(); + body.push(b'\n'); + body.extend_from_slice(text.as_bytes()); + body + }; + let cases: Vec<(&str, StoredObject, &str)> = vec![ + ( + "the space's DEK as the header salt", + StoredObject { + key: good.key.clone(), + body: salt_dek, + }, + "raw bytes carry the harness-derived DEK 1", + ), + ( + "b64url(DEK) as the object name", + StoredObject { + key: format!( + "{prefix}/{}", + base64::encode_config(dek, base64::URL_SAFE_NO_PAD) + ), + body: good.body.clone(), + }, + "name carry the harness-derived DEK 1", + ), + ( + "b64url(NK) as the object name", + StoredObject { + key: format!( + "{prefix}/{}", + base64::encode_config(fx.keys.nk, base64::URL_SAFE_NO_PAD) + ), + body: good.body.clone(), + }, + "name carry the harness-derived object-name key NK", + ), + ( + "hex(KEK_1) in the body", + StoredObject { + key: good.key.clone(), + body: with_text(hex::encode_upper(kek_1)), + }, + "bytes as text carry the harness-derived KEK of generation 1", + ), + ( + "padded base64(W) in the body", + StoredObject { + key: good.key.clone(), + body: with_text(base64::encode(fx.keys.w)), + }, + "bytes as text carry the harness-derived wrap key W", + ), + ( + "base32(NK) as a middle name segment", + StoredObject { + key: format!( + "v1/{}/{}", + base32_lower(&fx.keys.nk), + good.key.rsplit('/').next().expect("object") + ), + body: good.body.clone(), + }, + "name carry the harness-derived object-name key NK", + ), + ( + "base32(KEK_1) in the body", + StoredObject { + key: good.key.clone(), + body: with_text(base32_lower(&kek_1).to_uppercase()), + }, + "bytes as text carry the harness-derived KEK of generation 1", + ), + ( + "bech32 data of the DEK in the body", + StoredObject { + key: good.key.clone(), + body: with_text(bech32_of("age", &dek)), + }, + "bytes as text carry the harness-derived DEK 1", + ), + ]; + let mut missed = Vec::new(); + for (what, block, expected) in cases { + for policy in [ENCRYPT, key_mirror::Policy::default()] { + let findings = + fx.check_with(&all, &copies(std::slice::from_ref(&block)), &puts, policy); + if !any_contains(&findings, expected) { + missed.push(format!( + "{what} ({policy:?}): expected {expected:?}, got {findings:?}" + )); + } + } + } + ensure!( + missed.is_empty(), + "block secret scan missed:\n{}", + missed.join("\n") + ); + Ok(()) +} + +/// Round 7, offline: no TCE1 salt may hold any 16-byte piece of a harness +/// secret, in any mode: (a) a salt of DEK[0..31] plus one random byte; (b) +/// DEK[0..16] in one block's salt and DEK[16..32] in another's. Random salts +/// (fresh seals) never match. +#[test] +fn selftest_salts_hold_no_piece_of_a_secret() -> Result<()> { + let fx = MirrorFixture::new(); + let all = fx.with_edit("ark-", &|_, _| None); + let dek = fx.keys.deks[0]; + let (a, put_a) = fx.sealed_block(0, b"first value"); + let (b, put_b) = fx.sealed_block(0, b"second value"); + let puts = [put_a, put_b]; + let with_salt = |block: &StoredObject, salt: &[u8]| { + let mut body = block.body.clone(); + body[20..52].copy_from_slice(salt); + StoredObject { + key: block.key.clone(), + body, + } + }; + let pieces = |findings: &[String]| { + findings + .iter() + .filter(|f| f.contains("TCE1 header salt holds bytes")) + .count() + }; + let truncated = [&dek[..31], &random_bytes(1)[..]].concat(); + let half_a = [&dek[..16], &random_bytes(16)[..]].concat(); + let half_b = [&random_bytes(16)[..], &dek[16..]].concat(); + for policy in [ENCRYPT, key_mirror::Policy::default()] { + let findings = fx.check_with(&all, &[with_salt(&a, &truncated)], &puts, policy); + ensure!( + pieces(&findings) == 1 + && any_contains( + &findings, + "salt holds bytes 0..16 of the harness-derived DEK 1" + ), + "a salt of DEK[0..31] plus one byte was missed ({policy:?}): {findings:#?}" + ); + let findings = fx.check_with( + &all, + &[with_salt(&a, &half_a), with_salt(&b, &half_b)], + &puts, + policy, + ); + ensure!( + pieces(&findings) == 2 + && any_contains( + &findings, + "salt holds bytes 0..16 of the harness-derived DEK 1" + ) + && any_contains( + &findings, + "salt holds bytes 16..32 of the harness-derived DEK 1" + ), + "a DEK split across two blocks' salts was missed ({policy:?}): {findings:#?}" + ); + } + let blocks: Vec = (0..64) + .map(|i| fx.sealed_block(i % 2, format!("value {i}").as_bytes()).0) + .collect(); + let findings = fx.check_with(&all, &blocks, &[], key_mirror::Policy::default()); + ensure!( + pieces(&findings) == 0, + "a random salt matched a piece of a secret: {findings:#?}" + ); + Ok(()) +}