use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use ferrocrypt_test_support::wire_manifest::{
self, DEFAULT_LIMIT_PROFILE_ID, SMALL_ARTIFACT_LIMIT_PROFILE_ID, corpus_files, field_violation,
is_corpus_reference, is_manifest_id, is_structural_corpus_file, read_committed, read_table,
read_table_with_columns, sha3_hex, table_columns,
};
use sha3::{Digest, Sha3_256};
use crate::crypto::aead::WRAP_NONCE_SIZE;
use crate::crypto::kdf::KdfLimit;
use crate::crypto::kdf::{ARGON2_SALT_SIZE, KDF_PARAMS_SIZE, KdfParams};
use crate::crypto::keys::{DerivedSubkeys, FileKey, derive_subkeys, random_bytes};
use crate::crypto::stream::STREAM_NONCE_SIZE;
use crate::format::{read_u16_be, read_u32_be, write_u16_be, write_u32_be};
use crate::passphrase::Passphrase;
use crate::recipient::RecipientEntry;
use crate::recipient::name::TYPE_NAME_MAX_LEN;
use crate::recipient::native::{argon2id, x25519};
use crate::{
ArchiveLimits, CryptoError, HeaderReadLimits, KeyPairGenerator, KeyReadLimits, PublicKey,
};
const SCHEMA_VERSION: u32 = 1;
const CORPUS_REVISION: u32 = 1;
const BASELINE_ID: &str = "0.3.0";
const INTRODUCED_IN_RELEASE: &str = "0.3.0";
const WIRE_SEED: u64 = 0xFECC_0000_1234_0003;
fn case_scope(case_id: &str) -> crate::crypto::keys::SeedScope {
let mut hasher = Sha3_256::new();
hasher.update(b"ferrocrypt/wire-corpus/case-seed");
hasher.update(WIRE_SEED.to_be_bytes());
hasher.update([0x00]);
hasher.update(case_id.as_bytes());
let digest = hasher.finalize();
let seed = u64::from_be_bytes(digest[..8].try_into().expect("digest prefix"));
crate::crypto::keys::deterministic_seed_scope(seed)
}
const CORPUS_PASSPHRASE: &str = "wire-corpus-passphrase-not-secret-do-not-reuse";
const WRONG_PASSPHRASE: &str = "wire-corpus-wrong-passphrase";
const SOURCE_FILE_MODE: u16 = 0o644;
const SOURCE_DIR_MODE: u16 = 0o755;
const SOURCE_FILE_NAME: &str = "p";
fn type_capability(domain: &str, type_name: &str) -> Option<String> {
(!wire_manifest::is_test_type_name(type_name))
.then(|| wire_manifest::capability_id(domain, type_name))
}
fn recipient_type_capability(type_name: &str) -> Option<String> {
type_capability(wire_manifest::RECIPIENT_TYPE_DOMAIN, type_name)
}
fn key_type_capability(type_name: &str) -> Option<String> {
type_capability(wire_manifest::KEY_TYPE_DOMAIN, type_name)
}
const UNKNOWN_RECIPIENT_TYPE: &str = "test/unknown";
const UNSUPPORTED_KEY_TYPE: &str = "test/future-kem";
const SHORTEST_TYPE_NAME: &str = "t";
fn ungrammatical_type_name() -> String {
UNSUPPORTED_KEY_TYPE.to_uppercase()
}
const UNGRAMMATICAL_TYPE_NAME_BYTE: u8 = 0x00;
const FILLER: u8 = 0xAA;
const NEWER_VERSION: u8 = 0x02;
fn newer_version_capability(domain: &str) -> String {
wire_manifest::version_capability(domain, NEWER_VERSION)
}
const RESERVED_VERSION: u8 = 0x00;
const RESERVED_FLAG_BIT: u16 = 0x0001;
const RESERVED_RECIPIENT_FLAG_BIT: u16 = 1 << 15;
fn break_magic(bytes: &mut [u8]) {
bytes[0] ^= 0xFF;
}
const UNKNOWN_CRITICAL_TAG: u16 = wire_manifest::CRITICAL_TEST_TAG;
const SECOND_UNKNOWN_CRITICAL_TAG: u16 = 0x8002;
const UNKNOWN_IGNORABLE_TAG: u16 = wire_manifest::IGNORABLE_TEST_TAG;
const SECOND_UNKNOWN_IGNORABLE_TAG: u16 = 0x0002;
const IGNORABLE_HALF_RESERVED_TAG: u16 = 0x0000;
const CRITICAL_HALF_RESERVED_TAG: u16 = 0x8000;
const FCA_EXT_FILLER: u8 = 0x41;
fn critical_tag_then_malformed_entry(critical_value: &[u8]) -> Vec<u8> {
[
crate::crypto::tlv::tlv_bytes(UNKNOWN_CRITICAL_TAG, critical_value),
tlv_value_running_past_region(SECOND_UNKNOWN_CRITICAL_TAG),
]
.concat()
}
fn type_name_on_maximum(stem: &str, fill: &str) -> String {
let name = format!("{stem}{}", fill.repeat(TYPE_NAME_MAX_LEN - stem.len()));
assert_eq!(
name.len(),
TYPE_NAME_MAX_LEN,
"the name must land exactly on the grammar maximum"
);
name
}
fn longest_unsupported_key_type() -> String {
type_name_on_maximum(&format!("{UNSUPPORTED_KEY_TYPE}-"), "k")
}
struct CaseRow {
case_id: String,
case_type: &'static str,
artifact_ref: String,
construction: &'static str,
parent_case_id: String,
payload_transcript_kind: &'static str,
payload_origin_ids: String,
credential_id: String,
limit_profile_id: &'static str,
outcome: &'static str,
expectation_scope: &'static str,
capability_id: String,
condition_id: String,
diagnostic_class: String,
expected_ref: String,
}
impl CaseRow {
fn new(case_id: &str, case_type: &'static str, artifact_ref: &str) -> Self {
Self {
case_id: case_id.to_string(),
case_type,
artifact_ref: artifact_ref.to_string(),
construction: "original",
parent_case_id: "-".to_string(),
payload_transcript_kind: "not_applicable",
payload_origin_ids: "-".to_string(),
credential_id: "-".to_string(),
limit_profile_id: limit_profile_for(case_type),
outcome: "reject",
expectation_scope: "invariant",
capability_id: "-".to_string(),
condition_id: "-".to_string(),
diagnostic_class: "-".to_string(),
expected_ref: "-".to_string(),
}
}
fn fcr(case_id: &str, artifact_ref: &str) -> Self {
Self {
payload_transcript_kind: "none",
..Self::new(case_id, "fcr_decrypt", artifact_ref)
}
}
fn origin(mut self, origin_id: &str) -> Self {
self.payload_transcript_kind = "origins";
self.payload_origin_ids = origin_id.to_string();
self
}
fn credential(mut self, credential_id: &str) -> Self {
self.credential_id = credential_id.to_string();
self
}
fn accept(mut self, expected_ref: &str) -> Self {
self.outcome = "accept";
self.expected_ref = expected_ref.to_string();
self
}
fn reject(mut self, condition_id: &str, diagnostic_class: &str) -> Self {
self.outcome = "reject";
self.condition_id = condition_id.to_string();
self.diagnostic_class = diagnostic_class.to_string();
self
}
fn transcript_equal(mut self, expected_ref: &str) -> Self {
self.outcome = "transcript_equal";
self.expected_ref = expected_ref.to_string();
self
}
fn mutation_of(mut self, parent_case_id: &str) -> Self {
self.construction = "mutation";
self.parent_case_id = parent_case_id.to_string();
self
}
fn fabricated(mut self) -> Self {
self.construction = "fabricated";
self
}
fn capability(mut self, capability_id: &str) -> Self {
self.expectation_scope = "capability_relative";
self.capability_id = capability_id.to_string();
self
}
fn capability_if_any(self, capability_id: Option<&str>) -> Self {
match capability_id {
Some(capability_id) => self.capability(capability_id),
None => self,
}
}
fn limit_profile(mut self, limit_profile_id: &'static str) -> Self {
assert_ne!(
self.limit_profile_id, "-",
"{}: its action applies no local cap, so it names no limit profile",
self.case_id
);
self.limit_profile_id = limit_profile_id;
self
}
}
fn limit_profile_for(case_type: &str) -> &'static str {
match case_type {
"private_key_validate" | "stream_encrypt_kat" => "-",
_ => DEFAULT_LIMIT_PROFILE_ID,
}
}
fn default_limit_profile_row() -> Vec<String> {
let HeaderReadLimits {
max_header_len,
max_recipient_count,
max_recipient_body_len,
max_header_mac_work_bytes,
} = HeaderReadLimits::default();
let KdfLimit {
max_mem_cost_kib,
max_time_cost,
max_lanes,
max_work,
} = KdfLimit::default();
let KeyReadLimits {
max_recipient_string_chars,
max_private_key_wrapped_secret_len,
} = KeyReadLimits::default();
let ArchiveLimits {
max_entry_count,
max_total_plaintext_bytes,
max_path_depth,
max_path_bytes,
max_manifest_bytes,
max_archive_ext_bytes,
max_entry_ext_bytes,
max_total_entry_ext_bytes,
max_tlv_value_bytes,
} = ArchiveLimits::default();
let values: BTreeMap<&str, String> = [
("limit_profile_id", DEFAULT_LIMIT_PROFILE_ID.to_string()),
("max_header_len", max_header_len.to_string()),
("max_recipient_count", max_recipient_count.to_string()),
("max_recipient_body_len", max_recipient_body_len.to_string()),
(
"max_header_mac_work_bytes",
max_header_mac_work_bytes.to_string(),
),
("max_kdf_mem_kib", max_mem_cost_kib.to_string()),
("max_kdf_time", max_time_cost.to_string()),
("max_kdf_lanes", max_lanes.to_string()),
("max_kdf_work", max_work.to_string()),
(
"max_recipient_string_chars",
max_recipient_string_chars.to_string(),
),
(
"max_private_key_wrapped_secret_len",
max_private_key_wrapped_secret_len.to_string(),
),
("max_entry_count", max_entry_count.to_string()),
(
"max_total_plaintext_bytes",
max_total_plaintext_bytes.to_string(),
),
("max_path_depth", max_path_depth.to_string()),
("max_path_bytes", max_path_bytes.to_string()),
("max_manifest_bytes", max_manifest_bytes.to_string()),
("max_archive_ext_bytes", max_archive_ext_bytes.to_string()),
("max_entry_ext_bytes", max_entry_ext_bytes.to_string()),
(
"max_total_entry_ext_bytes",
max_total_entry_ext_bytes.to_string(),
),
("max_tlv_value_bytes", max_tlv_value_bytes.to_string()),
("introduced_in_corpus_revision", CORPUS_REVISION.to_string()),
]
.into_iter()
.collect();
let columns = table_columns("limit-profiles.tsv");
assert_eq!(
values.len(),
columns.len(),
"the default profile fills exactly the profile columns"
);
columns
.iter()
.map(|column| {
values
.get(column)
.unwrap_or_else(|| panic!("the default profile has no {column}"))
.clone()
})
.collect()
}
const HEADER_MAXIMA_LIMIT_PROFILE_ID: &str = "header-structural-maxima";
const LOWERED_TLV_VALUE_LIMIT_PROFILE_ID: &str = "lowered-tlv-value-cap";
const LOWERED_TOTAL_ENTRY_EXT_LIMIT_PROFILE_ID: &str = "lowered-total-entry-ext-cap";
const LOWERED_HEADER_MAC_WORK_LIMIT_PROFILE_ID: &str = "lowered-header-mac-work-cap";
const RECIPIENT_STRING_MAXIMUM_LIMIT_PROFILE_ID: &str = "recipient-string-structural-maximum";
const ZERO_RECIPIENT_STRING_CAP_LIMIT_PROFILE_ID: &str = "zero-recipient-string-cap";
const LOWERED_TLV_VALUE_CAP: u64 = 16;
const LOWERED_TOTAL_ENTRY_EXT_CAP: u64 = 64;
fn derived_limit_profiles() -> Vec<(&'static str, Vec<(&'static str, u64)>)> {
vec![
(
HEADER_MAXIMA_LIMIT_PROFILE_ID,
vec![
(
"max_header_len",
HeaderReadLimits::HEADER_LEN_STRUCTURAL_MAX as u64,
),
(
"max_recipient_count",
HeaderReadLimits::RECIPIENT_COUNT_STRUCTURAL_MAX as u64,
),
(
"max_recipient_body_len",
HeaderReadLimits::RECIPIENT_BODY_LEN_STRUCTURAL_MAX as u64,
),
],
),
(SMALL_ARTIFACT_LIMIT_PROFILE_ID, small_artifact_caps()),
(
LOWERED_TLV_VALUE_LIMIT_PROFILE_ID,
vec![("max_tlv_value_bytes", LOWERED_TLV_VALUE_CAP)],
),
(
LOWERED_TOTAL_ENTRY_EXT_LIMIT_PROFILE_ID,
vec![("max_total_entry_ext_bytes", LOWERED_TOTAL_ENTRY_EXT_CAP)],
),
(
LOWERED_HEADER_MAC_WORK_LIMIT_PROFILE_ID,
vec![("max_header_mac_work_bytes", lowered_header_mac_work_cap())],
),
(
RECIPIENT_STRING_MAXIMUM_LIMIT_PROFILE_ID,
vec![(
"max_recipient_string_chars",
u64::from(KeyReadLimits::RECIPIENT_STRING_CHARS_STRUCTURAL_MAX),
)],
),
(
ZERO_RECIPIENT_STRING_CAP_LIMIT_PROFILE_ID,
vec![("max_recipient_string_chars", 0)],
),
]
}
fn small_artifact_caps() -> Vec<(&'static str, u64)> {
use crate::format::{HEADER_FIXED_SIZE, PREFIX_SIZE};
use crate::recipient::entry::ENTRY_HEADER_SIZE;
let header_len =
(HEADER_FIXED_SIZE + ENTRY_HEADER_SIZE + argon2id::TYPE_NAME.len() + argon2id::BODY_LENGTH)
as u64;
let kdf = KdfParams::test_fast_default();
let fca = build_fca(&[FcaEntry::file(SOURCE_FILE_NAME, &[0])], b"");
let recipient = crate::key::public::encode_recipient_string_unchecked(
x25519::TYPE_NAME,
&[9; x25519::PUBLIC_KEY_SIZE],
)
.expect("encode an x25519 recipient string");
vec![
("max_header_len", header_len),
("max_recipient_count", 1),
("max_recipient_body_len", argon2id::BODY_LENGTH as u64),
("max_header_mac_work_bytes", PREFIX_SIZE as u64 + header_len),
("max_kdf_mem_kib", u64::from(kdf.mem_cost)),
("max_kdf_time", u64::from(kdf.time_cost)),
("max_kdf_lanes", u64::from(kdf.lanes)),
(
"max_kdf_work",
u64::from(kdf.mem_cost) * u64::from(kdf.time_cost),
),
("max_recipient_string_chars", recipient.len() as u64),
(
"max_private_key_wrapped_secret_len",
x25519::WRAPPED_SECRET_LEN as u64,
),
("max_entry_count", 1),
("max_total_plaintext_bytes", 1),
("max_path_depth", 1),
("max_path_bytes", SOURCE_FILE_NAME.len() as u64),
(
"max_manifest_bytes",
u64::from(be_u32_at(&fca, FCA_OFF_MANIFEST_LEN)),
),
("max_archive_ext_bytes", 0),
("max_entry_ext_bytes", 0),
("max_total_entry_ext_bytes", 0),
("max_tlv_value_bytes", 0),
]
}
fn lowered_header_mac_work_cap() -> u64 {
let x25519_slot = RecipientEntry {
type_name: x25519::TYPE_NAME.to_string(),
recipient_flags: 0,
body: vec![0; x25519::BODY_LENGTH],
};
let refused = [unknown_entry(false), x25519_slot.clone(), x25519_slot];
header_mac_work(2, header_len_of(&refused)) - 1
}
fn header_len_of(entries: &[RecipientEntry]) -> usize {
crate::format::HEADER_FIXED_SIZE
+ entries
.iter()
.map(|entry| entry.to_bytes().len())
.sum::<usize>()
}
fn header_mac_work(supported: usize, header_len: usize) -> u64 {
(supported * (crate::format::PREFIX_SIZE + header_len)) as u64
}
fn padding_past_lowered_header_mac_work_cap(
entries: &[RecipientEntry],
crossing: usize,
) -> Vec<u8> {
assert!(
entries.iter().all(|entry| {
crate::recipient::policy::NativeRecipientType::from_type_name(&entry.type_name)
.is_some()
}),
"every entry must be native, so every reader counts it as supported"
);
assert!(
(1..=entries.len()).contains(&crossing),
"the entries hold the recipients that cross the cap"
);
let cap = lowered_header_mac_work_cap();
let per_recipient = usize::try_from(cap).expect("the cap is small") / crossing + 1;
let unpadded = header_len_of(entries);
let ext_len = per_recipient
.checked_sub(crate::format::PREFIX_SIZE + unpadded)
.expect("the entries leave room to pad the header");
let region = ignorable_tlv_region(ext_len, FILLER);
let header_len = unpadded + region.len();
let work = header_mac_work(crossing, header_len);
assert!(
work > cap
&& work - crossing as u64 <= cap
&& header_mac_work(crossing - 1, header_len) <= cap,
"the padding takes the header just past the cap at that many recipients"
);
region
}
fn ignorable_tlv_region(len: usize, fill: u8) -> Vec<u8> {
use crate::crypto::tlv::{ENTRY_HEADER_SIZE as TLV_ENTRY_HEADER_SIZE, tlv_bytes};
let value_len = len
.checked_sub(TLV_ENTRY_HEADER_SIZE)
.expect("the region holds at least one entry header");
let region = tlv_bytes(UNKNOWN_IGNORABLE_TAG, &vec![fill; value_len]);
assert_eq!(region.len(), len);
region
}
fn limit_profile_rows() -> Vec<Vec<String>> {
let columns = table_columns("limit-profiles.tsv");
let column_at = |column: &str| {
columns
.iter()
.position(|c| *c == column)
.unwrap_or_else(|| panic!("limit-profiles.tsv has no {column} column"))
};
let default = default_limit_profile_row();
let mut rows = vec![default.clone()];
for (profile_id, changed) in derived_limit_profiles() {
let mut row = default.clone();
row[column_at("limit_profile_id")] = profile_id.to_string();
for (column, value) in changed {
let at = column_at(column);
assert_ne!(
row[at],
value.to_string(),
"{profile_id}: {column} already has this value in the default profile"
);
row[at] = value.to_string();
}
rows.push(row);
}
rows
}
struct CredentialRow {
credential_id: String,
kind: &'static str,
primary_ref: String,
secret_ref: String,
}
struct OriginRow {
origin_id: String,
origin_kind: &'static str,
anchor_case_id: String,
payload_key_ref: String,
payload_key_sha3_256: String,
stream_nonce_hex: String,
}
struct Corpus {
root: PathBuf,
cases: Vec<CaseRow>,
credentials: Vec<CredentialRow>,
origins: Vec<OriginRow>,
classes: BTreeMap<&'static str, &'static str>,
active_limit_profile: Option<&'static str>,
}
impl Corpus {
fn new(root: PathBuf) -> Self {
Self {
root,
cases: Vec::new(),
credentials: Vec::new(),
origins: Vec::new(),
classes: BTreeMap::new(),
active_limit_profile: None,
}
}
fn write_ref(&self, relative_ref: &str, bytes: &[u8]) -> String {
assert!(
is_corpus_reference(relative_ref),
"{relative_ref:?} is not a corpus reference"
);
let path = self.root.join(relative_ref);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create corpus subdirectory");
}
fs::write(&path, bytes).expect("write corpus file");
relative_ref.to_string()
}
fn push_case(&mut self, case: CaseRow) {
let case = match self.active_limit_profile {
Some(profile_id) => case.limit_profile(profile_id),
None => case,
};
if case.diagnostic_class != "-" {
let (id, text) = DIAGNOSTIC_CLASS_TEXT
.iter()
.find(|(id, _)| *id == case.diagnostic_class)
.unwrap_or_else(|| {
panic!("unregistered diagnostic class {}", case.diagnostic_class)
});
self.classes.insert(id, text);
}
self.cases.push(case);
}
fn push_credential(&mut self, credential: CredentialRow) {
self.credentials.push(credential);
}
fn push_origin(&mut self, origin: OriginRow) {
self.origins.push(origin);
}
}
fn under_limit_profile(
corpus: &mut Corpus,
limit_profile_id: &'static str,
write: impl FnOnce(&mut Corpus),
) {
assert!(
corpus.active_limit_profile.is_none(),
"limit profile scopes do not nest"
);
corpus.active_limit_profile = Some(limit_profile_id);
write(corpus);
corpus.active_limit_profile = None;
}
fn digest_of_ref(root: &Path, reference: &str) -> String {
if reference == "-" {
return "-".to_string();
}
let bytes = fs::read(root.join(reference))
.unwrap_or_else(|e| panic!("read referenced corpus file {reference}: {e}"));
sha3_hex(&bytes)
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
const DIAGNOSTIC_CLASS_TEXT: &[(&str, &str)] = &[
(
"truncated",
"Top-level .fcr framing ended before the prefix, declared header, or header MAC was complete.",
),
("bad_magic", "The top-level FCR\\0 magic does not match."),
(
"not_a_key_file",
"The input is not a recognized FerroCrypt key artifact.",
),
(
"wrong_kind",
"An FCR\\0 artifact has the wrong kind for the requested operation.",
),
(
"wrong_key_file_type",
"The input opens like the other key file: with the private.key signature where a public.key is requested (FORMAT.md section 7.1), or with fcr1 where a private.key is requested (FORMAT.md section 8).",
),
(
"unsupported_outer_version",
"A nonzero .fcr outer-container version this implementation does not support.",
),
(
"unsupported_fca_version",
"A nonzero FCA archive version this implementation does not support.",
),
(
"unsupported_public_key_version",
"A nonzero public-key encoding version this implementation does not support.",
),
(
"unsupported_private_key_version",
"A nonzero private-key encoding version this implementation does not support.",
),
(
"oversized_header",
"The declared .fcr header exceeds the structural maximum.",
),
(
"malformed_header",
"The .fcr prefix or header violates a structural grammar or accounting rule assigned to this class.",
),
(
"extension_region_too_large",
"The declared .fcr header extension region exceeds its structural maximum.",
),
(
"malformed_tlv",
"A TLV region violates framing or canonicality rules, or an FCA TLV value exceeds the configured max_tlv_value_bytes (FORMAT.md section 9.12).",
),
(
"unknown_critical_tlv",
"A well-formed critical TLV is unsupported.",
),
(
"recipient_count_out_of_range",
"The recipient count violates structural bounds.",
),
(
"malformed_type_name",
"A recipient type name violates FORMAT.md section 3.3.",
),
(
"malformed_recipient_entry",
"Recipient framing or recipient-specific structural validation failed.",
),
(
"recipient_flags_reserved",
"A reserved recipient flag bit is nonzero.",
),
(
"unknown_critical_recipient",
"A well-formed critical recipient type is unsupported.",
),
(
"no_supported_recipient",
"No supported recipient type is present.",
),
(
"incompatible_recipients",
"The recipient set violates a mixing policy.",
),
(
"recipient_unwrap_failed",
"No supported recipient accepted the supplied credential.",
),
(
"header_authentication_failed",
"A candidate file key failed header-MAC verification.",
),
(
"invalid_kdf_parameters",
"Stored KDF parameters violate structural rules.",
),
(
"resource_cap_exceeded",
"Structurally valid data exceeds configured local resource policy, except the FCA per-value TLV cap (FORMAT.md section 9.12).",
),
(
"payload_authentication_failed",
"Payload-chunk authentication failed.",
),
(
"payload_truncated",
"The encrypted payload ended before a valid final chunk.",
),
(
"malformed_payload_stream",
"The payload STREAM transcript violates structural rules.",
),
(
"extra_data_after_payload",
"Bytes follow the authenticated final payload chunk.",
),
(
"payload_chunk_count_exceeded",
"The payload exceeds the permitted counter/chunk range.",
),
(
"malformed_archive",
"The authenticated FCA payload violates header, manifest, or content-region grammar.",
),
(
"unsafe_archive_path",
"An FCA path violates the portable path-safety grammar.",
),
(
"invalid_archive_tree",
"The FCA manifest violates tree-shape or collision rules.",
),
(
"malformed_public_key",
"A public-key artifact violates its encoding or canonicality rules.",
),
(
"unsupported_key_type",
"A well-formed key type is unsupported.",
),
(
"malformed_private_key",
"A private-key artifact violates its encoding or post-unlock consistency rules.",
),
(
"private_key_unlock_failed",
"Private-key authentication failed for the supplied passphrase or modified file.",
),
];
#[test]
fn every_class_text_is_its_registry_meaning() {
let spec = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("FORMAT.md"))
.expect("read FORMAT.md");
let header = "| Diagnostic class | Meaning | FerroCrypt mapping |";
let table = &spec[spec.find(header).expect("the ยง12.1 registry table")..];
let meanings: BTreeMap<&str, &str> = table
.lines()
.skip(2)
.take_while(|line| line.starts_with("| `"))
.map(|line| {
let cells: Vec<&str> = line.trim_matches('|').split('|').map(str::trim).collect();
(cells[0].trim_matches('`'), cells[1])
})
.collect();
assert_eq!(
meanings.len(),
DIAGNOSTIC_CLASS_TEXT.len(),
"one text per registry class"
);
for (class_id, text) in DIAGNOSTIC_CLASS_TEXT {
let meaning = meanings
.get(class_id)
.unwrap_or_else(|| panic!("{class_id} is not in the ยง12.1 registry"));
let expected = format!(
"{}.",
meaning.replace('`', "").replace('ยง', "FORMAT.md section ")
);
assert_eq!(*text, expected, "{class_id}");
}
}
fn check_field(table: &str, column: &str, value: &str) {
for forbidden in ['\t', '\r', '\n'] {
assert!(
!value.contains(forbidden),
"{table}.{column} must not contain {forbidden:?}: {value}"
);
}
if let Some(violation) = field_violation(table, column, value) {
panic!("{table}.{column} {value:?}: {violation}");
}
}
fn write_table(root: &Path, name: &str, columns: &[&str], rows: Vec<Vec<String>>) {
let mut out = format!("# {}\n", columns.join("\t"));
for row in &rows {
assert_eq!(row.len(), columns.len(), "{name} row width");
for (column, value) in columns.iter().zip(row) {
check_field(name, column, value);
}
out.push_str(&row.join("\t"));
out.push('\n');
}
fs::write(root.join(name), out).unwrap_or_else(|e| panic!("write {name}: {e}"));
}
struct BuiltFcr {
bytes: Vec<u8>,
stream_nonce_hex: String,
payload_key_sha3_256: String,
}
fn corpus_passphrase() -> Passphrase {
Passphrase::new(CORPUS_PASSPHRASE)
}
fn build_fcr(
source: &Path,
file_key: &FileKey,
entries: &[RecipientEntry],
ext_bytes: &[u8],
) -> Result<BuiltFcr, CryptoError> {
let stream_nonce = random_bytes::<STREAM_NONCE_SIZE>()?;
let DerivedSubkeys {
payload_key,
header_key,
} = derive_subkeys(file_key, &stream_nonce)?;
let payload_key_sha3_256 = sha3_hex(payload_key.expose());
let built = crate::container::build_encrypted_header(
entries,
ext_bytes,
stream_nonce,
payload_key,
&header_key,
)?;
let staging = tempfile::tempdir().expect("fcr staging dir");
let prepared = crate::archive::prepare_archive(source, ArchiveLimits::default())?;
let written = crate::container::write_encrypted_file(
prepared,
staging.path(),
Some(&staging.path().join("out.fcr")),
"wire",
&built,
)?;
Ok(BuiltFcr {
bytes: fs::read(&written).expect("read built fcr"),
stream_nonce_hex: hex(&stream_nonce),
payload_key_sha3_256,
})
}
fn argon2id_entry(file_key: &FileKey) -> RecipientEntry {
let body = argon2id::wrap(
file_key,
&corpus_passphrase(),
&KdfParams::test_fast_default(),
&|_| {},
)
.expect("wrap argon2id recipient");
RecipientEntry {
type_name: argon2id::TYPE_NAME.to_string(),
recipient_flags: 0,
body: body.to_vec(),
}
}
fn argon2id_entry_with_kdf_params(file_key: &FileKey, params: &KdfParams) -> RecipientEntry {
let mut entry = argon2id_entry(file_key);
entry.body[ARGON2_SALT_SIZE..ARGON2_SALT_SIZE + KDF_PARAMS_SIZE]
.copy_from_slice(¶ms.to_bytes());
entry
}
fn decode_public_key_file(bytes: &[u8]) -> PublicKey {
std::str::from_utf8(bytes)
.expect("public.key is UTF-8")
.trim_end_matches('\n')
.parse()
.expect("public.key holds a canonical recipient string")
}
fn x25519_entry(public_key_file: &[u8], file_key: &FileKey) -> RecipientEntry {
let public = decode_public_key_file(public_key_file)
.to_x25519_bytes()
.expect("resolve corpus public key");
let body = x25519::wrap(file_key, &public).expect("wrap x25519 recipient");
RecipientEntry {
type_name: x25519::TYPE_NAME.to_string(),
recipient_flags: 0,
body: body.to_vec(),
}
}
fn write_source(dir: &Path, name: &str, len: usize) -> PathBuf {
let path = dir.join(name);
let content: Vec<u8> = (0..len).map(|i| (i * 7 + 13) as u8).collect();
fs::write(&path, &content).expect("write source file");
set_source_mode(&path);
path
}
#[cfg(unix)]
fn set_source_mode(path: &Path) {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(
path,
fs::Permissions::from_mode(u32::from(SOURCE_FILE_MODE)),
)
.expect("set source mode");
}
#[cfg(not(unix))]
fn set_source_mode(_path: &Path) {}
fn generate_key_pair() -> (Vec<u8>, Vec<u8>) {
let staging = tempfile::tempdir().expect("keygen staging dir");
KeyPairGenerator::with_passphrase(corpus_passphrase())
.kdf_params(KdfParams::test_fast_default())
.write(staging.path(), |_| {})
.expect("generate corpus key pair");
(
fs::read(staging.path().join("public.key")).expect("read public.key"),
fs::read(staging.path().join("private.key")).expect("read private.key"),
)
}
pub(crate) fn wire_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("testvectors/wire")
}
pub(crate) fn wire_corpus_present() -> bool {
wire_dir().is_dir()
}
#[test]
#[ignore]
fn regenerate_wire_corpus() {
crate::crypto::keys::with_sealed_rng(regenerate_wire_corpus_inner);
}
fn regenerate_wire_corpus_inner() {
let root = wire_dir();
for generated in ["artifacts", "expected", "credentials", "diagnostic-classes"] {
let dir = root.join(generated);
if dir.exists() {
fs::remove_dir_all(&dir).expect("clean generated corpus subdirectory");
}
}
fs::create_dir_all(&root).expect("create corpus root");
let mut corpus = Corpus::new(root.clone());
let sources = tempfile::tempdir().expect("source staging dir");
write_credentials(&mut corpus);
let keys = write_key_artifacts(&mut corpus);
let (base, x25519_base) = write_valid_fcr_cases(&mut corpus, sources.path(), &keys);
write_prefix_cases(&mut corpus, &base);
write_header_cases(&mut corpus, &base);
write_header_maximum_cases(&mut corpus, sources.path(), &keys);
write_recipient_framing_cases(&mut corpus, sources.path(), &keys, &base);
write_argon2id_cases(&mut corpus, sources.path(), &base);
write_x25519_cases(&mut corpus, &x25519_base);
write_tlv_cases(&mut corpus, sources.path());
write_payload_stream_cases(&mut corpus, sources.path());
write_public_key_cases(&mut corpus, &keys, &base.bytes);
write_private_key_cases(&mut corpus, &keys);
write_fca_cases(&mut corpus);
write_resource_policy_cases(&mut corpus, &keys);
write_lowered_limit_cases(&mut corpus, sources.path(), &keys, &base);
write_stream_kat_cases(&mut corpus);
emit(&corpus);
}
struct CorpusKeys {
public_a: Vec<u8>,
private_a: String,
public_b: Vec<u8>,
}
fn write_credentials(corpus: &mut Corpus) {
let main = corpus.write_ref(
"credentials/passphrase-main.txt",
CORPUS_PASSPHRASE.as_bytes(),
);
let wrong = corpus.write_ref(
"credentials/passphrase-wrong.txt",
WRONG_PASSPHRASE.as_bytes(),
);
corpus.push_credential(CredentialRow {
credential_id: "passphrase-main".to_string(),
kind: "passphrase",
primary_ref: main,
secret_ref: "-".to_string(),
});
corpus.push_credential(CredentialRow {
credential_id: "passphrase-wrong".to_string(),
kind: "passphrase",
primary_ref: wrong,
secret_ref: "-".to_string(),
});
corpus.push_credential(CredentialRow {
credential_id: "none".to_string(),
kind: "none",
primary_ref: "-".to_string(),
secret_ref: "-".to_string(),
});
}
fn private_key_open_row(
corpus: &mut Corpus,
case_id: &str,
bytes: &[u8],
credential_id: &str,
) -> CaseRow {
let artifact_ref = corpus.write_ref(
&format!("artifacts/private-key/{case_id}.private.key"),
bytes,
);
CaseRow::new(case_id, "private_key_open", &artifact_ref)
.fabricated()
.credential(credential_id)
}
fn private_key_open_case(
corpus: &mut Corpus,
case_id: &str,
bytes: &[u8],
credential_id: &str,
outcome: Result<[u8; 32], (&str, &str)>,
) {
let row = private_key_open_row(corpus, case_id, bytes, credential_id);
let row = match outcome {
Ok(material) => {
let expected_ref =
corpus.write_ref(&format!("expected/private-key/{case_id}.bin"), &material);
row.accept(&expected_ref)
}
Err((condition, class)) => row.reject(condition, class),
};
corpus.push_case(row);
}
fn write_key_artifacts(corpus: &mut Corpus) -> CorpusKeys {
let mut refs = Vec::new();
for label in ["a", "b"] {
let _scope = case_scope(&format!("private-key-{label}"));
let (public, private) = generate_key_pair();
let private_ref = corpus.write_ref(
&format!("artifacts/private-key/recipient-{label}.private.key"),
&private,
);
corpus.push_credential(CredentialRow {
credential_id: format!("private-key-{label}"),
kind: "private_key",
primary_ref: private_ref.clone(),
secret_ref: "credentials/passphrase-main.txt".to_string(),
});
if label == "a" {
corpus.push_credential(CredentialRow {
credential_id: "private-key-a-wrong-unlock".to_string(),
kind: "private_key",
primary_ref: private_ref.clone(),
secret_ref: "credentials/passphrase-wrong.txt".to_string(),
});
}
refs.push((public, private_ref));
}
let (public_b, _) = refs.pop().expect("recipient b");
let (public_a, private_a) = refs.pop().expect("recipient a");
CorpusKeys {
public_a,
private_a,
public_b,
}
}
fn accept_fcr_case(
corpus: &mut Corpus,
case_id: &str,
origin_id: &str,
credential_id: &str,
source: &Path,
built: BuiltFcr,
) {
let artifact_ref = corpus.write_ref(&format!("artifacts/fcr/{case_id}.fcr"), &built.bytes);
let expected_ref = corpus.write_ref(
&format!("expected/plaintext/{case_id}.bin"),
&fs::read(source).expect("read source for expected plaintext"),
);
corpus.push_origin(OriginRow {
origin_id: origin_id.to_string(),
origin_kind: "fcr_payload",
anchor_case_id: case_id.to_string(),
payload_key_ref: "-".to_string(),
payload_key_sha3_256: built.payload_key_sha3_256,
stream_nonce_hex: built.stream_nonce_hex,
});
corpus.push_case(
CaseRow::fcr(case_id, &artifact_ref)
.origin(origin_id)
.credential(credential_id)
.accept(&expected_ref),
);
}
fn fca_overhead(name: &str) -> usize {
build_fca(&[FcaEntry::file(name, b"")], b"").len()
}
fn write_valid_fcr_cases(
corpus: &mut Corpus,
sources: &Path,
keys: &CorpusKeys,
) -> (MutationBase, MutationBase) {
let mut mutation_base = None;
let chunk = crate::crypto::stream::BUFFER_SIZE;
let overhead = fca_overhead(SOURCE_FILE_NAME);
let sizes: [(&str, usize); 5] = [
("empty", 0),
("one-byte", 1),
("chunk-exact", chunk - overhead),
("chunk-plus-one", chunk + 1 - overhead),
("multi-chunk", chunk * 3 + 977 - overhead),
];
for (label, len) in sizes {
let _scope = case_scope(&format!("fcr-argon2id-{label}"));
let source = write_source(sources, SOURCE_FILE_NAME, len);
let file_key = FileKey::generate().expect("file key");
let entries = [argon2id_entry(&file_key)];
let built = build_fcr(&source, &file_key, &entries, b"").expect("build argon2id fcr");
let case_id = format!("fcr-argon2id-{label}");
let origin_id = format!("origin-argon2id-{label}");
if label == "one-byte" {
mutation_base = Some(MutationBase {
case_id: case_id.clone(),
origin_id: origin_id.clone(),
bytes: built.bytes.clone(),
});
}
accept_fcr_case(
corpus,
&case_id,
&origin_id,
"passphrase-main",
&source,
built,
);
}
let scope = case_scope("fcr-x25519-single");
let source = write_source(sources, SOURCE_FILE_NAME, 4096);
let file_key = FileKey::generate().expect("file key");
let entries = [x25519_entry(&keys.public_a, &file_key)];
let built = build_fcr(&source, &file_key, &entries, b"").expect("build x25519 fcr");
let x25519_base = MutationBase {
case_id: "fcr-x25519-single".to_string(),
origin_id: "origin-x25519-single".to_string(),
bytes: built.bytes.clone(),
};
accept_fcr_case(
corpus,
"fcr-x25519-single",
"origin-x25519-single",
"private-key-a",
&source,
built,
);
drop(scope);
let scope = case_scope("fcr-x25519-multi-first");
let file_key = FileKey::generate().expect("file key");
let entries = [
x25519_entry(&keys.public_a, &file_key),
x25519_entry(&keys.public_b, &file_key),
];
let built = build_fcr(&source, &file_key, &entries, b"").expect("build multi-x25519 fcr");
let multi_bytes = built.bytes.clone();
accept_fcr_case(
corpus,
"fcr-x25519-multi-first",
"origin-x25519-multi",
"private-key-a",
&source,
built,
);
drop(scope);
let artifact_ref = corpus.write_ref("artifacts/fcr/fcr-x25519-multi-second.fcr", &multi_bytes);
let expected_ref = corpus.write_ref(
"expected/plaintext/fcr-x25519-multi-second.bin",
&fs::read(&source).expect("read source"),
);
corpus.push_case(
CaseRow::fcr("fcr-x25519-multi-second", &artifact_ref)
.origin("origin-x25519-multi")
.credential("private-key-b")
.accept(&expected_ref),
);
(mutation_base.expect("mutation base built"), x25519_base)
}
fn published_rows(root: &Path, name: &str, columns: &[&str]) -> Vec<Vec<String>> {
if !root.join(name).is_file() {
return Vec::new();
}
read_table_with_columns(root, name, columns)
.into_iter()
.map(|row| {
columns
.iter()
.map(|column| {
row.get(*column)
.unwrap_or_else(|| panic!("{name}: a published row has no {column} column"))
.clone()
})
.collect()
})
.collect()
}
const STAMP_COLUMNS: [&str; 3] = [
"introduced_in_corpus_revision",
"introduced_in_release",
"established_by_release",
];
fn write_appended_table(
root: &Path,
name: &str,
id_column: &str,
columns: &[&str],
mut rows: Vec<Vec<String>>,
) {
let column_at = |column: &str| {
columns
.iter()
.position(|c| *c == column)
.unwrap_or_else(|| panic!("{name}: no {column} column"))
};
let id_at = column_at(id_column);
let revision_at = column_at("introduced_in_corpus_revision");
let stamps: Vec<usize> = STAMP_COLUMNS
.iter()
.filter(|column| columns.contains(column))
.map(|column| column_at(column))
.collect();
let published: BTreeMap<String, Vec<String>> = published_rows(root, name, columns)
.into_iter()
.map(|row| (row[id_at].clone(), row))
.collect();
let mut carried = BTreeSet::new();
for row in &mut rows {
let Some(before) = published.get(&row[id_at]) else {
continue;
};
carried.insert(row[id_at].clone());
for at in &stamps {
row[*at] = before[*at].clone();
}
let revision: u32 = before[revision_at]
.parse()
.unwrap_or_else(|e| panic!("{name}: {}: revision column: {e}", row[id_at]));
if revision < CORPUS_REVISION {
for (at, column) in columns.iter().enumerate() {
assert_eq!(
row[at], before[at],
"{name}: {} is frozen at corpus revision {revision}; \
its {column} must be corrected through an erratum, not an edit",
row[id_at],
);
}
}
}
for (id, before) in &published {
let revision: u32 = before[revision_at]
.parse()
.unwrap_or_else(|e| panic!("{name}: {id}: revision column: {e}"));
assert!(
revision >= CORPUS_REVISION || carried.contains(id),
"{name}: {id} was published by corpus revision {revision} and cannot be withdrawn; \
retire it through an erratum instead",
);
}
write_table(root, name, columns, rows);
}
const APPEND_TEST_COLUMNS: [&str; 4] = [
"case_id",
"artifact_ref",
"introduced_in_release",
"introduced_in_corpus_revision",
];
fn append_test_row(case_id: &str, artifact: &str, release: &str, revision: &str) -> Vec<String> {
vec![
case_id.to_string(),
artifact.to_string(),
release.to_string(),
revision.to_string(),
]
}
fn publish_append_test_table(dir: &Path) {
write_table(
dir,
"t.tsv",
&APPEND_TEST_COLUMNS,
vec![
append_test_row("frozen", "a.bin", "0.2.0", "0"),
append_test_row("working", "b.bin", "0.2.0", &CORPUS_REVISION.to_string()),
],
);
}
fn append_test_rebuild() -> Vec<Vec<String>> {
let revision = CORPUS_REVISION.to_string();
vec![
append_test_row("frozen", "a.bin", INTRODUCED_IN_RELEASE, &revision),
append_test_row("working", "b.bin", INTRODUCED_IN_RELEASE, &revision),
append_test_row("added", "c.bin", INTRODUCED_IN_RELEASE, &revision),
]
}
#[test]
fn a_published_row_keeps_the_stamps_it_was_published_with() {
let dir = tempfile::tempdir().expect("append-only table dir");
publish_append_test_table(dir.path());
write_appended_table(
dir.path(),
"t.tsv",
"case_id",
&APPEND_TEST_COLUMNS,
append_test_rebuild(),
);
let rows = read_table_with_columns(dir.path(), "t.tsv", &APPEND_TEST_COLUMNS);
let stamps = |case_id: &str| {
let row = rows
.iter()
.find(|r| r["case_id"] == case_id)
.unwrap_or_else(|| panic!("{case_id} is missing"));
(
row["introduced_in_release"].clone(),
row["introduced_in_corpus_revision"].clone(),
)
};
assert_eq!(
stamps("frozen"),
("0.2.0".to_string(), "0".to_string()),
"a frozen row keeps its own provenance"
);
assert_eq!(
stamps("working"),
("0.2.0".to_string(), CORPUS_REVISION.to_string()),
"a row of the working revision keeps its release too"
);
assert_eq!(
stamps("added"),
(
INTRODUCED_IN_RELEASE.to_string(),
CORPUS_REVISION.to_string()
),
"only an added row is stamped with the current constants"
);
}
#[test]
fn a_row_of_the_working_revision_may_still_change() {
let dir = tempfile::tempdir().expect("append-only table dir");
publish_append_test_table(dir.path());
let mut rows = append_test_rebuild();
rows[1] = append_test_row(
"working",
"edited.bin",
INTRODUCED_IN_RELEASE,
&CORPUS_REVISION.to_string(),
);
write_appended_table(dir.path(), "t.tsv", "case_id", &APPEND_TEST_COLUMNS, rows);
let rows = read_table_with_columns(dir.path(), "t.tsv", &APPEND_TEST_COLUMNS);
let working = rows
.iter()
.find(|r| r["case_id"] == "working")
.expect("working row");
assert_eq!(working["artifact_ref"], "edited.bin");
}
#[test]
#[should_panic(expected = "must be corrected through an erratum")]
fn an_edited_frozen_row_is_refused() {
let dir = tempfile::tempdir().expect("append-only table dir");
publish_append_test_table(dir.path());
let mut rows = append_test_rebuild();
rows[0] = append_test_row(
"frozen",
"edited.bin",
INTRODUCED_IN_RELEASE,
&CORPUS_REVISION.to_string(),
);
write_appended_table(dir.path(), "t.tsv", "case_id", &APPEND_TEST_COLUMNS, rows);
}
#[test]
#[should_panic(expected = "cannot be withdrawn")]
fn a_withdrawn_frozen_row_is_refused() {
let dir = tempfile::tempdir().expect("append-only table dir");
publish_append_test_table(dir.path());
let rows = append_test_rebuild().into_iter().skip(1).collect();
write_appended_table(dir.path(), "t.tsv", "case_id", &APPEND_TEST_COLUMNS, rows);
}
#[test]
#[should_panic(expected = "is not a corpus reference")]
fn the_generator_refuses_to_write_outside_the_corpus() {
let dir = tempfile::tempdir().expect("corpus dir");
let root = dir.path().join("wire");
Corpus::new(root).write_ref("../outside.bin", b"escaped");
}
#[test]
fn every_limit_profile_is_a_valid_row() {
for row in limit_profile_rows() {
for (column, value) in table_columns("limit-profiles.tsv").iter().zip(&row) {
check_field("limit-profiles.tsv", column, value);
}
}
}
fn emit(corpus: &Corpus) {
check_manifest_invariants(corpus);
let root = &corpus.root;
write_appended_table(
root,
"baselines.tsv",
"baseline_id",
table_columns("baselines.tsv"),
vec![vec![
BASELINE_ID.to_string(),
INTRODUCED_IN_RELEASE.to_string(),
"-".to_string(),
CORPUS_REVISION.to_string(),
]],
);
let mut class_rows = Vec::new();
for (class_id, text) in &corpus.classes {
let description_ref = corpus.write_ref(
&format!("diagnostic-classes/{class_id}.txt"),
format!("{text}\n").as_bytes(),
);
let digest = digest_of_ref(root, &description_ref);
class_rows.push(vec![
(*class_id).to_string(),
description_ref,
digest,
CORPUS_REVISION.to_string(),
]);
}
write_appended_table(
root,
"diagnostic-classes.tsv",
"class_id",
table_columns("diagnostic-classes.tsv"),
class_rows,
);
write_appended_table(
root,
"credentials.tsv",
"credential_id",
table_columns("credentials.tsv"),
corpus
.credentials
.iter()
.map(|c| {
vec![
c.credential_id.clone(),
c.kind.to_string(),
c.primary_ref.clone(),
digest_of_ref(root, &c.primary_ref),
c.secret_ref.clone(),
digest_of_ref(root, &c.secret_ref),
INTRODUCED_IN_RELEASE.to_string(),
CORPUS_REVISION.to_string(),
]
})
.collect(),
);
write_appended_table(
root,
"limit-profiles.tsv",
"limit_profile_id",
table_columns("limit-profiles.tsv"),
limit_profile_rows(),
);
write_appended_table(
root,
"origins.tsv",
"origin_id",
table_columns("origins.tsv"),
corpus
.origins
.iter()
.map(|o| {
vec![
o.origin_id.clone(),
o.origin_kind.to_string(),
o.anchor_case_id.clone(),
o.payload_key_ref.clone(),
o.payload_key_sha3_256.clone(),
o.stream_nonce_hex.clone(),
INTRODUCED_IN_RELEASE.to_string(),
CORPUS_REVISION.to_string(),
]
})
.collect(),
);
write_appended_table(
root,
"cases.tsv",
"case_id",
table_columns("cases.tsv"),
corpus
.cases
.iter()
.map(|c| {
vec![
c.case_id.clone(),
c.case_type.to_string(),
c.artifact_ref.clone(),
digest_of_ref(root, &c.artifact_ref),
BASELINE_ID.to_string(),
INTRODUCED_IN_RELEASE.to_string(),
CORPUS_REVISION.to_string(),
c.construction.to_string(),
c.parent_case_id.clone(),
c.payload_transcript_kind.to_string(),
c.payload_origin_ids.clone(),
c.credential_id.clone(),
c.limit_profile_id.to_string(),
c.outcome.to_string(),
c.expectation_scope.to_string(),
c.capability_id.clone(),
c.condition_id.clone(),
c.diagnostic_class.clone(),
c.expected_ref.clone(),
digest_of_ref(root, &c.expected_ref),
]
})
.collect(),
);
let errata_columns = table_columns("errata.tsv");
let errata = published_rows(root, "errata.tsv", errata_columns);
write_table(root, "errata.tsv", errata_columns, errata);
fs::write(root.join("SCHEMA-VERSION"), format!("{SCHEMA_VERSION}\n"))
.expect("write SCHEMA-VERSION");
fs::write(root.join("CORPUS-REVISION"), format!("{CORPUS_REVISION}\n"))
.expect("write CORPUS-REVISION");
assert_no_unreferenced_files(corpus);
}
fn assert_no_unreferenced_files(corpus: &Corpus) {
let mut referenced: std::collections::BTreeSet<String> = corpus
.cases
.iter()
.flat_map(|c| [c.artifact_ref.clone(), c.expected_ref.clone()])
.chain(
corpus
.credentials
.iter()
.flat_map(|c| [c.primary_ref.clone(), c.secret_ref.clone()]),
)
.chain(corpus.origins.iter().map(|o| o.payload_key_ref.clone()))
.collect();
referenced.extend(
corpus
.classes
.keys()
.map(|class| format!("diagnostic-classes/{class}.txt")),
);
for path in corpus_files(&corpus.root) {
let relative = path
.strip_prefix(&corpus.root)
.expect("corpus file is under the corpus root")
.to_str()
.expect("corpus path is UTF-8")
.replace('\\', "/");
assert!(
is_structural_corpus_file(&relative) || referenced.contains(&relative),
"{relative}: the generator left a file no manifest row references"
);
}
}
fn check_manifest_invariants(corpus: &Corpus) {
let mut seen = std::collections::BTreeSet::new();
for case in &corpus.cases {
assert!(
seen.insert(case.case_id.clone()),
"duplicate case id {}",
case.case_id
);
assert!(
is_manifest_id(&case.case_id),
"case id {} must match [a-z0-9][a-z0-9._-]*",
case.case_id
);
match case.outcome {
"reject" => {
assert_ne!(
case.condition_id, "-",
"{}: reject needs a condition",
case.case_id
);
assert_ne!(
case.diagnostic_class, "-",
"{}: reject needs a diagnostic class",
case.case_id
);
assert_eq!(
case.expected_ref, "-",
"{}: reject has no expected result",
case.case_id
);
}
"accept" | "transcript_equal" => {
assert_ne!(
case.expected_ref, "-",
"{}: non-reject needs a byte-exact expected result",
case.case_id
);
assert_eq!(
case.diagnostic_class, "-",
"{}: non-reject uses diagnostic_class '-'",
case.case_id
);
}
other => panic!("{}: unknown outcome {other}", case.case_id),
}
match case.expectation_scope {
"invariant" => assert_eq!(
case.capability_id, "-",
"{}: an invariant case uses capability_id '-'",
case.case_id
),
"capability_relative" => assert_ne!(
case.capability_id, "-",
"{}: a capability-relative case names one capability",
case.case_id
),
other => panic!("{}: unknown expectation scope {other}", case.case_id),
}
if case.construction == "mutation" {
assert_ne!(
case.parent_case_id, "-",
"{}: a mutation names its parent",
case.case_id
);
} else {
assert_eq!(
case.parent_case_id, "-",
"{}: only a mutation names a parent",
case.case_id
);
}
if case.case_type == "fcr_decrypt" {
assert!(
matches!(case.payload_transcript_kind, "origins" | "none"),
"{}: an .fcr case uses origins or none",
case.case_id
);
} else {
assert_eq!(
case.payload_transcript_kind, "not_applicable",
"{}: a non-.fcr case uses not_applicable",
case.case_id
);
}
if case.payload_transcript_kind == "origins" {
assert!(
!case.payload_origin_ids.is_empty(),
"{}: origins list is nonempty",
case.case_id
);
let ids: Vec<&str> = case.payload_origin_ids.split(',').collect();
assert!(
ids.iter().all(|id| !id.is_empty()),
"{}: origins list has no empty entry",
case.case_id
);
let unique: std::collections::BTreeSet<&&str> = ids.iter().collect();
assert_eq!(
unique.len(),
ids.len(),
"{}: origins are duplicate-free",
case.case_id
);
for id in ids {
assert!(
corpus.origins.iter().any(|o| o.origin_id == id),
"{}: origin {id} is not declared",
case.case_id
);
}
} else {
assert_eq!(
case.payload_origin_ids, "-",
"{}: only an origins case lists origins",
case.case_id
);
}
if case.credential_id != "-" {
assert!(
corpus
.credentials
.iter()
.any(|c| c.credential_id == case.credential_id),
"{}: credential {} is not declared",
case.case_id,
case.credential_id
);
}
}
for origin in &corpus.origins {
assert_eq!(
origin.stream_nonce_hex.len(),
STREAM_NONCE_SIZE * 2,
"{}: stream_nonce_hex is a 19-byte prefix",
origin.origin_id
);
assert!(
corpus
.cases
.iter()
.any(|c| c.case_id == origin.anchor_case_id),
"{}: anchor case {} is not declared",
origin.origin_id,
origin.anchor_case_id
);
}
let nonces: std::collections::BTreeSet<&String> =
corpus.origins.iter().map(|o| &o.stream_nonce_hex).collect();
assert_eq!(
nonces.len(),
corpus.origins.len(),
"independent origins must use distinct nonce prefixes"
);
let declared: BTreeSet<&str> = std::iter::once(DEFAULT_LIMIT_PROFILE_ID)
.chain(derived_limit_profiles().into_iter().map(|(id, _)| id))
.collect();
let named: BTreeSet<&str> = corpus
.cases
.iter()
.map(|case| case.limit_profile_id)
.filter(|id| *id != "-")
.collect();
assert_eq!(
named, declared,
"the named and the declared limit profiles differ"
);
}
struct MutationBase {
case_id: String,
origin_id: String,
bytes: Vec<u8>,
}
fn mutate_fcr(
corpus: &mut Corpus,
base: &MutationBase,
case_id: &str,
credential_id: &str,
condition_id: &str,
diagnostic_class: &str,
mutate: impl FnOnce(&mut Vec<u8>),
) {
let row = mutated_fcr_row(corpus, base, case_id, credential_id, mutate);
corpus.push_case(row.reject(condition_id, diagnostic_class));
}
fn mutated_fcr_row(
corpus: &mut Corpus,
base: &MutationBase,
case_id: &str,
credential_id: &str,
mutate: impl FnOnce(&mut Vec<u8>),
) -> CaseRow {
let mut bytes = base.bytes.clone();
mutate(&mut bytes);
assert_ne!(bytes, base.bytes, "{case_id}: mutation changed nothing");
let artifact_ref = corpus.write_ref(&format!("artifacts/fcr/{case_id}.fcr"), &bytes);
CaseRow::fcr(case_id, &artifact_ref)
.origin(&base.origin_id)
.mutation_of(&base.case_id)
.credential(credential_id)
}
fn fabricate_fcr(
corpus: &mut Corpus,
case_id: &str,
credential_id: &str,
condition_id: &str,
diagnostic_class: &str,
bytes: &[u8],
) {
let artifact_ref = corpus.write_ref(&format!("artifacts/fcr/{case_id}.fcr"), bytes);
corpus.push_case(
CaseRow::fcr(case_id, &artifact_ref)
.fabricated()
.credential(credential_id)
.reject(condition_id, diagnostic_class),
);
}
fn be_u16_at(bytes: &[u8], offset: usize) -> u16 {
read_u16_be(bytes, offset).expect("the bytes hold the whole field")
}
fn be_u32_at(bytes: &[u8], offset: usize) -> u32 {
read_u32_be(bytes, offset).expect("the bytes hold the whole field")
}
fn payload_offset(bytes: &[u8]) -> usize {
let header_len = be_u32_at(bytes, OFF_HEADER_LEN) as usize;
crate::format::PREFIX_SIZE + header_len + crate::format::HEADER_MAC_SIZE
}
fn modify_header_mac(bytes: &mut [u8]) {
let mac = payload_offset(bytes) - crate::format::HEADER_MAC_SIZE;
bytes[mac] ^= 0x01;
}
fn pad_recipient_region(b: &mut Vec<u8>, extra: usize) {
let entries_end = OFF_FIRST_ENTRY + be_u32_at(b, OFF_RECIPIENT_ENTRIES_LEN) as usize;
b.splice(entries_end..entries_end, std::iter::repeat_n(0u8, extra));
let delta = i32::try_from(extra).expect("the padding fits a length field");
shift_length_fields(b, ®ION_LENGTH_FIELDS, delta);
}
fn with_recipient_region_padding(fcr: &[u8], file_key: &FileKey, extra: usize) -> Vec<u8> {
use crate::format::{HEADER_MAC_SIZE, PREFIX_SIZE};
let mut out = fcr.to_vec();
pad_recipient_region(&mut out, extra);
let mac_at = payload_offset(&out) - HEADER_MAC_SIZE;
let stream_nonce: [u8; STREAM_NONCE_SIZE] = out
[OFF_STREAM_NONCE..OFF_STREAM_NONCE + STREAM_NONCE_SIZE]
.try_into()
.expect("stream nonce");
let DerivedSubkeys { header_key, .. } =
derive_subkeys(file_key, &stream_nonce).expect("derive subkeys");
let prefix: [u8; PREFIX_SIZE] = out[..PREFIX_SIZE].try_into().expect("prefix");
let mac = crate::format::compute_header_mac(&prefix, &out[PREFIX_SIZE..mac_at], &header_key)
.expect("header MAC");
out[mac_at..mac_at + HEADER_MAC_SIZE].copy_from_slice(&mac);
out
}
#[test]
fn a_padded_recipient_region_keeps_its_header_authentic() {
use crate::format::{HEADER_MAC_SIZE, PREFIX_SIZE};
let sources = tempfile::tempdir().expect("source dir");
let source = write_source(sources.path(), SOURCE_FILE_NAME, 64);
let file_key = FileKey::generate().expect("file key");
let built = build_fcr_with_entries(&source, &file_key, &[argon2id_entry(&file_key)], b"");
let padded = with_recipient_region_padding(&built.bytes, &file_key, 1);
assert_eq!(padded.len(), built.bytes.len() + 1);
let header_end = payload_offset(&padded) - HEADER_MAC_SIZE;
let prefix: [u8; PREFIX_SIZE] = padded[..PREFIX_SIZE].try_into().expect("prefix");
let tag: [u8; HEADER_MAC_SIZE] = padded[header_end..header_end + HEADER_MAC_SIZE]
.try_into()
.expect("tag");
let stream_nonce: [u8; STREAM_NONCE_SIZE] = padded
[OFF_STREAM_NONCE..OFF_STREAM_NONCE + STREAM_NONCE_SIZE]
.try_into()
.expect("stream nonce");
let DerivedSubkeys { header_key, .. } =
derive_subkeys(&file_key, &stream_nonce).expect("derive subkeys");
crate::format::verify_header_mac(&prefix, &padded[PREFIX_SIZE..header_end], &header_key, &tag)
.expect("the padded header must still authenticate");
assert_eq!(
padded[payload_offset(&padded)..],
built.bytes[payload_offset(&built.bytes)..],
"the payload must be the one the file was built with"
);
}
fn write_prefix_cases(corpus: &mut Corpus, base: &MutationBase) {
use crate::format::{HEADER_LEN_MAX, HEADER_MAC_SIZE, PREFIX_SIZE};
mutate_fcr(
corpus,
base,
"prefix-bad-magic",
"passphrase-main",
"prefix_magic_mismatch",
"bad_magic",
|b| break_magic(b),
);
mutate_fcr(
corpus,
base,
"prefix-version-zero",
"passphrase-main",
"outer_version_reserved_zero",
"malformed_header",
|b| b[OFF_OUTER_VERSION] = RESERVED_VERSION,
);
mutate_fcr(
corpus,
base,
"prefix-wrong-kind",
"passphrase-main",
"prefix_kind_not_encrypted",
"wrong_kind",
|b| b[OFF_PREFIX_KIND] = crate::format::KIND_PRIVATE_KEY,
);
mutate_fcr(
corpus,
base,
"prefix-nonzero-flags",
"passphrase-main",
"prefix_flags_nonzero",
"malformed_header",
|b| write_u16_be(b, OFF_PREFIX_FLAGS, RESERVED_FLAG_BIT << u8::BITS),
);
mutate_fcr(
corpus,
base,
"prefix-header-len-over-structural-max",
"passphrase-main",
"prefix_header_len_above_structural_maximum",
"oversized_header",
|b| write_u32_be(b, OFF_HEADER_LEN, HEADER_LEN_MAX + 1),
);
mutate_fcr(
corpus,
base,
"prefix-header-len-over-default-cap",
"passphrase-main",
"header_len_above_default_cap",
"resource_cap_exceeded",
|b| {
write_u32_be(
b,
OFF_HEADER_LEN,
crate::HeaderReadLimits::HEADER_LEN_DEFAULT + 1,
)
},
);
let row = mutated_fcr_row(
corpus,
base,
"prefix-newer-outer-version",
"passphrase-main",
|b| b[OFF_OUTER_VERSION] = NEWER_VERSION,
);
corpus.push_case(
row.capability(&newer_version_capability(
wire_manifest::OUTER_VERSION_DOMAIN,
))
.reject("outer_version_unsupported", "unsupported_outer_version"),
);
let header_end = PREFIX_SIZE + be_u32_at(&base.bytes, OFF_HEADER_LEN) as usize;
for (case_id, keep) in [
("framing-truncated-in-prefix", PREFIX_SIZE - 1),
("framing-truncated-in-header", header_end - 1),
(
"framing-truncated-in-header-mac",
header_end + HEADER_MAC_SIZE - 1,
),
] {
fabricate_fcr(
corpus,
case_id,
"passphrase-main",
"framing_ends_before_declared_region",
"truncated",
&base.bytes[..keep],
);
}
let mut zero_header = base.bytes[..PREFIX_SIZE].to_vec();
write_u32_be(&mut zero_header, OFF_HEADER_LEN, 0);
fabricate_fcr(
corpus,
"framing-header-len-zero-no-mac",
"passphrase-main",
"header_len_zero_without_mac_bytes",
"truncated",
&zero_header,
);
let mut zero_header_with_mac = zero_header.clone();
zero_header_with_mac.extend_from_slice(&[0u8; HEADER_MAC_SIZE]);
fabricate_fcr(
corpus,
"framing-header-len-zero-with-mac",
"passphrase-main",
"header_len_zero_with_complete_mac_bytes",
"malformed_header",
&zero_header_with_mac,
);
write_prefix_check_order_cases(corpus, base);
}
fn write_prefix_check_order_cases(corpus: &mut Corpus, base: &MutationBase) {
use crate::format::{HEADER_LEN_MAX, KIND_PRIVATE_KEY, PREFIX_SIZE};
let mut short_with_bad_magic = base.bytes[..PREFIX_SIZE - 1].to_vec();
break_magic(&mut short_with_bad_magic);
fabricate_fcr(
corpus,
"prefix-order-length-before-magic",
"passphrase-main",
"input_shorter_than_prefix_and_magic_mismatch",
"truncated",
&short_with_bad_magic,
);
let cases: [ByteMutationCase; 6] = [
(
"prefix-order-magic-before-kind",
Box::new(|b: &mut Vec<u8>| {
break_magic(b);
b[OFF_PREFIX_KIND] = KIND_PRIVATE_KEY;
}),
"prefix_magic_mismatch_and_kind_not_encrypted",
"bad_magic",
),
(
"prefix-order-kind-before-version-zero",
Box::new(|b: &mut Vec<u8>| {
b[OFF_PREFIX_KIND] = KIND_PRIVATE_KEY;
b[OFF_OUTER_VERSION] = RESERVED_VERSION;
}),
"prefix_kind_not_encrypted_and_outer_version_reserved_zero",
"wrong_kind",
),
(
"prefix-order-kind-before-newer-version",
Box::new(|b: &mut Vec<u8>| {
b[OFF_PREFIX_KIND] = KIND_PRIVATE_KEY;
b[OFF_OUTER_VERSION] = NEWER_VERSION;
}),
"prefix_kind_not_encrypted_with_newer_outer_version",
"wrong_kind",
),
(
"prefix-order-kind-before-flags",
Box::new(|b: &mut Vec<u8>| {
b[OFF_PREFIX_KIND] = KIND_PRIVATE_KEY;
write_u16_be(b, OFF_PREFIX_FLAGS, RESERVED_FLAG_BIT);
}),
"prefix_kind_not_encrypted_and_prefix_flags_nonzero",
"wrong_kind",
),
(
"prefix-order-version-zero-before-header-len",
Box::new(|b: &mut Vec<u8>| {
b[OFF_OUTER_VERSION] = RESERVED_VERSION;
write_u32_be(b, OFF_HEADER_LEN, HEADER_LEN_MAX + 1);
}),
"outer_version_reserved_zero_and_header_len_above_structural_maximum",
"malformed_header",
),
(
"prefix-order-flags-before-header-len",
Box::new(|b: &mut Vec<u8>| {
write_u16_be(b, OFF_PREFIX_FLAGS, RESERVED_FLAG_BIT);
write_u32_be(b, OFF_HEADER_LEN, HEADER_LEN_MAX + 1);
}),
"prefix_flags_nonzero_and_header_len_above_structural_maximum",
"malformed_header",
),
];
for (case_id, mutate, condition_id, class) in cases {
mutate_fcr(
corpus,
base,
case_id,
"passphrase-main",
condition_id,
class,
mutate,
);
}
let row = mutated_fcr_row(
corpus,
base,
"prefix-order-newer-version-before-flags",
"passphrase-main",
|b| {
b[OFF_OUTER_VERSION] = NEWER_VERSION;
write_u16_be(b, OFF_PREFIX_FLAGS, RESERVED_FLAG_BIT);
},
);
corpus.push_case(
row.capability(&newer_version_capability(
wire_manifest::OUTER_VERSION_DOMAIN,
))
.reject(
"outer_version_unsupported_and_prefix_flags_nonzero",
"unsupported_outer_version",
),
);
}
const OFF_OUTER_VERSION: usize = crate::format::MAGIC_SIZE;
const OFF_PREFIX_KIND: usize = OFF_OUTER_VERSION + 1;
const OFF_PREFIX_FLAGS: usize = OFF_PREFIX_KIND + 1;
const OFF_HEADER_LEN: usize = OFF_PREFIX_FLAGS + 2;
const _: () = assert!(OFF_HEADER_LEN + 4 == crate::format::PREFIX_SIZE);
const OFF_HEADER_FLAGS: usize = crate::format::PREFIX_SIZE;
const OFF_RECIPIENT_COUNT: usize = OFF_HEADER_FLAGS + 2;
const OFF_RECIPIENT_ENTRIES_LEN: usize = OFF_RECIPIENT_COUNT + 2;
const OFF_EXT_LEN: usize = OFF_RECIPIENT_ENTRIES_LEN + 4;
const OFF_STREAM_NONCE: usize = OFF_EXT_LEN + 4;
const OFF_FIRST_ENTRY: usize = crate::format::PREFIX_SIZE + crate::format::HEADER_FIXED_SIZE;
const OFF_FIRST_ENTRY_TYPE_NAME_LEN: usize =
OFF_FIRST_ENTRY + crate::recipient::entry::ENTRY_TYPE_NAME_LEN_OFFSET;
const OFF_FIRST_ENTRY_FLAGS: usize =
OFF_FIRST_ENTRY + crate::recipient::entry::ENTRY_RECIPIENT_FLAGS_OFFSET;
const OFF_FIRST_ENTRY_BODY_LEN: usize =
OFF_FIRST_ENTRY + crate::recipient::entry::ENTRY_BODY_LEN_OFFSET;
const OFF_FIRST_ENTRY_TYPE_NAME: usize =
OFF_FIRST_ENTRY + crate::recipient::entry::ENTRY_HEADER_SIZE;
fn assemble_fcr(built: &crate::container::BuiltEncryptedHeader, payload: &[u8]) -> Vec<u8> {
let mut bytes = built.prefix_bytes.to_vec();
bytes.extend_from_slice(&built.header_bytes);
bytes.extend_from_slice(&built.header_mac);
bytes.extend_from_slice(payload);
bytes
}
fn craft_header(
file_key: &FileKey,
entries: &[RecipientEntry],
ext_bytes: &[u8],
) -> (crate::container::BuiltEncryptedHeader, String, String) {
let stream_nonce = random_bytes::<STREAM_NONCE_SIZE>().expect("stream nonce");
let DerivedSubkeys {
payload_key,
header_key,
} = derive_subkeys(file_key, &stream_nonce).expect("derive subkeys");
let payload_key_digest = sha3_hex(payload_key.expose());
let built = crate::container::build_encrypted_header(
entries,
ext_bytes,
stream_nonce,
payload_key,
&header_key,
)
.expect("build header");
(built, hex(&stream_nonce), payload_key_digest)
}
fn write_header_cases(corpus: &mut Corpus, base: &MutationBase) {
mutate_fcr(
corpus,
base,
"header-flags-nonzero",
"passphrase-main",
"header_flags_nonzero",
"malformed_header",
|b| write_u16_be(b, OFF_HEADER_FLAGS, RESERVED_FLAG_BIT),
);
mutate_fcr(
corpus,
base,
"header-recipient-count-zero",
"passphrase-main",
"recipient_count_zero",
"recipient_count_out_of_range",
|b| write_u16_be(b, OFF_RECIPIENT_COUNT, 0),
);
mutate_fcr(
corpus,
base,
"header-recipient-count-above-max",
"passphrase-main",
"recipient_count_above_structural_maximum",
"recipient_count_out_of_range",
|b| {
write_u16_be(
b,
OFF_RECIPIENT_COUNT,
crate::format::RECIPIENT_COUNT_MAX + 1,
)
},
);
mutate_fcr(
corpus,
base,
"header-section-lengths-disagree",
"passphrase-main",
"header_section_lengths_do_not_sum_to_header_len",
"malformed_header",
|b| break_section_lengths(b),
);
mutate_fcr(
corpus,
base,
"header-ext-len-above-structural-max",
"passphrase-main",
"ext_len_above_structural_maximum",
"extension_region_too_large",
declare_ext_region_above_max,
);
mutate_fcr(
corpus,
base,
"header-mac-tamper-stream-nonce",
"passphrase-main",
"header_mac_covers_stream_nonce",
"header_authentication_failed",
|b| b[OFF_STREAM_NONCE] ^= 0x01,
);
mutate_fcr(
corpus,
base,
"header-mac-tamper-tag",
"passphrase-main",
"header_mac_tag_modified",
"header_authentication_failed",
|b| modify_header_mac(b),
);
write_header_check_order_cases(corpus, base);
}
fn declare_ext_region_above_max(b: &mut Vec<u8>) {
let entries = be_u32_at(b, OFF_RECIPIENT_ENTRIES_LEN);
let ext = crate::format::EXT_LEN_MAX + 1;
write_u32_be(b, OFF_EXT_LEN, ext);
let header_len = crate::format::HEADER_FIXED_SIZE as u32 + entries + ext;
write_u32_be(b, OFF_HEADER_LEN, header_len);
let insert_at = OFF_FIRST_ENTRY + entries as usize;
b.splice(insert_at..insert_at, std::iter::repeat_n(0u8, ext as usize));
}
const FIRST_BODY_LENGTH_FIELDS: [usize; 3] = [
OFF_FIRST_ENTRY_BODY_LEN,
OFF_RECIPIENT_ENTRIES_LEN,
OFF_HEADER_LEN,
];
const REGION_LENGTH_FIELDS: [usize; 2] = [OFF_RECIPIENT_ENTRIES_LEN, OFF_HEADER_LEN];
fn shift_length_fields(b: &mut [u8], fields: &[usize], delta: i32) {
for &field in fields {
let declared = be_u32_at(b, field);
let shifted = declared
.checked_add_signed(delta)
.expect("the length stays within its field");
write_u32_be(b, field, shifted);
}
}
fn remove_first_entry_body_byte(b: &mut Vec<u8>, at: usize) {
shift_length_fields(b, &FIRST_BODY_LENGTH_FIELDS, -1);
b.remove(at);
}
fn first_entry_end(b: &[u8]) -> usize {
entry_offset(b, 1)
}
fn entry_offset(b: &[u8], index: usize) -> usize {
use crate::recipient::entry::{
ENTRY_BODY_LEN_OFFSET, ENTRY_HEADER_SIZE, ENTRY_TYPE_NAME_LEN_OFFSET,
};
(0..index).fold(OFF_FIRST_ENTRY, |offset, _| {
offset
+ ENTRY_HEADER_SIZE
+ usize::from(be_u16_at(b, offset + ENTRY_TYPE_NAME_LEN_OFFSET))
+ be_u32_at(b, offset + ENTRY_BODY_LEN_OFFSET) as usize
})
}
fn grow_first_entry_body(b: &mut Vec<u8>, extra: usize) {
let body_end = first_entry_end(b);
b.splice(body_end..body_end, std::iter::repeat_n(FILLER, extra));
let delta = i32::try_from(extra).expect("the growth fits a length field");
shift_length_fields(b, &FIRST_BODY_LENGTH_FIELDS, delta);
}
fn break_section_lengths(b: &mut [u8]) {
let entries = be_u32_at(b, OFF_RECIPIENT_ENTRIES_LEN);
write_u32_be(b, OFF_RECIPIENT_ENTRIES_LEN, entries + 1);
}
fn write_header_check_order_cases(corpus: &mut Corpus, base: &MutationBase) {
use crate::format::{HEADER_MAC_SIZE, PREFIX_SIZE};
let over_count_cap = crate::HeaderReadLimits::RECIPIENT_COUNT_DEFAULT + 1;
mutate_fcr(
corpus,
base,
"header-order-flags-before-count",
"passphrase-main",
"header_flags_nonzero_and_recipient_count_zero",
"malformed_header",
|b| {
write_u16_be(b, OFF_HEADER_FLAGS, RESERVED_FLAG_BIT);
write_u16_be(b, OFF_RECIPIENT_COUNT, 0);
},
);
mutate_fcr(
corpus,
base,
"header-order-count-before-ext-len",
"passphrase-main",
"recipient_count_zero_and_ext_len_above_structural_maximum",
"recipient_count_out_of_range",
|b| {
write_u16_be(b, OFF_RECIPIENT_COUNT, 0);
declare_ext_region_above_max(b);
},
);
mutate_fcr(
corpus,
base,
"header-order-ext-len-before-section-lengths",
"passphrase-main",
"ext_len_above_structural_maximum_and_section_lengths_disagree",
"extension_region_too_large",
|b| write_u32_be(b, OFF_EXT_LEN, crate::format::EXT_LEN_MAX + 1),
);
mutate_fcr(
corpus,
base,
"header-order-section-lengths-before-count-cap",
"passphrase-main",
"section_lengths_disagree_and_recipient_count_above_default_cap",
"malformed_header",
|b| {
break_section_lengths(b);
write_u16_be(b, OFF_RECIPIENT_COUNT, over_count_cap);
},
);
mutate_fcr(
corpus,
base,
"header-order-count-cap-before-entries",
"passphrase-main",
"recipient_count_above_default_cap_and_entry_type_name_len_zero",
"resource_cap_exceeded",
|b| {
write_u16_be(b, OFF_RECIPIENT_COUNT, over_count_cap);
write_u16_be(b, OFF_FIRST_ENTRY_TYPE_NAME_LEN, 0);
},
);
mutate_fcr(
corpus,
base,
"header-order-count-cap-before-entry-header",
"passphrase-main",
"recipient_count_above_default_cap_and_entry_header_shorter_than_eight_bytes",
"resource_cap_exceeded",
|b| {
use crate::recipient::entry::ENTRY_HEADER_SIZE;
write_u16_be(b, OFF_RECIPIENT_COUNT, over_count_cap);
let region_len = be_u32_at(b, OFF_RECIPIENT_ENTRIES_LEN) as usize;
let kept = ENTRY_HEADER_SIZE - 1;
b.drain(OFF_FIRST_ENTRY + kept..OFF_FIRST_ENTRY + region_len);
let removed = i32::try_from(region_len - kept).expect("the region fits a length field");
shift_length_fields(b, ®ION_LENGTH_FIELDS, -removed);
},
);
let mut flags_then_cut = base.bytes.clone();
write_u16_be(&mut flags_then_cut, OFF_HEADER_FLAGS, RESERVED_FLAG_BIT);
flags_then_cut.truncate(payload_offset(&flags_then_cut) - 1);
fabricate_fcr(
corpus,
"header-order-truncation-before-flags",
"passphrase-main",
"header_mac_ends_early_and_header_flags_nonzero",
"truncated",
&flags_then_cut,
);
let mut undersized = base.bytes[..OFF_RECIPIENT_ENTRIES_LEN].to_vec();
let undersized_len =
u32::try_from(OFF_RECIPIENT_ENTRIES_LEN - PREFIX_SIZE).expect("the length fits its field");
write_u32_be(&mut undersized, OFF_HEADER_LEN, undersized_len);
write_u16_be(&mut undersized, OFF_RECIPIENT_COUNT, 0);
undersized.extend_from_slice(&[0u8; HEADER_MAC_SIZE]);
fabricate_fcr(
corpus,
"header-order-undersized-before-count",
"passphrase-main",
"header_len_below_fixed_size_and_recipient_count_zero",
"malformed_header",
&undersized,
);
}
fn write_header_maximum_cases(corpus: &mut Corpus, sources: &Path, keys: &CorpusKeys) {
use crate::format::{EXT_LEN_MAX, HEADER_FIXED_SIZE, HEADER_LEN_MAX, RECIPIENT_COUNT_MAX};
let source = write_source(sources, SOURCE_FILE_NAME, 32);
let scope = case_scope("prefix-header-len-at-structural-max");
let file_key = FileKey::generate().expect("file key");
let opener = x25519_entry(&keys.public_a, &file_key);
let mut filler = unknown_entry(false);
filler.body.clear();
let framing = HEADER_FIXED_SIZE
+ opener.checked_wire_len().expect("x25519 entry length")
+ filler.checked_wire_len().expect("unknown entry length");
filler.body = vec![FILLER; HEADER_LEN_MAX as usize - framing];
let built = build_fcr_with_entries(&source, &file_key, &[filler, opener], b"");
drop(scope);
assert_eq!(
be_u32_at(&built.bytes, OFF_HEADER_LEN),
HEADER_LEN_MAX,
"the header must land exactly on its maximum"
);
under_limit_profile(corpus, HEADER_MAXIMA_LIMIT_PROFILE_ID, |corpus| {
fabricated_accept_fcr_case(
corpus,
"prefix-header-len-at-structural-max",
"private-key-a",
&source,
built,
);
});
let scope = case_scope("header-recipient-count-at-max");
let file_key = FileKey::generate().expect("file key");
let mut entries: Vec<RecipientEntry> = (1..RECIPIENT_COUNT_MAX)
.map(|_| unknown_entry(false))
.collect();
entries.push(x25519_entry(&keys.public_a, &file_key));
assert_eq!(entries.len(), usize::from(RECIPIENT_COUNT_MAX));
let built = build_fcr_with_entries(&source, &file_key, &entries, b"");
drop(scope);
under_limit_profile(corpus, HEADER_MAXIMA_LIMIT_PROFILE_ID, |corpus| {
fabricated_accept_fcr_case(
corpus,
"header-recipient-count-at-max",
"private-key-a",
&source,
built,
);
});
let scope = case_scope("header-ext-len-at-structural-max");
let file_key = FileKey::generate().expect("file key");
let ext = ignorable_tlv_region(EXT_LEN_MAX as usize, FILLER);
let built = build_fcr_with_entries(&source, &file_key, &[argon2id_entry(&file_key)], &ext);
drop(scope);
fabricated_accept_fcr_case(
corpus,
"header-ext-len-at-structural-max",
"passphrase-main",
&source,
built,
);
}
#[allow(clippy::too_many_arguments)]
fn crafted_reject_case(
corpus: &mut Corpus,
sources: &Path,
case_id: &str,
credential_id: &str,
capability_id: Option<&str>,
condition_id: &str,
diagnostic_class: &str,
file_key: &FileKey,
entries: &[RecipientEntry],
ext_bytes: &[u8],
) -> MutationBase {
let source = write_source(sources, SOURCE_FILE_NAME, 64);
let built = build_fcr_with_entries(&source, file_key, entries, ext_bytes);
commit_crafted_reject(
corpus,
case_id,
credential_id,
capability_id,
condition_id,
diagnostic_class,
built,
)
}
#[allow(clippy::too_many_arguments)]
fn seeded_crafted_reject_case(
corpus: &mut Corpus,
sources: &Path,
case_id: &str,
credential_id: &str,
capability_id: Option<&str>,
(condition_id, class): (&str, &str),
entries: &dyn Fn(&FileKey) -> Vec<RecipientEntry>,
ext: &dyn Fn(&[RecipientEntry]) -> Vec<u8>,
) -> MutationBase {
let _scope = case_scope(case_id);
let file_key = FileKey::generate().expect("file key");
let entries = entries(&file_key);
crafted_reject_case(
corpus,
sources,
case_id,
credential_id,
capability_id,
condition_id,
class,
&file_key,
&entries,
&ext(&entries),
)
}
fn no_extension(_entries: &[RecipientEntry]) -> Vec<u8> {
Vec::new()
}
fn commit_crafted_reject(
corpus: &mut Corpus,
case_id: &str,
credential_id: &str,
capability_id: Option<&str>,
condition_id: &str,
diagnostic_class: &str,
built: BuiltFcr,
) -> MutationBase {
let artifact_ref = corpus.write_ref(&format!("artifacts/fcr/{case_id}.fcr"), &built.bytes);
let origin_id = format!("origin-{case_id}");
corpus.push_origin(OriginRow {
origin_id: origin_id.clone(),
origin_kind: "fcr_payload",
anchor_case_id: case_id.to_string(),
payload_key_ref: "-".to_string(),
payload_key_sha3_256: built.payload_key_sha3_256,
stream_nonce_hex: built.stream_nonce_hex,
});
corpus.push_case(
CaseRow::fcr(case_id, &artifact_ref)
.origin(&origin_id)
.fabricated()
.credential(credential_id)
.capability_if_any(capability_id)
.reject(condition_id, diagnostic_class),
);
MutationBase {
case_id: case_id.to_string(),
origin_id,
bytes: built.bytes,
}
}
fn build_fcr_with_entries(
source: &Path,
file_key: &FileKey,
entries: &[RecipientEntry],
ext_bytes: &[u8],
) -> BuiltFcr {
build_fcr(source, file_key, entries, ext_bytes).expect("build crafted fcr")
}
fn shorten_body(entry: &mut RecipientEntry) {
entry.body.pop();
}
fn damaged_x25519(
keys: &CorpusKeys,
file_key: &FileKey,
damage: fn(&mut RecipientEntry),
) -> RecipientEntry {
let mut entry = x25519_entry(&keys.public_a, file_key);
damage(&mut entry);
entry
}
fn zero_x25519_ephemeral(entry: &mut RecipientEntry) {
let at = x25519::EPHEMERAL_PUBLIC_KEY_OFFSET;
entry.body[at..at + x25519::PUBLIC_KEY_SIZE].fill(0);
}
fn set_critical_flag(entry: &mut RecipientEntry) {
entry.recipient_flags = crate::recipient::entry::RECIPIENT_FLAG_CRITICAL;
}
fn unknown_entry(critical: bool) -> RecipientEntry {
RecipientEntry {
type_name: UNKNOWN_RECIPIENT_TYPE.to_string(),
recipient_flags: if critical {
crate::recipient::entry::RECIPIENT_FLAG_CRITICAL
} else {
0
},
body: vec![FILLER; 64],
}
}
fn write_recipient_framing_cases(
corpus: &mut Corpus,
sources: &Path,
keys: &CorpusKeys,
base: &MutationBase,
) {
mutate_fcr(
corpus,
base,
"entry-type-name-len-zero",
"passphrase-main",
"entry_type_name_len_zero",
"malformed_recipient_entry",
|b| write_u16_be(b, OFF_FIRST_ENTRY_TYPE_NAME_LEN, 0),
);
mutate_fcr(
corpus,
base,
"entry-body-len-past-region",
"passphrase-main",
"entry_body_len_runs_past_recipient_region",
"malformed_recipient_entry",
|b| write_u32_be(b, OFF_FIRST_ENTRY_BODY_LEN, 0xFFFF),
);
let scope = case_scope("entry-region-trailing-bytes");
let source = write_source(sources, SOURCE_FILE_NAME, 64);
let file_key = FileKey::generate().expect("file key");
let built = build_fcr_with_entries(&source, &file_key, &[argon2id_entry(&file_key)], b"");
let padded = with_recipient_region_padding(&built.bytes, &file_key, 1);
drop(scope);
commit_crafted_reject(
corpus,
"entry-region-trailing-bytes",
"passphrase-main",
None,
"recipient_entries_do_not_fill_region",
"malformed_recipient_entry",
BuiltFcr {
bytes: padded,
..built
},
);
mutate_fcr(
corpus,
base,
"entry-reserved-flag-set",
"passphrase-main",
"entry_reserved_flag_bit_nonzero",
"recipient_flags_reserved",
|b| write_u16_be(b, OFF_FIRST_ENTRY_FLAGS, RESERVED_RECIPIENT_FLAG_BIT),
);
mutate_fcr(
corpus,
base,
"entry-type-name-malformed",
"passphrase-main",
"entry_type_name_violates_grammar",
"malformed_type_name",
|b| b[OFF_FIRST_ENTRY_TYPE_NAME] = UNGRAMMATICAL_TYPE_NAME_BYTE,
);
let scope = case_scope("recipient-none-supported");
crafted_reject_case(
corpus,
sources,
"recipient-none-supported",
"passphrase-main",
None,
"no_recipient_of_a_supported_type",
"no_supported_recipient",
&FileKey::generate().expect("file key"),
&[unknown_entry(false)],
b"",
);
drop(scope);
let source = write_source(sources, SOURCE_FILE_NAME, 64);
let shortest = RecipientEntry {
type_name: SHORTEST_TYPE_NAME.to_string(),
recipient_flags: crate::recipient::entry::RECIPIENT_FLAG_CRITICAL,
body: Vec::new(),
};
for (case_id, critical, condition_id) in [
(
"recipient-unknown-critical",
unknown_entry(true),
"critical_recipient_type_unsupported",
),
(
"recipient-unknown-critical-lengths-at-min",
shortest,
"critical_recipient_type_unsupported_with_type_name_len_and_body_len_at_structural_minima",
),
] {
let _scope = case_scope(case_id);
let file_key = FileKey::generate().expect("file key");
let capability = recipient_type_capability(&critical.type_name);
let entries = [critical, x25519_entry(&keys.public_a, &file_key)];
let built = build_fcr_with_entries(&source, &file_key, &entries, b"");
commit_crafted_reject(
corpus,
case_id,
"private-key-a",
capability.as_deref(),
condition_id,
"unknown_critical_recipient",
built,
);
}
let scope = case_scope("recipient-unknown-ignorable-skipped");
let file_key = FileKey::generate().expect("file key");
let entries = [
unknown_entry(false),
x25519_entry(&keys.public_a, &file_key),
];
let built = build_fcr_with_entries(&source, &file_key, &entries, b"");
accept_fcr_case(
corpus,
"recipient-unknown-ignorable-skipped",
"origin-recipient-unknown-ignorable",
"private-key-a",
&source,
built,
);
drop(scope);
let scope = case_scope("recipient-type-name-at-max");
let file_key = FileKey::generate().expect("file key");
let mut longest = unknown_entry(false);
longest.type_name = type_name_on_maximum(wire_manifest::TEST_TYPE_NAME_PREFIX, "u");
let entries = [longest, x25519_entry(&keys.public_a, &file_key)];
let built = build_fcr_with_entries(&source, &file_key, &entries, b"");
accept_fcr_case(
corpus,
"recipient-type-name-at-max",
"origin-recipient-type-name-at-max",
"private-key-a",
&source,
built,
);
drop(scope);
let scope = case_scope("recipient-illegal-mixing");
let file_key = FileKey::generate().expect("file key");
let entries = [
argon2id_entry(&file_key),
x25519_entry(&keys.public_a, &file_key),
];
crafted_reject_case(
corpus,
sources,
"recipient-illegal-mixing",
"passphrase-main",
None,
"argon2id_mixed_with_another_recipient",
"incompatible_recipients",
&file_key,
&entries,
b"",
);
drop(scope);
for (case_id, condition_id, argon2id_first, damage_x25519, class) in [
(
"recipient-step8-length-before-content-argon2id-first",
"argon2id_kdf_invalid_and_x25519_body_short_argon2id_first",
true,
shorten_body as fn(&mut RecipientEntry),
"malformed_recipient_entry",
),
(
"recipient-step8-length-before-content-x25519-first",
"argon2id_kdf_invalid_and_x25519_body_short_x25519_first",
false,
shorten_body,
"malformed_recipient_entry",
),
(
"recipient-step8-order-argon2id-first",
"argon2id_kdf_invalid_and_x25519_ephemeral_zero_argon2id_first",
true,
zero_x25519_ephemeral,
"invalid_kdf_parameters",
),
(
"recipient-step8-order-x25519-first",
"argon2id_kdf_invalid_and_x25519_ephemeral_zero_x25519_first",
false,
zero_x25519_ephemeral,
"invalid_kdf_parameters",
),
(
"recipient-step8-flags-before-content-argon2id-first",
"argon2id_kdf_invalid_and_x25519_critical_flag_set_argon2id_first",
true,
set_critical_flag,
"malformed_recipient_entry",
),
(
"recipient-step8-flags-before-content-x25519-first",
"argon2id_kdf_invalid_and_x25519_critical_flag_set_x25519_first",
false,
set_critical_flag,
"malformed_recipient_entry",
),
] {
let _scope = case_scope(case_id);
let file_key = FileKey::generate().expect("file key");
let bad_argon2id = argon2id_entry_with_kdf_params(&file_key, &kdf_params_without_lanes());
let mut bad_x25519 = x25519_entry(&keys.public_a, &file_key);
damage_x25519(&mut bad_x25519);
let entries = if argon2id_first {
[bad_argon2id, bad_x25519]
} else {
[bad_x25519, bad_argon2id]
};
crafted_reject_case(
corpus,
sources,
case_id,
"passphrase-main",
None,
condition_id,
class,
&file_key,
&entries,
b"",
);
}
write_recipient_check_order_cases(corpus, sources, keys, base);
write_walk_order_cases(corpus, sources);
}
pub(crate) const WALK_ORDER_CASE_PREFIX: &str = "walk-order-";
fn write_walk_order_cases(corpus: &mut Corpus, sources: &Path) {
use crate::recipient::entry::{
ENTRY_BODY_LEN_OFFSET, ENTRY_HEADER_SIZE, ENTRY_RECIPIENT_FLAGS_OFFSET,
ENTRY_TYPE_NAME_LEN_OFFSET,
};
let first_case = corpus.cases.len();
const MIDDLE: usize = 2;
let condition = |defect: &str| {
format!("argon2id_mixed_and_header_mac_work_above_local_cap_before_{defect}")
};
let edit_middle = |edit: fn(&mut RecipientEntry)| {
Box::new(move |b: &mut Vec<u8>| edit_entry(b, MIDDLE, edit))
};
let mut mixed = None;
under_limit_profile(corpus, LOWERED_HEADER_MAC_WORK_LIMIT_PROFILE_ID, |corpus| {
let base = seeded_crafted_reject_case(
corpus,
sources,
"walk-order-mixed-entries",
"passphrase-main",
None,
(
"argon2id_mixed_with_other_entries_and_header_mac_work_above_local_cap",
"incompatible_recipients",
),
&|file_key| (0..=2 * MIDDLE).map(|_| argon2id_entry(file_key)).collect(),
&no_extension,
);
let header_len = be_u32_at(&base.bytes, OFF_HEADER_LEN) as usize;
let cap = lowered_header_mac_work_cap();
assert!(
header_mac_work(1, header_len) <= cap && header_mac_work(2, header_len) > cap,
"two entries cross the cap and one does not"
);
let case_id = "walk-order-unknown-critical-before-mixing-and-cap";
let row = mutated_fcr_row(corpus, &base, case_id, "passphrase-main", |b| {
edit_entry(b, MIDDLE, |entry| *entry = unknown_entry(true))
});
corpus.push_case(
row.capability_if_any(recipient_type_capability(UNKNOWN_RECIPIENT_TYPE).as_deref())
.reject(
&condition("critical_recipient_type_unsupported"),
"unknown_critical_recipient",
),
);
let middle = |b: &[u8]| entry_offset(b, MIDDLE);
let mutations: [ByteMutationCase; 10] = [
(
"walk-order-type-name-len-before-mixing-and-cap",
Box::new(move |b: &mut Vec<u8>| {
let at = middle(b);
let name_len = be_u16_at(b, at + ENTRY_TYPE_NAME_LEN_OFFSET);
write_u16_be(b, at + ENTRY_TYPE_NAME_LEN_OFFSET, 0);
let body_len = be_u32_at(b, at + ENTRY_BODY_LEN_OFFSET);
write_u32_be(
b,
at + ENTRY_BODY_LEN_OFFSET,
body_len + u32::from(name_len),
);
}),
"entry_type_name_len_zero",
"malformed_recipient_entry",
),
(
"walk-order-flags-before-mixing-and-cap",
Box::new(move |b: &mut Vec<u8>| {
let at = middle(b) + ENTRY_RECIPIENT_FLAGS_OFFSET;
write_u16_be(b, at, RESERVED_RECIPIENT_FLAG_BIT);
}),
"entry_reserved_flag_bit_nonzero",
"recipient_flags_reserved",
),
(
"walk-order-type-name-before-mixing-and-cap",
Box::new(move |b: &mut Vec<u8>| {
let at = middle(b) + ENTRY_HEADER_SIZE;
b[at] = UNGRAMMATICAL_TYPE_NAME_BYTE;
}),
"entry_type_name_violates_grammar",
"malformed_type_name",
),
(
"walk-order-body-len-before-mixing-and-cap",
Box::new(move |b: &mut Vec<u8>| {
let at = middle(b) + ENTRY_BODY_LEN_OFFSET;
write_u32_be(b, at, crate::format::BODY_LEN_MAX + 1);
}),
"entry_body_len_above_structural_maximum",
"malformed_recipient_entry",
),
(
"walk-order-trailing-bytes-before-mixing-and-cap",
Box::new(|b: &mut Vec<u8>| pad_recipient_region(b, 1)),
"bytes_left_after_last_entry",
"malformed_recipient_entry",
),
(
"walk-order-entry-header-before-mixing-and-cap",
Box::new(|b: &mut Vec<u8>| {
let declared = be_u16_at(b, OFF_RECIPIENT_COUNT);
let added = entry_offset(b, usize::from(declared));
pad_recipient_region(b, ENTRY_BODY_LEN_OFFSET + size_of::<u16>());
let name_len = u16::try_from(argon2id::TYPE_NAME.len()).expect("name length");
write_u16_be(b, added + ENTRY_TYPE_NAME_LEN_OFFSET, name_len);
write_u16_be(b, OFF_RECIPIENT_COUNT, declared + 1);
}),
"last_entry_header_shorter_than_eight_bytes",
"malformed_recipient_entry",
),
(
"walk-order-native-flags-before-mixing-and-cap",
edit_middle(set_critical_flag),
"argon2id_critical_flag_set",
"malformed_recipient_entry",
),
(
"walk-order-native-length-before-mixing-and-cap",
edit_middle(shorten_body),
"argon2id_body_short",
"malformed_recipient_entry",
),
(
"walk-order-kdf-params-before-mixing-and-cap",
edit_middle(|entry| {
let at = argon2id::KDF_PARAMS_OFFSET;
entry.body[at..at + KDF_PARAMS_SIZE]
.copy_from_slice(&kdf_params_without_lanes().to_bytes());
}),
"argon2id_lanes_below_structural_minimum",
"invalid_kdf_parameters",
),
(
"walk-order-x25519-ephemeral-before-mixing-and-cap",
edit_middle(|entry| {
*entry = RecipientEntry {
type_name: x25519::TYPE_NAME.to_string(),
recipient_flags: 0,
body: vec![FILLER; x25519::BODY_LENGTH],
};
zero_x25519_ephemeral(entry);
}),
"x25519_ephemeral_all_zero",
"malformed_recipient_entry",
),
];
for (case_id, mutate, defect, class) in mutations {
mutate_fcr(
corpus,
&base,
case_id,
"passphrase-main",
&condition(defect),
class,
mutate,
);
}
mixed = Some(base);
});
let mixed = mixed.expect("the base file is written");
mutate_fcr(
corpus,
&mixed,
"walk-order-body-cap-before-mixing",
"passphrase-main",
"argon2id_mixed_before_entry_body_len_above_default_cap",
"resource_cap_exceeded",
|b| {
edit_entry(b, MIDDLE, |entry| {
*entry = unknown_entry(false);
let cap = crate::HeaderReadLimits::RECIPIENT_BODY_LEN_DEFAULT as usize;
entry.body.resize(cap + 1, FILLER);
})
},
);
assert!(
corpus.cases[first_case..]
.iter()
.all(|case| case.case_id.starts_with(WALK_ORDER_CASE_PREFIX)),
"every walk-order case ID starts with {WALK_ORDER_CASE_PREFIX}"
);
}
fn edit_entry(b: &mut Vec<u8>, index: usize, edit: impl FnOnce(&mut RecipientEntry)) {
use crate::recipient::entry::{
ENTRY_HEADER_SIZE, ENTRY_RECIPIENT_FLAGS_OFFSET, ENTRY_TYPE_NAME_LEN_OFFSET,
};
let start = entry_offset(b, index);
let end = entry_offset(b, index + 1);
let name_start = start + ENTRY_HEADER_SIZE;
let name_end = name_start + usize::from(be_u16_at(b, start + ENTRY_TYPE_NAME_LEN_OFFSET));
let mut entry = RecipientEntry {
type_name: String::from_utf8(b[name_start..name_end].to_vec()).expect("a UTF-8 name"),
recipient_flags: be_u16_at(b, start + ENTRY_RECIPIENT_FLAGS_OFFSET),
body: b[name_end..end].to_vec(),
};
edit(&mut entry);
let edited = entry.to_bytes();
let length = |len: usize| i32::try_from(len).expect("an entry length fits a signed shift");
let delta = length(edited.len()) - length(end - start);
b.splice(start..end, edited);
shift_length_fields(b, ®ION_LENGTH_FIELDS, delta);
}
fn write_recipient_check_order_cases(
corpus: &mut Corpus,
sources: &Path,
keys: &CorpusKeys,
base: &MutationBase,
) {
use crate::recipient::entry::{
ENTRY_BODY_LEN_OFFSET, ENTRY_HEADER_SIZE, ENTRY_RECIPIENT_FLAGS_OFFSET,
ENTRY_TYPE_NAME_LEN_OFFSET,
};
let reserved_flag = |b: &mut Vec<u8>| {
write_u16_be(b, OFF_FIRST_ENTRY_FLAGS, RESERVED_RECIPIENT_FLAG_BIT);
};
let break_type_name = |b: &mut Vec<u8>| {
b[OFF_FIRST_ENTRY_TYPE_NAME] = UNGRAMMATICAL_TYPE_NAME_BYTE;
};
let declare_second_entry = |b: &mut Vec<u8>| write_u16_be(b, OFF_RECIPIENT_COUNT, 2);
let lanes_zero = |b: &mut Vec<u8>| {
b[OFF_FIRST_ARGON2ID_KDF_PARAMS..OFF_FIRST_ARGON2ID_KDF_PARAMS + KDF_PARAMS_SIZE]
.copy_from_slice(&kdf_params_without_lanes().to_bytes());
};
let cases: [ByteMutationCase; 9] = [
(
"entry-order-type-name-len-before-flags",
Box::new(move |b: &mut Vec<u8>| {
write_u16_be(b, OFF_FIRST_ENTRY_TYPE_NAME_LEN, 0);
reserved_flag(b);
let body_len = be_u32_at(b, OFF_FIRST_ENTRY_BODY_LEN);
let name_len = u32::try_from(argon2id::TYPE_NAME.len()).expect("name length");
write_u32_be(b, OFF_FIRST_ENTRY_BODY_LEN, body_len + name_len);
}),
"entry_type_name_len_zero_and_reserved_flag_bit_nonzero",
"malformed_recipient_entry",
),
(
"entry-order-body-len-before-flags",
Box::new(move |b: &mut Vec<u8>| {
write_u32_be(b, OFF_FIRST_ENTRY_BODY_LEN, crate::format::BODY_LEN_MAX + 1);
reserved_flag(b);
}),
"entry_body_len_above_structural_maximum_and_reserved_flag_bit_nonzero",
"malformed_recipient_entry",
),
(
"entry-order-flags-before-fit",
Box::new(move |b: &mut Vec<u8>| {
reserved_flag(b);
let body_len = be_u32_at(b, OFF_FIRST_ENTRY_BODY_LEN);
write_u32_be(b, OFF_FIRST_ENTRY_BODY_LEN, body_len + 1);
}),
"entry_reserved_flag_bit_nonzero_and_entry_runs_past_region",
"recipient_flags_reserved",
),
(
"entry-order-body-cap-before-type-name",
Box::new(move |b: &mut Vec<u8>| {
let cap = crate::HeaderReadLimits::RECIPIENT_BODY_LEN_DEFAULT as usize;
grow_first_entry_body(b, cap + 1 - argon2id::BODY_LENGTH);
break_type_name(b);
}),
"entry_body_len_above_default_cap_and_type_name_violates_grammar",
"resource_cap_exceeded",
),
(
"entry-order-type-name-before-trailing-bytes",
Box::new(move |b: &mut Vec<u8>| {
break_type_name(b);
pad_recipient_region(b, 1);
}),
"entry_type_name_violates_grammar_and_bytes_left_after_last_entry",
"malformed_type_name",
),
(
"entry-order-entry-header-before-flags",
Box::new(move |b: &mut Vec<u8>| {
let second = first_entry_end(b);
pad_recipient_region(b, ENTRY_BODY_LEN_OFFSET + size_of::<u16>());
let name_len = u16::try_from(argon2id::TYPE_NAME.len()).expect("name length");
write_u16_be(b, second + ENTRY_TYPE_NAME_LEN_OFFSET, name_len);
write_u16_be(
b,
second + ENTRY_RECIPIENT_FLAGS_OFFSET,
RESERVED_RECIPIENT_FLAG_BIT,
);
declare_second_entry(b);
}),
"second_entry_header_shorter_than_eight_bytes_and_reserved_flag_bit_nonzero",
"malformed_recipient_entry",
),
(
"entry-order-whole-entry-before-next-entry",
Box::new(move |b: &mut Vec<u8>| {
break_type_name(b);
declare_second_entry(b);
}),
"entry_type_name_violates_grammar_and_second_entry_header_missing",
"malformed_type_name",
),
(
"recipient-order-trailing-bytes-before-kdf-params",
Box::new(move |b: &mut Vec<u8>| {
lanes_zero(b);
pad_recipient_region(b, 1);
}),
"bytes_left_after_last_entry_and_argon2id_lanes_below_structural_minimum",
"malformed_recipient_entry",
),
(
"recipient-order-entry-header-before-kdf-params",
Box::new(move |b: &mut Vec<u8>| {
lanes_zero(b);
declare_second_entry(b);
}),
"second_entry_header_missing_and_argon2id_lanes_below_structural_minimum",
"malformed_recipient_entry",
),
];
for (case_id, mutate, condition_id, class) in cases {
mutate_fcr(
corpus,
base,
case_id,
"passphrase-main",
condition_id,
class,
mutate,
);
}
let unknown_type = recipient_type_capability(UNKNOWN_RECIPIENT_TYPE);
let break_second_type_name = |b: &mut Vec<u8>| {
let second_type_name = first_entry_end(b) + ENTRY_HEADER_SIZE;
b[second_type_name] = UNGRAMMATICAL_TYPE_NAME_BYTE;
};
let lone_critical = seeded_crafted_reject_case(
corpus,
sources,
"recipient-order-unknown-critical-before-none-supported",
"passphrase-main",
unknown_type.as_deref(),
(
"critical_recipient_type_unsupported_and_no_supported_recipient",
"unknown_critical_recipient",
),
&|_| vec![unknown_entry(true)],
&no_extension,
);
let lone_critical_cases: [ByteMutationCase; 2] = [
(
"recipient-order-trailing-bytes-before-unknown-critical",
Box::new(|b: &mut Vec<u8>| pad_recipient_region(b, 1)),
"bytes_left_after_last_entry_and_critical_recipient_type_unsupported",
"malformed_recipient_entry",
),
(
"recipient-order-entry-header-before-unknown-critical",
Box::new(declare_second_entry),
"second_entry_header_missing_and_critical_recipient_type_unsupported",
"malformed_recipient_entry",
),
];
for (case_id, mutate, condition_id, class) in lone_critical_cases {
mutate_fcr(
corpus,
&lone_critical,
case_id,
"passphrase-main",
condition_id,
class,
mutate,
);
}
mutate_fcr(
corpus,
&lone_critical,
"entry-order-fit-before-body-cap",
"passphrase-main",
"entry_runs_past_region_and_body_len_above_default_cap",
"malformed_recipient_entry",
|b| {
write_u32_be(
b,
OFF_FIRST_ENTRY_BODY_LEN,
crate::HeaderReadLimits::RECIPIENT_BODY_LEN_DEFAULT + 1,
)
},
);
let short_then_critical = seeded_crafted_reject_case(
corpus,
sources,
"recipient-order-unknown-critical-before-native-length-x25519-first",
"private-key-a",
unknown_type.as_deref(),
(
"critical_recipient_type_unsupported_and_x25519_body_short_x25519_first",
"unknown_critical_recipient",
),
&|file_key| {
vec![
damaged_x25519(keys, file_key, shorten_body),
unknown_entry(true),
]
},
&no_extension,
);
mutate_fcr(
corpus,
&short_then_critical,
"recipient-order-type-name-before-native-length",
"private-key-a",
"x25519_body_short_and_second_entry_type_name_violates_grammar",
"malformed_type_name",
break_second_type_name,
);
seeded_crafted_reject_case(
corpus,
sources,
"recipient-order-unknown-critical-before-native-length-unknown-first",
"private-key-a",
unknown_type.as_deref(),
(
"critical_recipient_type_unsupported_and_x25519_body_short_unknown_first",
"unknown_critical_recipient",
),
&|file_key| {
vec![
unknown_entry(true),
damaged_x25519(keys, file_key, shorten_body),
]
},
&no_extension,
);
let flagged_then_critical = seeded_crafted_reject_case(
corpus,
sources,
"recipient-order-unknown-critical-before-native-flags-x25519-first",
"private-key-a",
unknown_type.as_deref(),
(
"critical_recipient_type_unsupported_and_x25519_critical_flag_set_x25519_first",
"unknown_critical_recipient",
),
&|file_key| {
vec![
damaged_x25519(keys, file_key, set_critical_flag),
unknown_entry(true),
]
},
&no_extension,
);
mutate_fcr(
corpus,
&flagged_then_critical,
"recipient-order-type-name-before-native-flags",
"private-key-a",
"x25519_critical_flag_set_and_second_entry_type_name_violates_grammar",
"malformed_type_name",
break_second_type_name,
);
seeded_crafted_reject_case(
corpus,
sources,
"recipient-order-unknown-critical-before-native-flags-unknown-first",
"private-key-a",
unknown_type.as_deref(),
(
"critical_recipient_type_unsupported_and_x25519_critical_flag_set_unknown_first",
"unknown_critical_recipient",
),
&|file_key| {
vec![
unknown_entry(true),
damaged_x25519(keys, file_key, set_critical_flag),
]
},
&no_extension,
);
seeded_crafted_reject_case(
corpus,
sources,
"recipient-order-body-content-before-mixing",
"passphrase-main",
None,
(
"x25519_ephemeral_all_zero_and_argon2id_mixed_with_another_recipient",
"malformed_recipient_entry",
),
&|file_key| {
let zero_ephemeral = damaged_x25519(keys, file_key, zero_x25519_ephemeral);
vec![argon2id_entry(file_key), zero_ephemeral]
},
&no_extension,
);
}
const fn body_offset(type_name: &str) -> usize {
OFF_FIRST_ENTRY_TYPE_NAME + type_name.len()
}
const OFF_FIRST_ARGON2ID_KDF_PARAMS: usize = body_offset(argon2id::TYPE_NAME) + ARGON2_SALT_SIZE;
fn kdf_params_without_lanes() -> KdfParams {
KdfParams {
lanes: 0,
..KdfParams::test_fast_default()
}
}
fn recredential_case(
corpus: &mut Corpus,
base: &MutationBase,
case_id: &str,
credential_id: &str,
condition_id: &str,
diagnostic_class: &str,
) {
let artifact_ref = corpus.write_ref(&format!("artifacts/fcr/{case_id}.fcr"), &base.bytes);
corpus.push_case(
CaseRow::fcr(case_id, &artifact_ref)
.origin(&base.origin_id)
.mutation_of(&base.case_id)
.credential(credential_id)
.reject(condition_id, diagnostic_class),
);
}
fn write_argon2id_cases(corpus: &mut Corpus, sources: &Path, base: &MutationBase) {
let body = body_offset(argon2id::TYPE_NAME);
let salt = body;
let kdf = OFF_FIRST_ARGON2ID_KDF_PARAMS;
let nonce = kdf + KDF_PARAMS_SIZE;
let wrapped = nonce + WRAP_NONCE_SIZE;
mutate_fcr(
corpus,
base,
"argon2id-critical-flag-set",
"passphrase-main",
"argon2id_native_entry_flags_nonzero",
"malformed_recipient_entry",
|b| {
write_u16_be(
b,
OFF_FIRST_ENTRY_FLAGS,
crate::recipient::entry::RECIPIENT_FLAG_CRITICAL,
)
},
);
recredential_case(
corpus,
base,
"argon2id-wrong-passphrase",
"passphrase-wrong",
"argon2id_wrong_passphrase",
"recipient_unwrap_failed",
);
for (case_id, offset, condition) in [
("argon2id-tamper-salt", salt, "argon2id_salt_modified"),
(
"argon2id-tamper-wrap-nonce",
nonce,
"argon2id_wrap_nonce_modified",
),
(
"argon2id-tamper-wrapped-file-key",
wrapped,
"argon2id_wrapped_file_key_modified",
),
] {
mutate_fcr(
corpus,
base,
case_id,
"passphrase-main",
condition,
"recipient_unwrap_failed",
|b| b[offset] ^= 0x01,
);
}
mutate_fcr(
corpus,
base,
"argon2id-tamper-kdf-params",
"passphrase-main",
"argon2id_kdf_params_modified",
"recipient_unwrap_failed",
|b| b[kdf + 3] ^= 0x01,
);
mutate_fcr(
corpus,
base,
"argon2id-body-len-invalid",
"passphrase-main",
"argon2id_body_length_not_116",
"malformed_recipient_entry",
|b| remove_first_entry_body_byte(b, wrapped),
);
for (case_id, params, condition, class) in [
(
"argon2id-kdf-lanes-zero",
KdfParams {
mem_cost: 19_456,
time_cost: 1,
lanes: 0,
},
"argon2id_lanes_below_structural_minimum",
"invalid_kdf_parameters",
),
(
"argon2id-kdf-time-above-max",
KdfParams {
mem_cost: 19_456,
time_cost: KdfLimit::TIME_COST_STRUCTURAL_MAX + 1,
lanes: 1,
},
"argon2id_time_cost_above_structural_maximum",
"invalid_kdf_parameters",
),
(
"argon2id-kdf-memory-above-max",
KdfParams {
mem_cost: KdfLimit::MEM_COST_KIB_STRUCTURAL_MAX + 1,
time_cost: 1,
lanes: 1,
},
"argon2id_memory_cost_above_structural_maximum",
"invalid_kdf_parameters",
),
(
"argon2id-kdf-work-over-local-cap",
kdf_params_one_past_default_work_cap(),
"argon2id_work_above_default_local_cap",
"resource_cap_exceeded",
),
(
"argon2id-kdf-memory-over-local-cap",
kdf_params_one_past_default_memory_cap(),
"argon2id_memory_above_default_local_cap",
"resource_cap_exceeded",
),
] {
let _scope = case_scope(case_id);
let file_key = FileKey::generate().expect("file key");
let entry = argon2id_entry_with_kdf_params(&file_key, ¶ms);
crafted_reject_case(
corpus,
sources,
case_id,
"passphrase-main",
None,
condition,
class,
&file_key,
std::slice::from_ref(&entry),
b"",
);
}
}
const SMALL_ORDER_EPHEMERAL: [u8; x25519::PUBLIC_KEY_SIZE] = [
0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4, 0x6a,
0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49, 0xb8, 0x00,
];
#[test]
fn test_values_match_the_library() {
assert_eq!(
crate::crypto::tlv::CRITICAL_TEST_TAG,
wire_manifest::CRITICAL_TEST_TAG
);
assert_eq!(
crate::recipient::name::TEST_TYPE_NAME_PREFIX,
wire_manifest::TEST_TYPE_NAME_PREFIX
);
}
#[test]
fn small_order_vector_reaches_the_during_operation_check() {
let mut body = [0u8; x25519::BODY_LENGTH];
body[..x25519::PUBLIC_KEY_SIZE].copy_from_slice(&SMALL_ORDER_EPHEMERAL);
x25519::validate_body_preflight(&body)
.expect("the pinned vector must pass the canonical/nonzero preflight");
let private_key = [0x77u8; x25519::PRIVATE_KEY_SIZE];
match x25519::unwrap(&body, &private_key) {
Err(CryptoError::InvalidFormat(crate::error::FormatDefect::MalformedRecipientEntry)) => {}
other => panic!("expected the all-zero shared-secret rejection, got {other:?}"),
}
}
fn write_x25519_cases(corpus: &mut Corpus, base: &MutationBase) {
let body = body_offset(x25519::TYPE_NAME);
let ephemeral = body;
let nonce = ephemeral + x25519::PUBLIC_KEY_SIZE;
let wrapped = nonce + WRAP_NONCE_SIZE;
recredential_case(
corpus,
base,
"x25519-wrong-private-key",
"private-key-b",
"x25519_recipient_key_does_not_open_any_slot",
"recipient_unwrap_failed",
);
for (case_id, offset, condition) in [
(
"x25519-tamper-ephemeral",
ephemeral,
"x25519_ephemeral_modified",
),
(
"x25519-tamper-wrap-nonce",
nonce,
"x25519_wrap_nonce_modified",
),
(
"x25519-tamper-wrapped-file-key",
wrapped,
"x25519_wrapped_file_key_modified",
),
] {
mutate_fcr(
corpus,
base,
case_id,
"private-key-a",
condition,
"recipient_unwrap_failed",
|b| b[offset] ^= 0x01,
);
}
mutate_fcr(
corpus,
base,
"x25519-ephemeral-all-zero",
"private-key-a",
"x25519_ephemeral_all_zero",
"malformed_recipient_entry",
|b| b[ephemeral..ephemeral + x25519::PUBLIC_KEY_SIZE].fill(0),
);
mutate_fcr(
corpus,
base,
"x25519-ephemeral-noncanonical",
"private-key-a",
"x25519_ephemeral_not_canonical_encoding",
"malformed_recipient_entry",
|b| b[ephemeral + x25519::PUBLIC_KEY_SIZE - 1] |= 0x80,
);
mutate_fcr(
corpus,
base,
"x25519-small-order-ephemeral",
"private-key-a",
"x25519_all_zero_shared_secret",
"malformed_recipient_entry",
|b| {
b[ephemeral..ephemeral + x25519::PUBLIC_KEY_SIZE]
.copy_from_slice(&SMALL_ORDER_EPHEMERAL)
},
);
mutate_fcr(
corpus,
base,
"x25519-body-len-invalid",
"private-key-a",
"x25519_body_length_not_104",
"malformed_recipient_entry",
|b| remove_first_entry_body_byte(b, wrapped),
);
mutate_fcr(
corpus,
base,
"x25519-reserved-flag-set",
"private-key-a",
"x25519_reserved_flag_bit_nonzero",
"recipient_flags_reserved",
|b| write_u16_be(b, OFF_FIRST_ENTRY_FLAGS, 1 << 14),
);
mutate_fcr(
corpus,
base,
"x25519-critical-flag-set",
"private-key-a",
"x25519_native_entry_flags_nonzero",
"malformed_recipient_entry",
|b| {
write_u16_be(
b,
OFF_FIRST_ENTRY_FLAGS,
crate::recipient::entry::RECIPIENT_FLAG_CRITICAL,
)
},
);
}
fn write_tlv_cases(corpus: &mut Corpus, sources: &Path) {
use crate::crypto::tlv::tlv_bytes;
let scope = case_scope("tlv-unknown-ignorable");
let source = write_source(sources, SOURCE_FILE_NAME, 64);
let file_key = FileKey::generate().expect("file key");
let entries = [argon2id_entry(&file_key)];
let ext = tlv_bytes(UNKNOWN_IGNORABLE_TAG, b"ignorable");
let built = build_fcr_with_entries(&source, &file_key, &entries, &ext);
accept_fcr_case(
corpus,
"tlv-unknown-ignorable",
"origin-tlv-unknown-ignorable",
"passphrase-main",
&source,
built,
);
let tlv_len_at = size_of::<u16>();
let mut truncated_value = tlv_bytes(UNKNOWN_IGNORABLE_TAG, b"");
write_u32_be(&mut truncated_value, tlv_len_at, 99);
let cases: [(&str, Vec<u8>, &str, &str); 8] = [
(
"tlv-reserved-tag-0000",
tlv_bytes(IGNORABLE_HALF_RESERVED_TAG, b"x"),
"tlv_tag_reserved",
"malformed_tlv",
),
(
"tlv-reserved-tag-8000",
tlv_bytes(CRITICAL_HALF_RESERVED_TAG, b"x"),
"tlv_tag_reserved",
"malformed_tlv",
),
(
"tlv-duplicate-tags",
[
tlv_bytes(UNKNOWN_IGNORABLE_TAG, b"a"),
tlv_bytes(UNKNOWN_IGNORABLE_TAG, b"b"),
]
.concat(),
"tlv_tags_not_strictly_ascending",
"malformed_tlv",
),
(
"tlv-out-of-order-tags",
[
tlv_bytes(SECOND_UNKNOWN_IGNORABLE_TAG, b"a"),
tlv_bytes(UNKNOWN_IGNORABLE_TAG, b"b"),
]
.concat(),
"tlv_tags_not_strictly_ascending",
"malformed_tlv",
),
(
"tlv-truncated-entry-header",
vec![0x00, 0x01, 0x00],
"tlv_entry_header_truncated",
"malformed_tlv",
),
(
"tlv-value-runs-past-region",
truncated_value,
"tlv_value_runs_past_region",
"malformed_tlv",
),
(
"tlv-value-above-cap",
{
let mut entry = tlv_bytes(UNKNOWN_IGNORABLE_TAG, b"");
write_u32_be(&mut entry, tlv_len_at, crate::format::EXT_LEN_MAX + 1);
entry
},
"tlv_declared_value_above_cap",
"malformed_tlv",
),
(
"tlv-structure-before-critical-tag",
critical_tag_then_malformed_entry(b"x"),
"tlv_value_runs_past_region_after_unknown_critical_tag",
"malformed_tlv",
),
];
drop(scope);
for (case_id, ext_bytes, condition, class) in cases {
let _scope = case_scope(case_id);
let file_key = FileKey::generate().expect("file key");
let entries = [argon2id_entry(&file_key)];
crafted_reject_case(
corpus,
sources,
case_id,
"passphrase-main",
None,
condition,
class,
&file_key,
&entries,
&ext_bytes,
);
}
let scope = case_scope("tlv-empty-value");
let source = write_source(sources, SOURCE_FILE_NAME, 64);
let file_key = FileKey::generate().expect("file key");
let entries = [argon2id_entry(&file_key)];
let built = build_fcr_with_entries(
&source,
&file_key,
&entries,
&tlv_bytes(UNKNOWN_IGNORABLE_TAG, b""),
);
accept_fcr_case(
corpus,
"tlv-empty-value",
"origin-tlv-empty-value",
"passphrase-main",
&source,
built,
);
drop(scope);
let scope = case_scope("tlv-unknown-critical");
let source = write_source(sources, SOURCE_FILE_NAME, 64);
let file_key = FileKey::generate().expect("file key");
let entries = [argon2id_entry(&file_key)];
let built = build_fcr_with_entries(
&source,
&file_key,
&entries,
&tlv_bytes(UNKNOWN_CRITICAL_TAG, b"x"),
);
commit_crafted_reject(
corpus,
"tlv-unknown-critical",
"passphrase-main",
None,
"tlv_critical_tag_unsupported",
"unknown_critical_tlv",
built,
);
drop(scope);
}
fn encode_payload(
payload_key: &crate::crypto::keys::PayloadKey,
stream_nonce: &[u8; STREAM_NONCE_SIZE],
plaintext: &[u8],
empty_trailer: bool,
) -> Vec<u8> {
use chacha20poly1305::{
XChaCha20Poly1305,
aead::{KeyInit as AeadKeyInit, stream},
};
let cipher = XChaCha20Poly1305::new(payload_key.expose().into());
let mut encryptor = stream::EncryptorBE32::from_aead(cipher, stream_nonce.into());
let mut out = Vec::new();
let chunk = crate::crypto::stream::BUFFER_SIZE;
assert!(
!empty_trailer || (!plaintext.is_empty() && plaintext.len().is_multiple_of(chunk)),
"an empty trailer is only the forbidden transcript after an exact multiple of the chunk size"
);
let mut offset = 0;
loop {
let take = chunk.min(plaintext.len() - offset);
let piece = &plaintext[offset..offset + take];
offset += take;
let final_chunk = offset == plaintext.len() && !empty_trailer;
let mut buffer = piece.to_vec();
if final_chunk {
encryptor
.encrypt_last_in_place(b"", &mut buffer)
.expect("encrypt final chunk");
out.extend_from_slice(&buffer);
break;
}
encryptor
.encrypt_next_in_place(b"", &mut buffer)
.expect("encrypt chunk");
out.extend_from_slice(&buffer);
if offset == plaintext.len() {
let mut trailer = Vec::new();
encryptor
.encrypt_last_in_place(b"", &mut trailer)
.expect("encrypt empty trailer");
out.extend_from_slice(&trailer);
break;
}
}
out
}
fn write_payload_stream_cases(corpus: &mut Corpus, sources: &Path) {
let scope = case_scope("payload-two-chunk-valid");
let source = write_source(
sources,
SOURCE_FILE_NAME,
crate::crypto::stream::BUFFER_SIZE * 2,
);
let file_key = FileKey::generate().expect("file key");
let entries = [argon2id_entry(&file_key)];
let mut fca = Vec::new();
fca = crate::archive::prepare_archive(&source, ArchiveLimits::default())
.expect("prepare archive")
.write_to(fca)
.expect("serialize fca payload");
let (built, stream_nonce_hex, payload_key_digest) = craft_header(&file_key, &entries, b"");
let honest = encode_payload(&built.payload_key, &built.stream_nonce, &fca, false);
let bytes = assemble_fcr(&built, &honest);
let head = bytes.len() - honest.len();
let artifact_ref = corpus.write_ref("artifacts/fcr/payload-two-chunk-valid.fcr", &bytes);
let expected_ref = corpus.write_ref(
"expected/plaintext/payload-two-chunk-valid.bin",
&fs::read(&source).expect("read source"),
);
corpus.push_origin(OriginRow {
origin_id: "origin-payload-two-chunk".to_string(),
origin_kind: "fcr_payload",
anchor_case_id: "payload-two-chunk-valid".to_string(),
payload_key_ref: "-".to_string(),
payload_key_sha3_256: payload_key_digest,
stream_nonce_hex,
});
corpus.push_case(
CaseRow::fcr("payload-two-chunk-valid", &artifact_ref)
.origin("origin-payload-two-chunk")
.fabricated()
.credential("passphrase-main")
.accept(&expected_ref),
);
let base = MutationBase {
case_id: "payload-two-chunk-valid".to_string(),
origin_id: "origin-payload-two-chunk".to_string(),
bytes: bytes.clone(),
};
mutate_fcr(
corpus,
&base,
"payload-chunk-tampered",
"passphrase-main",
"payload_chunk_ciphertext_modified",
"payload_authentication_failed",
|b| {
let at = head + 8;
b[at] ^= 0x01;
},
);
mutate_fcr(
corpus,
&base,
"payload-region-empty",
"passphrase-main",
"payload_region_carries_no_chunk",
"payload_truncated",
|b| b.truncate(head),
);
mutate_fcr(
corpus,
&base,
"payload-cut-at-chunk-boundary",
"passphrase-main",
"payload_cut_at_chunk_boundary",
"payload_authentication_failed",
|b| {
let frame = crate::crypto::stream::BUFFER_SIZE + crate::crypto::aead::TAG_SIZE;
b.truncate(head + frame);
},
);
mutate_fcr(
corpus,
&base,
"payload-trailing-bytes",
"passphrase-main",
"payload_bytes_appended_after_final_chunk",
"payload_authentication_failed",
|b| b.extend_from_slice(&[FILLER; 16]),
);
let exact_source = write_source(
sources,
SOURCE_FILE_NAME,
crate::crypto::stream::BUFFER_SIZE - fca_overhead(SOURCE_FILE_NAME),
);
drop(scope);
let scope = case_scope("payload-empty-final-after-data");
let mut exact_fca = Vec::new();
exact_fca = crate::archive::prepare_archive(&exact_source, ArchiveLimits::default())
.expect("prepare archive")
.write_to(exact_fca)
.expect("serialize fca payload");
let file_key = FileKey::generate().expect("file key");
let entries = [argon2id_entry(&file_key)];
let (built, stream_nonce_hex, payload_key_digest) = craft_header(&file_key, &entries, b"");
let forbidden = encode_payload(&built.payload_key, &built.stream_nonce, &exact_fca, true);
let bytes = assemble_fcr(&built, &forbidden);
let artifact_ref = corpus.write_ref("artifacts/fcr/payload-empty-final-after-data.fcr", &bytes);
corpus.push_origin(OriginRow {
origin_id: "origin-payload-empty-final".to_string(),
origin_kind: "fcr_payload",
anchor_case_id: "payload-empty-final-after-data".to_string(),
payload_key_ref: "-".to_string(),
payload_key_sha3_256: payload_key_digest,
stream_nonce_hex,
});
corpus.push_case(
CaseRow::fcr("payload-empty-final-after-data", &artifact_ref)
.origin("origin-payload-empty-final")
.fabricated()
.credential("passphrase-main")
.reject(
"payload_empty_final_chunk_after_data",
"malformed_payload_stream",
),
);
drop(scope);
}
fn public_key_row(corpus: &mut Corpus, case_id: &str, bytes: &[u8]) -> CaseRow {
let artifact_ref =
corpus.write_ref(&format!("artifacts/public-key/{case_id}.public.key"), bytes);
CaseRow::new(case_id, "public_key_decode", &artifact_ref)
.fabricated()
.credential("none")
}
fn unsupported_key_type_case(
corpus: &mut Corpus,
row: CaseRow,
type_name: &str,
condition_id: &str,
) {
let capability = key_type_capability(type_name);
let row = row
.capability_if_any(capability.as_deref())
.reject(condition_id, "unsupported_key_type");
corpus.push_case(row);
}
fn public_key_case(
corpus: &mut Corpus,
case_id: &str,
bytes: &[u8],
outcome: Result<[u8; 32], (&str, &str)>,
) {
let row = public_key_row(corpus, case_id, bytes);
let row = match outcome {
Ok(material) => {
let expected_ref =
corpus.write_ref(&format!("expected/public-key/{case_id}.bin"), &material);
row.accept(&expected_ref)
}
Err((condition, class)) => row.reject(condition, class),
};
corpus.push_case(row);
}
fn write_public_key_cases(corpus: &mut Corpus, keys: &CorpusKeys, encrypted_file: &[u8]) {
use crate::key::public::{
PUBLIC_KEY_FILE_READ_CAP_BYTES, RECIPIENT_HRP, encode_recipient_groups_for_tests,
encode_recipient_payload_with_hrp, non_canonical_padding_groups_for_tests,
recipient_payload_for_tests, recipient_payload_unchecked_for_tests,
};
let canonical = keys.public_a.clone();
let material = decode_public_key_file(&canonical)
.to_x25519_bytes()
.expect("decode canonical public key");
let text = String::from_utf8(canonical.clone()).expect("public.key is UTF-8");
let recipient = text.trim_end_matches('\n').to_string();
public_key_case(corpus, "public-key-canonical", &canonical, Ok(material));
public_key_case(
corpus,
"public-key-without-trailing-lf",
recipient.as_bytes(),
Ok(material),
);
let payload = recipient_payload_for_tests(
crate::format::WRITER_KEYPAIR_SUITE.public_key_version(),
"x25519",
&material,
)
.expect("build the canonical recipient payload");
let [nonzero_padding, surplus_group] = non_canonical_padding_groups_for_tests(&payload);
let reject = |s: String| -> Vec<u8> { s.into_bytes() };
let prefix = std::str::from_utf8(crate::format::RECIPIENT_STRING_PREFIX)
.expect("the recipient-string prefix is ASCII");
let data_part = recipient
.strip_prefix(prefix)
.expect("a recipient string begins with the prefix");
let mut not_utf8 = canonical.clone();
not_utf8[0] = 0xFF;
let private_key =
fs::read(corpus.root.join(&keys.private_a)).expect("read the corpus private key");
let mut newer_private_key = private_key.clone();
newer_private_key[crate::key::private::VERSION_OFFSET] = NEWER_VERSION;
for bytes in [&private_key, encrypted_file] {
assert!(bytes.len() <= PUBLIC_KEY_FILE_READ_CAP_BYTES);
assert!(std::str::from_utf8(bytes).is_err());
}
let cases: [(&str, Vec<u8>, &str, &str); 18] = [
(
"public-key-checksum-corrupted",
reject(with_corrupted_checksum(&recipient)),
"public_key_bech32_checksum_mismatch",
"malformed_public_key",
),
(
"public-key-bech32m",
reject(
bech32::encode::<bech32::Bech32m>(crate::key::public::RECIPIENT_HRP, &payload)
.expect("encode the canonical payload with the Bech32m checksum"),
),
"public_key_checksum_algorithm_is_bech32m",
"malformed_public_key",
),
(
"public-key-uppercase",
reject(recipient.to_uppercase()),
"public_key_not_canonical_lowercase",
"malformed_public_key",
),
(
"public-key-mixed-case",
reject(format!("{}{data_part}", prefix.to_uppercase())),
"public_key_mixed_case",
"malformed_public_key",
),
(
"public-key-leading-whitespace",
reject(format!(" {recipient}")),
"public_key_surrounding_whitespace",
"malformed_public_key",
),
(
"public-key-two-trailing-newlines",
reject(format!("{recipient}\n\n")),
"public_key_more_than_one_trailing_newline",
"malformed_public_key",
),
(
"public-key-crlf",
reject(format!("{recipient}\r\n")),
"public_key_crlf_line_ending",
"malformed_public_key",
),
(
"public-key-empty",
Vec::new(),
"public_key_file_empty",
"malformed_public_key",
),
(
"public-key-wrong-hrp",
reject(
encode_recipient_payload_with_hrp("xyz", &payload)
.expect("encode under a different human-readable part"),
),
"public_key_human_readable_part_not_fcr",
"malformed_public_key",
),
(
"public-key-internal-checksum-corrupted",
reject(
encode_recipient_payload_with_hrp(
RECIPIENT_HRP.as_str(),
&with_internal_checksum_corrupted(payload.clone()),
)
.expect("re-encode the corrupted payload"),
),
"public_key_internal_checksum_mismatch",
"malformed_public_key",
),
(
"public-key-truncated",
reject(recipient[..recipient.len() - 8].to_string()),
"public_key_string_truncated",
"malformed_public_key",
),
(
"public-key-padding-nonzero",
reject(
encode_recipient_groups_for_tests(&nonzero_padding)
.expect("encode a padding bit set in the last group"),
),
"public_key_padding_bits_nonzero",
"malformed_public_key",
),
(
"public-key-padding-surplus-group",
reject(
encode_recipient_groups_for_tests(&surplus_group)
.expect("encode a surplus padding group"),
),
"public_key_padding_surplus_group",
"malformed_public_key",
),
(
"public-key-type-name-malformed",
reject(
encode_recipient_payload_with_hrp(
RECIPIENT_HRP.as_str(),
&recipient_payload_unchecked_for_tests(
crate::format::WRITER_KEYPAIR_SUITE.public_key_version(),
&ungrammatical_type_name(),
&material,
)
.expect("build a payload with an ungrammatical type name"),
)
.expect("encode the payload"),
),
"public_key_type_name_violates_grammar",
"malformed_type_name",
),
(
"public-key-given-private-key-file",
private_key.clone(),
"public_key_reader_given_private_key_file",
"wrong_key_file_type",
),
(
"public-key-given-encrypted-file",
encrypted_file.to_vec(),
"public_key_reader_given_encrypted_file",
"not_a_key_file",
),
(
"public-key-given-newer-private-key-file",
newer_private_key,
"public_key_reader_given_private_key_file_of_newer_version",
"wrong_key_file_type",
),
(
"public-key-not-utf8",
not_utf8,
"public_key_file_not_utf8",
"not_a_key_file",
),
];
for (case_id, bytes, condition, class) in cases {
public_key_case(corpus, case_id, &bytes, Err((condition, class)));
}
write_public_key_version_cases(corpus);
write_public_key_material_cases(corpus);
write_public_key_length_cases(corpus);
write_public_key_order_cases(corpus, &private_key);
}
fn write_public_key_version_cases(corpus: &mut Corpus) {
use crate::key::public::encode_recipient_string_with_version;
let material = [7u8; x25519::PUBLIC_KEY_SIZE];
let reserved = encode_recipient_string_with_version(RESERVED_VERSION, "x25519", &material)
.expect("encode reserved public-key version");
public_key_case(
corpus,
"public-key-version-zero",
format!("{reserved}\n").as_bytes(),
Err(("public_key_version_reserved_zero", "malformed_public_key")),
);
let newer = encode_recipient_string_with_version(NEWER_VERSION, "x25519", &material)
.expect("encode newer public-key version");
let row = public_key_row(
corpus,
"public-key-newer-version",
format!("{newer}\n").as_bytes(),
)
.capability(&newer_version_capability(
wire_manifest::PUBLIC_KEY_VERSION_DOMAIN,
))
.reject(
"public_key_version_not_supported",
"unsupported_public_key_version",
);
corpus.push_case(row);
let unsupported = encode_recipient_string_with_version(
crate::key::public::PUBLIC_KEY_VERSION,
UNSUPPORTED_KEY_TYPE,
&material,
)
.expect("encode unsupported key type");
let row = public_key_row(
corpus,
"public-key-unsupported-type",
format!("{unsupported}\n").as_bytes(),
);
unsupported_key_type_case(
corpus,
row,
UNSUPPORTED_KEY_TYPE,
"public_key_type_not_supported",
);
}
fn with_internal_checksum_corrupted(mut payload: Vec<u8>) -> Vec<u8> {
*payload.last_mut().expect("payload is nonempty") ^= 0x01;
payload
}
fn with_corrupted_checksum(recipient: &str) -> String {
let mut s = recipient.to_string();
let last = s.pop().expect("recipient is nonempty");
s.push(if last == 'q' { 'p' } else { 'q' });
s
}
fn longest_recipient_payload() -> Vec<u8> {
use crate::key::public::{
KEY_MATERIAL_LEN_MAX, PUBLIC_KEY_VERSION, recipient_payload_for_tests,
};
recipient_payload_for_tests(
PUBLIC_KEY_VERSION,
&longest_unsupported_key_type(),
&vec![FILLER; KEY_MATERIAL_LEN_MAX as usize],
)
.expect("build the longest payload")
}
fn longest_recipient_string(payload: &[u8]) -> String {
use crate::key::public::{
RECIPIENT_HRP, RECIPIENT_STRING_LEN_MAX, encode_recipient_payload_with_hrp,
};
let string = encode_recipient_payload_with_hrp(RECIPIENT_HRP.as_str(), payload)
.expect("encode the longest payload");
assert_eq!(string.len(), RECIPIENT_STRING_LEN_MAX - 1);
string
}
fn write_public_key_length_cases(corpus: &mut Corpus) {
use crate::key::public::{
KEY_MATERIAL_LEN_MAX, PAYLOAD_HEADER_SIZE, PAYLOAD_VERSION_OFFSET,
PUBLIC_KEY_CHECKSUM_SIZE, PUBLIC_KEY_VERSION, RECIPIENT_HRP, RECIPIENT_STRING_LEN_MAX,
encode_recipient_groups_for_tests, encode_recipient_payload_with_hrp,
encode_recipient_string_with_version, recipient_groups_for_tests,
recipient_payload_unchecked_for_tests,
};
let material = [7u8; x25519::PUBLIC_KEY_SIZE];
let longest = longest_unsupported_key_type();
let encode_unchecked = |version: u8, type_name: &str, material: &[u8]| -> String {
let payload = recipient_payload_unchecked_for_tests(version, type_name, material)
.expect("build the payload");
encode_recipient_payload_with_hrp(RECIPIENT_HRP.as_str(), &payload)
.expect("encode the payload")
};
for (case_id, type_name, condition) in [
(
"public-key-type-name-len-zero",
String::new(),
"public_key_type_name_len_zero",
),
(
"public-key-type-name-len-above-max",
format!("{longest}k"),
"public_key_type_name_len_above_structural_maximum",
),
] {
public_key_case(
corpus,
case_id,
format!(
"{}\n",
encode_unchecked(PUBLIC_KEY_VERSION, &type_name, &material)
)
.as_bytes(),
Err((condition, "malformed_public_key")),
);
}
let newer = encode_unchecked(NEWER_VERSION, "", &material);
let row = public_key_row(
corpus,
"public-key-newer-version-type-name-len-zero",
format!("{newer}\n").as_bytes(),
)
.capability(&newer_version_capability(
wire_manifest::PUBLIC_KEY_VERSION_DOMAIN,
))
.reject(
"public_key_version_not_supported_and_type_name_len_zero",
"unsupported_public_key_version",
);
corpus.push_case(row);
let mut short = vec![FILLER; PAYLOAD_HEADER_SIZE + PUBLIC_KEY_CHECKSUM_SIZE - 1];
short[PAYLOAD_VERSION_OFFSET] = NEWER_VERSION;
let short = encode_recipient_payload_with_hrp(RECIPIENT_HRP.as_str(), &short)
.expect("encode a short payload");
public_key_case(
corpus,
"public-key-newer-version-payload-too-short",
format!("{short}\n").as_bytes(),
Err((
"public_key_payload_shorter_than_fixed_fields_with_newer_version",
"malformed_public_key",
)),
);
let at_max = encode_recipient_string_with_version(PUBLIC_KEY_VERSION, &longest, &material)
.expect("encode a type name on the maximum");
let row = public_key_row(
corpus,
"public-key-type-name-len-at-max",
format!("{at_max}\n").as_bytes(),
);
unsupported_key_type_case(
corpus,
row,
&longest,
"public_key_type_name_len_at_structural_maximum_and_type_not_supported",
);
let shortest =
encode_recipient_string_with_version(PUBLIC_KEY_VERSION, SHORTEST_TYPE_NAME, &[])
.expect("encode the shortest recipient string");
let row = public_key_row(
corpus,
"public-key-lengths-at-min",
format!("{shortest}\n").as_bytes(),
);
unsupported_key_type_case(
corpus,
row,
SHORTEST_TYPE_NAME,
"public_key_type_name_len_and_key_material_len_at_structural_minima_and_type_not_supported",
);
let longest_payload = longest_recipient_payload();
let longest_string = longest_recipient_string(&longest_payload);
let material_past_max = encode_unchecked(
PUBLIC_KEY_VERSION,
&ungrammatical_type_name(),
&vec![FILLER; KEY_MATERIAL_LEN_MAX as usize + 1],
);
under_limit_profile(
corpus,
RECIPIENT_STRING_MAXIMUM_LIMIT_PROFILE_ID,
|corpus| {
let row = public_key_row(
corpus,
"public-key-key-material-len-at-max",
format!("{longest_string}\n").as_bytes(),
);
unsupported_key_type_case(
corpus,
row,
&longest,
"public_key_type_name_and_key_material_len_at_structural_maxima_and_type_not_supported",
);
public_key_case(
corpus,
"public-key-key-material-len-above-max",
format!("{material_past_max}\n").as_bytes(),
Err((
"public_key_key_material_len_above_structural_maximum_and_type_name_malformed",
"malformed_public_key",
)),
);
},
);
let mut groups = recipient_groups_for_tests(&longest_payload);
groups.push(0);
let on_ceiling = encode_recipient_groups_for_tests(&groups).expect("encode 20,000 characters");
groups.push(0);
let past_ceiling =
encode_recipient_groups_for_tests(&groups).expect("encode 20,001 characters");
assert_eq!(on_ceiling.len(), RECIPIENT_STRING_LEN_MAX);
assert_eq!(past_ceiling.len(), RECIPIENT_STRING_LEN_MAX + 1);
public_key_case(
corpus,
"public-key-recipient-string-at-ceiling",
on_ceiling.as_bytes(),
Err((
"public_key_recipient_string_on_structural_ceiling_above_local_cap",
"resource_cap_exceeded",
)),
);
public_key_case(
corpus,
"public-key-recipient-string-above-ceiling",
past_ceiling.as_bytes(),
Err((
"public_key_recipient_string_above_structural_ceiling",
"malformed_public_key",
)),
);
let over_default_cap = encode_recipient_string_with_version(
PUBLIC_KEY_VERSION,
UNSUPPORTED_KEY_TYPE,
&[FILLER; KeyReadLimits::RECIPIENT_STRING_CHARS_DEFAULT as usize],
)
.expect("encode a string over the default cap");
assert!(over_default_cap.len() > KeyReadLimits::RECIPIENT_STRING_CHARS_DEFAULT as usize);
public_key_case(
corpus,
"public-key-over-cap-with-leading-whitespace",
format!(" {over_default_cap}\n").as_bytes(),
Err((
"public_key_recipient_string_above_local_cap_with_leading_whitespace",
"resource_cap_exceeded",
)),
);
}
fn write_public_key_order_cases(corpus: &mut Corpus, private_key: &[u8]) {
use crate::key::public::{
PAYLOAD_HEADER_SIZE, PUBLIC_KEY_CHECKSUM_SIZE, PUBLIC_KEY_FILE_READ_CAP_BYTES,
PUBLIC_KEY_VERSION, RECIPIENT_HRP, RECIPIENT_STRING_LEN_MAX,
encode_recipient_groups_for_tests, encode_recipient_payload_with_hrp,
encode_recipient_string_with_version, non_canonical_padding_groups_for_tests,
recipient_payload_unchecked_for_tests,
};
let material = [7u8; x25519::PUBLIC_KEY_SIZE];
let payload = |version: u8, type_name: &str| {
recipient_payload_unchecked_for_tests(version, type_name, &material)
.expect("build the payload")
};
let encode = |payload: &[u8]| {
encode_recipient_payload_with_hrp(RECIPIENT_HRP.as_str(), payload)
.expect("encode the payload")
};
let file = |string: &str| format!("{string}\n").into_bytes();
let mut oversized = private_key.to_vec();
oversized.resize(PUBLIC_KEY_FILE_READ_CAP_BYTES + 1, FILLER);
let not_utf8_past_ceiling = vec![FILLER; RECIPIENT_STRING_LEN_MAX + 1];
let default_cap = KeyReadLimits::RECIPIENT_STRING_CHARS_DEFAULT as usize;
let not_ascii_over_cap = format!("\u{e9}{}", "q".repeat(default_cap));
let newer = encode_recipient_string_with_version(NEWER_VERSION, x25519::TYPE_NAME, &material)
.expect("encode a newer public-key version");
let over_cap = |version: u8, type_name: &str| {
recipient_payload_unchecked_for_tests(version, type_name, &vec![FILLER; default_cap])
.expect("build the payload")
};
let [over_cap_with_padding, _] = non_canonical_padding_groups_for_tests(
&recipient_payload_unchecked_for_tests(
PUBLIC_KEY_VERSION,
UNSUPPORTED_KEY_TYPE,
&vec![FILLER; default_cap + 1],
)
.expect("build the payload"),
);
let mut short_with_type_name =
recipient_payload_unchecked_for_tests(PUBLIC_KEY_VERSION, &ungrammatical_type_name(), &[])
.expect("build the payload");
short_with_type_name.truncate(short_with_type_name.len() - PUBLIC_KEY_CHECKSUM_SIZE);
assert_eq!(
short_with_type_name.len(),
PAYLOAD_HEADER_SIZE + PUBLIC_KEY_CHECKSUM_SIZE - 1
);
let [type_name_and_padding, _] = non_canonical_padding_groups_for_tests(
&recipient_payload_unchecked_for_tests(
PUBLIC_KEY_VERSION,
&ungrammatical_type_name(),
&[7; x25519::PUBLIC_KEY_SIZE + 1],
)
.expect("build the payload"),
);
let [nonzero_padding, _] =
non_canonical_padding_groups_for_tests(&payload(NEWER_VERSION, x25519::TYPE_NAME));
let newer_with_padding = encode_recipient_groups_for_tests(&nonzero_padding)
.expect("encode a padding bit set in the last group");
let cases: [(&str, Vec<u8>, &str, &str); 17] = [
(
"public-key-order-file-size-before-signature",
oversized,
"public_key_file_above_ceiling_with_private_key_signature",
"malformed_public_key",
),
(
"public-key-order-utf8-before-ceiling",
not_utf8_past_ceiling,
"public_key_file_not_utf8_and_above_recipient_string_ceiling",
"not_a_key_file",
),
(
"public-key-order-ascii-before-cap",
file(¬_ascii_over_cap),
"public_key_recipient_string_not_ascii_and_above_local_cap",
"malformed_public_key",
),
(
"public-key-order-cap-before-version-zero",
file(&encode(&over_cap(RESERVED_VERSION, UNSUPPORTED_KEY_TYPE))),
"public_key_recipient_string_above_local_cap_and_version_reserved_zero",
"resource_cap_exceeded",
),
(
"public-key-order-cap-before-padding",
file(
&encode_recipient_groups_for_tests(&over_cap_with_padding)
.expect("encode a padding bit set in the last group"),
),
"public_key_recipient_string_above_local_cap_and_padding_bits_nonzero",
"resource_cap_exceeded",
),
(
"public-key-order-cap-before-lengths",
file(&encode(&over_cap(PUBLIC_KEY_VERSION, ""))),
"public_key_recipient_string_above_local_cap_and_type_name_len_zero",
"resource_cap_exceeded",
),
(
"public-key-order-cap-before-payload-length",
file(&encode(&with_trailing_filler(over_cap(
PUBLIC_KEY_VERSION,
UNSUPPORTED_KEY_TYPE,
)))),
"public_key_recipient_string_above_local_cap_and_payload_longer_than_declared",
"resource_cap_exceeded",
),
(
"public-key-order-cap-before-type-rules",
file(&encode(&over_cap(PUBLIC_KEY_VERSION, x25519::TYPE_NAME))),
"public_key_recipient_string_above_local_cap_and_x25519_material_not_32_bytes",
"resource_cap_exceeded",
),
(
"public-key-order-bech32-before-type-name",
file(&with_corrupted_checksum(&encode(&payload(
PUBLIC_KEY_VERSION,
&ungrammatical_type_name(),
)))),
"public_key_bech32_checksum_mismatch_and_type_name_violates_grammar",
"malformed_public_key",
),
(
"public-key-order-payload-size-before-type-name",
file(&encode(&short_with_type_name)),
"public_key_payload_shorter_than_fixed_fields_and_type_name_violates_grammar",
"malformed_public_key",
),
(
"public-key-order-padding-before-type-name",
file(
&encode_recipient_groups_for_tests(&type_name_and_padding)
.expect("encode a padding bit set in the last group"),
),
"public_key_padding_bits_nonzero_and_type_name_violates_grammar",
"malformed_public_key",
),
(
"public-key-order-bech32-before-version",
file(&with_corrupted_checksum(&newer)),
"public_key_bech32_checksum_mismatch_with_newer_version",
"malformed_public_key",
),
(
"public-key-order-padding-before-version",
file(&newer_with_padding),
"public_key_padding_bits_nonzero_with_newer_version",
"malformed_public_key",
),
(
"public-key-order-version-zero-before-type-name",
file(&encode(&payload(
RESERVED_VERSION,
&ungrammatical_type_name(),
))),
"public_key_version_reserved_zero_and_type_name_violates_grammar",
"malformed_public_key",
),
(
"public-key-order-payload-length-before-type-name",
file(&encode(&with_trailing_filler(payload(
PUBLIC_KEY_VERSION,
&ungrammatical_type_name(),
)))),
"public_key_payload_longer_than_declared_and_type_name_violates_grammar",
"malformed_public_key",
),
(
"public-key-order-type-name-before-internal-checksum",
file(&encode(&with_internal_checksum_corrupted(payload(
PUBLIC_KEY_VERSION,
&ungrammatical_type_name(),
)))),
"public_key_type_name_violates_grammar_and_internal_checksum_mismatch",
"malformed_type_name",
),
(
"public-key-order-internal-checksum-before-type-support",
file(&encode(&with_internal_checksum_corrupted(payload(
PUBLIC_KEY_VERSION,
UNSUPPORTED_KEY_TYPE,
)))),
"public_key_internal_checksum_mismatch_and_type_not_supported",
"malformed_public_key",
),
];
for (case_id, bytes, condition, class) in cases {
public_key_case(corpus, case_id, &bytes, Err((condition, class)));
}
let all_zero_material = recipient_payload_unchecked_for_tests(
NEWER_VERSION,
x25519::TYPE_NAME,
&[0; x25519::PUBLIC_KEY_SIZE],
)
.expect("build the payload");
let newer_version_cases = [
(
"public-key-order-version-before-payload-length",
with_trailing_filler(payload(NEWER_VERSION, x25519::TYPE_NAME)),
"public_key_version_not_supported_and_payload_longer_than_declared",
),
(
"public-key-order-version-before-type-rules",
all_zero_material,
"public_key_version_not_supported_and_x25519_material_all_zero",
),
];
for (case_id, payload, condition) in newer_version_cases {
let row = public_key_row(corpus, case_id, &file(&encode(&payload)))
.capability(&newer_version_capability(
wire_manifest::PUBLIC_KEY_VERSION_DOMAIN,
))
.reject(condition, "unsupported_public_key_version");
corpus.push_case(row);
}
under_limit_profile(
corpus,
ZERO_RECIPIENT_STRING_CAP_LIMIT_PROFILE_ID,
|corpus| {
public_key_case(
corpus,
"public-key-order-cap-before-payload-size",
&file(&encode(
&[FILLER; PAYLOAD_HEADER_SIZE + PUBLIC_KEY_CHECKSUM_SIZE - 1],
)),
Err((
"public_key_recipient_string_above_local_cap_and_payload_shorter_than_fixed_fields",
"resource_cap_exceeded",
)),
);
},
);
}
fn write_public_key_material_cases(corpus: &mut Corpus) {
use crate::key::public::encode_recipient_string_unchecked;
let offset_from_prime = |offset: i8| {
let mut bytes = x25519::FIELD_PRIME_LE;
bytes[0] = bytes[0].wrapping_add_signed(offset);
bytes
};
let below_prime = offset_from_prime(-1);
let mut max_255_bit = [0xFFu8; x25519::PUBLIC_KEY_SIZE];
max_255_bit[x25519::PUBLIC_KEY_SIZE - 1] = 0x7F;
let high_bit_set = [0xFFu8; x25519::PUBLIC_KEY_SIZE];
let canonical = encode_recipient_string_unchecked("x25519", &below_prime)
.expect("encode largest canonical material");
public_key_case(
corpus,
"public-key-material-field-prime-minus-one",
format!("{canonical}\n").as_bytes(),
Ok(below_prime),
);
let rejected: [(&str, [u8; x25519::PUBLIC_KEY_SIZE], &str); 5] = [
(
"public-key-material-all-zero",
[0u8; x25519::PUBLIC_KEY_SIZE],
"public_key_material_all_zero",
),
(
"public-key-material-field-prime",
offset_from_prime(0),
"public_key_material_not_canonical",
),
(
"public-key-material-field-prime-plus-one",
offset_from_prime(1),
"public_key_material_not_canonical",
),
(
"public-key-material-max-255-bit",
max_255_bit,
"public_key_material_not_canonical",
),
(
"public-key-material-high-bit-set",
high_bit_set,
"public_key_material_high_bit_not_masked",
),
];
for (case_id, material, condition) in rejected {
let text = encode_recipient_string_unchecked("x25519", &material)
.expect("encode noncanonical material");
public_key_case(
corpus,
case_id,
format!("{text}\n").as_bytes(),
Err((condition, "malformed_public_key")),
);
}
let short = encode_recipient_string_unchecked("x25519", &[9u8; 31])
.expect("encode undersized material");
public_key_case(
corpus,
"public-key-material-wrong-length",
format!("{short}\n").as_bytes(),
Err(("public_key_material_not_32_bytes", "malformed_public_key")),
);
}
fn open_corpus_private_key(corpus: &Corpus, keys: &CorpusKeys) -> x25519::OpenedX25519KeyFile {
x25519::open_x25519_key_file(
&corpus.root.join(&keys.private_a),
&corpus_passphrase(),
None,
crate::KeyReadLimits::default(),
&|_| {},
)
.expect("open corpus private key")
}
fn seal_private_key_for_case(
case_id: &str,
type_name: &str,
secret_material: &[u8],
public_material: &[u8],
ext_bytes: &[u8],
) -> Vec<u8> {
let _scope = case_scope(case_id);
crate::key::private::seal_private_key_unchecked_tlv(
secret_material,
type_name,
public_material,
ext_bytes,
&corpus_passphrase(),
&KdfParams::test_fast_default(),
)
.expect("seal private key")
}
fn write_private_key_ext_and_pair_cases(
corpus: &mut Corpus,
keys: &CorpusKeys,
canonical: &[u8],
opened: &x25519::OpenedX25519KeyFile,
) {
use crate::crypto::tlv::tlv_bytes;
let seal = |case_id: &str, ext_bytes: &[u8], public_material: &[u8]| {
seal_private_key_for_case(
case_id,
x25519::TYPE_NAME,
opened.secret.as_slice(),
public_material,
ext_bytes,
)
};
private_key_open_case(
corpus,
"private-key-ext-ignorable",
&seal(
"private-key-ext-ignorable",
&tlv_bytes(UNKNOWN_IGNORABLE_TAG, b"ignorable"),
&opened.public,
),
"private-key-a",
Ok(opened.public),
);
let critical = seal(
"private-key-ext-unknown-critical",
&unknown_critical_tlv_region(),
&opened.public,
);
let row = private_key_open_row(
corpus,
"private-key-ext-unknown-critical",
&critical,
"private-key-a",
)
.reject(
"private_key_ext_unknown_critical_tag",
"unknown_critical_tlv",
);
corpus.push_case(row);
private_key_open_case(
corpus,
"private-key-ext-malformed",
&seal(
"private-key-ext-malformed",
&tlv_value_running_past_region(UNKNOWN_IGNORABLE_TAG),
&opened.public,
),
"private-key-a",
Err(("private_key_ext_value_runs_past_region", "malformed_tlv")),
);
let other = decode_public_key_file(&keys.public_b)
.to_x25519_bytes()
.expect("decode the other corpus public key");
private_key_open_case(
corpus,
"private-key-public-secret-mismatch",
&seal("private-key-public-secret-mismatch", b"", &other),
"private-key-a",
Err((
"private_key_public_material_not_derived_from_secret",
"malformed_private_key",
)),
);
use crate::key::private::{
EXT_LEN_OFFSET, PRIVATE_KEY_HEADER_FIXED_SIZE, PUBLIC_LEN_OFFSET, TYPE_NAME_LEN_OFFSET,
WRAPPED_SECRET_LEN_OFFSET,
};
let over_cap_len = crate::KeyReadLimits::PRIVATE_KEY_WRAPPED_SECRET_LEN_DEFAULT + 1;
let mut over_cap = canonical.to_vec();
let declared = PRIVATE_KEY_HEADER_FIXED_SIZE
+ usize::from(be_u16_at(&over_cap, TYPE_NAME_LEN_OFFSET))
+ be_u32_at(&over_cap, PUBLIC_LEN_OFFSET) as usize
+ be_u32_at(&over_cap, EXT_LEN_OFFSET) as usize
+ over_cap_len as usize;
write_u32_be(&mut over_cap, WRAPPED_SECRET_LEN_OFFSET, over_cap_len);
over_cap.resize(declared, 0);
private_key_open_case(
corpus,
"private-key-wrapped-secret-over-default-cap",
&over_cap,
"private-key-a",
Err((
"private_key_wrapped_secret_over_local_cap",
"resource_cap_exceeded",
)),
);
let mut over_memory_cap = canonical.to_vec();
write_private_key_kdf_params(
&mut over_memory_cap,
kdf_params_one_past_default_memory_cap(),
);
private_key_open_case(
corpus,
"private-key-kdf-memory-over-default-cap",
&over_memory_cap,
"private-key-a",
Err((
"private_key_argon2id_memory_above_default_local_cap",
"resource_cap_exceeded",
)),
);
let mut over_work_cap = canonical.to_vec();
write_private_key_kdf_params(&mut over_work_cap, kdf_params_one_past_default_work_cap());
private_key_open_case(
corpus,
"private-key-kdf-work-over-default-cap",
&over_work_cap,
"private-key-a",
Err((
"private_key_argon2id_work_above_default_local_cap",
"resource_cap_exceeded",
)),
);
}
fn unknown_critical_tlv_region() -> Vec<u8> {
crate::crypto::tlv::tlv_bytes(UNKNOWN_CRITICAL_TAG, b"critical")
}
fn tlv_value_running_past_region(tag: u16) -> Vec<u8> {
let mut region = crate::crypto::tlv::tlv_bytes(tag, b"value");
region.truncate(region.len() - 2);
region
}
fn kdf_params_one_past_default_memory_cap() -> KdfParams {
KdfParams {
mem_cost: KdfLimit::MEM_COST_KIB_DEFAULT + 1,
time_cost: 1,
lanes: 1,
}
}
fn kdf_params_one_past_default_work_cap() -> KdfParams {
let limit = KdfLimit::default();
let work = limit.max_work + 1;
(1..=limit.max_time_cost)
.find_map(|time_cost| {
let mem_cost = u32::try_from(work / u64::from(time_cost)).ok()?;
(work % u64::from(time_cost) == 0 && mem_cost <= limit.max_mem_cost_kib).then_some(
KdfParams {
mem_cost,
time_cost,
lanes: 1,
},
)
})
.expect("a time cost under its cap divides the work into memory under its cap")
}
type ByteMutationCase = (
&'static str,
Box<dyn Fn(&mut Vec<u8>)>,
&'static str,
&'static str,
);
fn private_key_row(corpus: &mut Corpus, case_id: &str, bytes: &[u8]) -> CaseRow {
let artifact_ref = corpus.write_ref(
&format!("artifacts/private-key/{case_id}.private.key"),
bytes,
);
CaseRow::new(case_id, "private_key_validate", &artifact_ref)
.fabricated()
.credential("none")
}
fn private_key_case(
corpus: &mut Corpus,
case_id: &str,
bytes: &[u8],
condition_id: &str,
diagnostic_class: &str,
) {
let row = private_key_row(corpus, case_id, bytes).reject(condition_id, diagnostic_class);
corpus.push_case(row);
}
fn with_private_key_type(key: &[u8], type_name: &str) -> Vec<u8> {
use crate::key::private::{PRIVATE_KEY_HEADER_FIXED_SIZE, TYPE_NAME_LEN_OFFSET};
let old_len = usize::from(be_u16_at(key, TYPE_NAME_LEN_OFFSET));
let new_len = u16::try_from(type_name.len()).expect("a type name fits its length field");
let mut out = key[..PRIVATE_KEY_HEADER_FIXED_SIZE].to_vec();
write_u16_be(&mut out, TYPE_NAME_LEN_OFFSET, new_len);
out.extend_from_slice(type_name.as_bytes());
out.extend_from_slice(&key[PRIVATE_KEY_HEADER_FIXED_SIZE + old_len..]);
out
}
fn with_trailing_filler(mut bytes: Vec<u8>) -> Vec<u8> {
bytes.push(FILLER);
bytes
}
fn private_key_kdf_params_field(key: &mut [u8]) -> &mut [u8] {
use crate::key::private::KDF_PARAMS_OFFSET;
&mut key[KDF_PARAMS_OFFSET..KDF_PARAMS_OFFSET + KDF_PARAMS_SIZE]
}
fn write_private_key_kdf_params(key: &mut [u8], params: KdfParams) {
private_key_kdf_params_field(key).copy_from_slice(¶ms.to_bytes());
}
fn set_kdf_memory_above_max(key: &mut [u8]) {
let field = private_key_kdf_params_field(key);
let stored = KdfParams::from_bytes_structural(
&<[u8; KDF_PARAMS_SIZE]>::try_from(&*field).expect("the field holds KDF parameters"),
)
.expect("the key stores valid KDF parameters");
write_private_key_kdf_params(
key,
KdfParams {
mem_cost: KdfLimit::MEM_COST_KIB_STRUCTURAL_MAX + 1,
..stored
},
);
}
const PRIVATE_KEY_REGION_LEN_OFFSETS: [usize; 3] = [
crate::key::private::PUBLIC_LEN_OFFSET,
crate::key::private::EXT_LEN_OFFSET,
crate::key::private::WRAPPED_SECRET_LEN_OFFSET,
];
fn assert_private_key_lengths_fit(case_id: &str, key: &[u8]) {
use crate::key::private::{PRIVATE_KEY_HEADER_FIXED_SIZE, TYPE_NAME_LEN_OFFSET};
let regions: usize = PRIVATE_KEY_REGION_LEN_OFFSETS
.iter()
.map(|&offset| be_u32_at(key, offset) as usize)
.sum();
let declared =
PRIVATE_KEY_HEADER_FIXED_SIZE + usize::from(be_u16_at(key, TYPE_NAME_LEN_OFFSET)) + regions;
assert_eq!(
declared,
key.len(),
"{case_id}: the declared lengths must add up to the file size"
);
}
fn with_private_key_region(key: &[u8], len_offset: usize, region: &[u8]) -> Vec<u8> {
use crate::key::private::{PRIVATE_KEY_HEADER_FIXED_SIZE, TYPE_NAME_LEN_OFFSET};
let mut start =
PRIVATE_KEY_HEADER_FIXED_SIZE + usize::from(be_u16_at(key, TYPE_NAME_LEN_OFFSET));
for offset in PRIVATE_KEY_REGION_LEN_OFFSETS {
let len = be_u32_at(key, offset) as usize;
if offset == len_offset {
let mut out = key[..start].to_vec();
let region_len = u32::try_from(region.len()).expect("a region length fits its field");
write_u32_be(&mut out, len_offset, region_len);
out.extend_from_slice(region);
out.extend_from_slice(&key[start + len..]);
return out;
}
start += len;
}
panic!("offset {len_offset} is not the length field of a private.key region")
}
fn write_private_key_cases(corpus: &mut Corpus, keys: &CorpusKeys) {
use crate::key::private::{
EXT_LEN_OFFSET, KEY_FLAGS_OFFSET, KIND_OFFSET, PRIVATE_KEY_EXT_LEN_MAX,
PRIVATE_KEY_HEADER_FIXED_SIZE, PRIVATE_KEY_PUBLIC_LEN_MAX,
PRIVATE_KEY_WRAPPED_SECRET_LEN_MAX, PRIVATE_KEY_WRAPPED_SECRET_LEN_MIN, PUBLIC_LEN_OFFSET,
TYPE_NAME_LEN_OFFSET, VERSION_OFFSET, WRAPPED_SECRET_LEN_OFFSET,
};
let canonical = fs::read(corpus.root.join(&keys.private_a)).expect("read private key");
let mutations: [ByteMutationCase; 9] = [
(
"private-key-bad-magic",
Box::new(|b: &mut Vec<u8>| break_magic(b)),
"private_key_magic_mismatch",
"not_a_key_file",
),
(
"private-key-version-zero",
Box::new(|b: &mut Vec<u8>| b[VERSION_OFFSET] = RESERVED_VERSION),
"private_key_version_reserved_zero",
"malformed_private_key",
),
(
"private-key-wrong-kind",
Box::new(|b: &mut Vec<u8>| b[KIND_OFFSET] = crate::format::KIND_ENCRYPTED),
"private_key_kind_not_private_key",
"wrong_kind",
),
(
"private-key-key-flags-nonzero",
Box::new(|b: &mut Vec<u8>| write_u16_be(b, KEY_FLAGS_OFFSET, RESERVED_FLAG_BIT)),
"private_key_flags_nonzero",
"malformed_private_key",
),
(
"private-key-type-name-len-zero",
Box::new(|b: &mut Vec<u8>| write_u16_be(b, TYPE_NAME_LEN_OFFSET, 0)),
"private_key_type_name_len_zero",
"malformed_private_key",
),
(
"private-key-type-name-not-utf8",
Box::new(|b: &mut Vec<u8>| b[PRIVATE_KEY_HEADER_FIXED_SIZE] = 0xFF),
"private_key_type_name_not_utf8",
"malformed_type_name",
),
(
"private-key-truncated",
Box::new(|b: &mut Vec<u8>| {
b.truncate(40);
}),
"private_key_ends_before_declared_fields",
"malformed_private_key",
),
(
"private-key-trailing-data",
Box::new(|b: &mut Vec<u8>| b.extend_from_slice(&[FILLER; 8])),
"private_key_bytes_follow_declared_fields",
"malformed_private_key",
),
(
"private-key-kdf-memory-above-max",
Box::new(|b: &mut Vec<u8>| set_kdf_memory_above_max(b)),
"private_key_argon2id_memory_cost_above_structural_maximum",
"invalid_kdf_parameters",
),
];
for (case_id, mutate, condition, class) in mutations {
let mut bytes = canonical.clone();
mutate(&mut bytes);
assert_ne!(bytes, canonical, "{case_id}: mutation changed nothing");
private_key_case(corpus, case_id, &bytes, condition, class);
}
let unsupported = with_private_key_type(&canonical, UNSUPPORTED_KEY_TYPE);
let row = private_key_row(corpus, "private-key-unsupported-type", &unsupported);
unsupported_key_type_case(
corpus,
row,
UNSUPPORTED_KEY_TYPE,
"private_key_type_not_supported",
);
let ungrammatical = with_private_key_type(&canonical, &ungrammatical_type_name());
assert_private_key_lengths_fit("private-key-type-name-malformed", &ungrammatical);
private_key_case(
corpus,
"private-key-type-name-malformed",
&ungrammatical,
"private_key_type_name_violates_grammar",
"malformed_type_name",
);
let length_limits: [(&str, usize, u32, &str); 4] = [
(
"private-key-public-len-above-max",
PUBLIC_LEN_OFFSET,
PRIVATE_KEY_PUBLIC_LEN_MAX + 1,
"private_key_public_len_above_structural_maximum",
),
(
"private-key-ext-len-above-max",
EXT_LEN_OFFSET,
PRIVATE_KEY_EXT_LEN_MAX + 1,
"private_key_ext_len_above_structural_maximum",
),
(
"private-key-wrapped-secret-below-min",
WRAPPED_SECRET_LEN_OFFSET,
PRIVATE_KEY_WRAPPED_SECRET_LEN_MIN - 1,
"private_key_wrapped_secret_len_below_structural_minimum",
),
(
"private-key-wrapped-secret-above-max",
WRAPPED_SECRET_LEN_OFFSET,
PRIVATE_KEY_WRAPPED_SECRET_LEN_MAX + 1,
"private_key_wrapped_secret_len_above_structural_maximum",
),
];
for (case_id, offset, value, condition) in length_limits {
let mut bytes = unsupported.clone();
write_u32_be(&mut bytes, offset, value);
private_key_case(corpus, case_id, &bytes, condition, "malformed_private_key");
}
let longest = longest_unsupported_key_type();
let above_max = with_private_key_type(&canonical, &format!("{longest}k"));
assert_private_key_lengths_fit("private-key-type-name-len-above-max", &above_max);
private_key_case(
corpus,
"private-key-type-name-len-above-max",
&above_max,
"private_key_type_name_len_above_structural_maximum",
"malformed_private_key",
);
let extension = ignorable_tlv_region(PRIVATE_KEY_EXT_LEN_MAX as usize, FILLER);
let on_limits = [
(
"private-key-type-name-len-at-max",
with_private_key_type(&canonical, &longest),
longest.as_str(),
"private_key_type_name_len_at_structural_maximum_and_type_not_supported",
),
(
"private-key-public-len-at-max",
with_private_key_region(
&unsupported,
PUBLIC_LEN_OFFSET,
&vec![FILLER; PRIVATE_KEY_PUBLIC_LEN_MAX as usize],
),
UNSUPPORTED_KEY_TYPE,
"private_key_public_len_at_structural_maximum_and_type_not_supported",
),
(
"private-key-ext-len-at-max",
with_private_key_region(&unsupported, EXT_LEN_OFFSET, &extension),
UNSUPPORTED_KEY_TYPE,
"private_key_ext_len_at_structural_maximum_and_type_not_supported",
),
(
"private-key-wrapped-secret-at-max",
with_private_key_region(
&unsupported,
WRAPPED_SECRET_LEN_OFFSET,
&vec![FILLER; PRIVATE_KEY_WRAPPED_SECRET_LEN_MAX as usize],
),
UNSUPPORTED_KEY_TYPE,
"private_key_wrapped_secret_len_at_structural_maximum_and_type_not_supported",
),
(
"private-key-wrapped-secret-at-min",
with_private_key_region(
&unsupported,
WRAPPED_SECRET_LEN_OFFSET,
&vec![FILLER; PRIVATE_KEY_WRAPPED_SECRET_LEN_MIN as usize],
),
UNSUPPORTED_KEY_TYPE,
"private_key_wrapped_secret_len_at_structural_minimum_and_type_not_supported",
),
(
"private-key-lengths-at-min",
[
(PUBLIC_LEN_OFFSET, Vec::new()),
(EXT_LEN_OFFSET, Vec::new()),
(
WRAPPED_SECRET_LEN_OFFSET,
vec![FILLER; PRIVATE_KEY_WRAPPED_SECRET_LEN_MIN as usize],
),
]
.into_iter()
.fold(
with_private_key_type(&canonical, SHORTEST_TYPE_NAME),
|key, (len_offset, region)| with_private_key_region(&key, len_offset, ®ion),
),
SHORTEST_TYPE_NAME,
"private_key_type_name_len_and_region_lengths_at_structural_minima_and_type_not_supported",
),
];
for (case_id, bytes, type_name, condition) in on_limits {
assert_private_key_lengths_fit(case_id, &bytes);
let row = private_key_row(corpus, case_id, &bytes);
unsupported_key_type_case(corpus, row, type_name, condition);
}
for (case_id, len_offset, region_len, condition) in [
(
"private-key-x25519-public-len-short",
PUBLIC_LEN_OFFSET,
x25519::PUBLIC_KEY_SIZE - 1,
"private_key_x25519_public_material_not_32_bytes",
),
(
"private-key-x25519-public-len-long",
PUBLIC_LEN_OFFSET,
x25519::PUBLIC_KEY_SIZE + 1,
"private_key_x25519_public_material_not_32_bytes",
),
(
"private-key-x25519-secret-len-short",
WRAPPED_SECRET_LEN_OFFSET,
x25519::WRAPPED_SECRET_LEN - 1,
"private_key_x25519_secret_material_not_32_bytes",
),
(
"private-key-x25519-secret-len-long",
WRAPPED_SECRET_LEN_OFFSET,
x25519::WRAPPED_SECRET_LEN + 1,
"private_key_x25519_secret_material_not_32_bytes",
),
] {
let bytes = with_private_key_region(&canonical, len_offset, &vec![FILLER; region_len]);
assert_private_key_lengths_fit(case_id, &bytes);
private_key_case(corpus, case_id, &bytes, condition, "malformed_private_key");
}
let mut newer = canonical.clone();
newer[VERSION_OFFSET] = NEWER_VERSION;
let row = private_key_row(corpus, "private-key-newer-version", &newer)
.capability(&newer_version_capability(
wire_manifest::PRIVATE_KEY_VERSION_DOMAIN,
))
.reject(
"private_key_version_unsupported",
"unsupported_private_key_version",
);
corpus.push_case(row);
let mut newer_empty_type = with_private_key_type(&canonical, "");
newer_empty_type[VERSION_OFFSET] = NEWER_VERSION;
private_key_case_through_both_readers(
corpus,
"newer-version-type-name-len-zero",
&newer_empty_type,
Some(&newer_version_capability(
wire_manifest::PRIVATE_KEY_VERSION_DOMAIN,
)),
"private_key_version_unsupported_and_type_name_len_zero",
"unsupported_private_key_version",
);
let mut short_newer = canonical[..PRIVATE_KEY_HEADER_FIXED_SIZE - 1].to_vec();
short_newer[VERSION_OFFSET] = NEWER_VERSION;
private_key_case(
corpus,
"private-key-newer-version-shorter-than-fixed-header",
&short_newer,
"private_key_shorter_than_fixed_header_with_newer_version",
"malformed_private_key",
);
private_key_case_through_both_readers(
corpus,
"given-public-key-file",
&keys.public_a,
None,
"private_key_reader_given_public_key_file",
"wrong_key_file_type",
);
for (suffix, bytes, condition, class) in public_key_look_alikes(keys) {
private_key_case_through_both_readers(
corpus,
&format!("given-{suffix}"),
&bytes,
None,
condition,
class,
);
}
let opened = open_corpus_private_key(corpus, keys);
write_private_key_ext_and_pair_cases(corpus, keys, &canonical, &opened);
let material = decode_public_key_file(&keys.public_a)
.to_x25519_bytes()
.expect("decode paired public key");
private_key_open_case(
corpus,
"private-key-open-canonical",
&canonical,
"private-key-a",
Ok(material),
);
private_key_open_case(
corpus,
"private-key-wrong-passphrase",
&canonical,
"private-key-a-wrong-unlock",
Err((
"private_key_wrong_unlock_passphrase",
"private_key_unlock_failed",
)),
);
let mut aad_tampered = canonical.clone();
let public_material_at =
PRIVATE_KEY_HEADER_FIXED_SIZE + usize::from(be_u16_at(&canonical, TYPE_NAME_LEN_OFFSET));
aad_tampered[public_material_at] ^= 0x01;
private_key_open_case(
corpus,
"private-key-cleartext-aad-tampered",
&aad_tampered,
"private-key-a",
Err((
"private_key_cleartext_aad_modified",
"private_key_unlock_failed",
)),
);
let mut secret_tampered = canonical.clone();
let last = secret_tampered.len() - 1;
secret_tampered[last] ^= 0x01;
private_key_open_case(
corpus,
"private-key-wrapped-secret-tampered",
&secret_tampered,
"private-key-a",
Err((
"private_key_wrapped_secret_modified",
"private_key_unlock_failed",
)),
);
write_private_key_unlock_check_cases(corpus, &canonical, &opened);
write_private_key_order_cases(corpus, &canonical, &opened);
}
type PublicKeyLookAlike = (&'static str, Vec<u8>, &'static str, &'static str);
fn public_key_look_alikes(keys: &CorpusKeys) -> [PublicKeyLookAlike; 5] {
use crate::format::RECIPIENT_STRING_PREFIX;
use crate::key::private::PRIVATE_KEY_HEADER_FIXED_SIZE;
use crate::key::public::encode_recipient_string_with_version;
let canonical = &keys.public_a;
let recipient = std::str::from_utf8(canonical)
.expect("public.key is UTF-8")
.trim_end_matches('\n');
let material = decode_public_key_file(canonical)
.to_x25519_bytes()
.expect("decode the canonical public key");
let newer = encode_recipient_string_with_version(NEWER_VERSION, x25519::TYPE_NAME, &material)
.expect("encode a newer public-key version");
[
(
"newer-public-key-file",
format!("{newer}\n").into_bytes(),
"private_key_reader_given_public_key_file_of_newer_version",
"wrong_key_file_type",
),
(
"public-key-file-with-corrupted-checksum",
format!("{}\n", with_corrupted_checksum(recipient)).into_bytes(),
"private_key_reader_given_public_key_file_with_corrupted_checksum",
"wrong_key_file_type",
),
(
"recipient-string-prefix-alone",
RECIPIENT_STRING_PREFIX.to_vec(),
"private_key_reader_given_recipient_string_prefix_alone",
"wrong_key_file_type",
),
(
"recipient-string-prefix-then-bytes-not-utf8",
[
RECIPIENT_STRING_PREFIX,
&[FILLER; PRIVATE_KEY_HEADER_FIXED_SIZE],
]
.concat(),
"private_key_reader_given_recipient_string_prefix_then_bytes_not_utf8",
"wrong_key_file_type",
),
(
"public-key-file-with-leading-space",
[&b" "[..], canonical].concat(),
"private_key_reader_given_public_key_file_with_leading_space",
"not_a_key_file",
),
]
}
fn write_private_key_unlock_check_cases(
corpus: &mut Corpus,
canonical: &[u8],
opened: &x25519::OpenedX25519KeyFile,
) {
let trailing = with_trailing_filler(canonical.to_vec());
let before_authentication: [(&str, &[u8], &str, &str); 3] = [
(
"private-key-open-trailing-data",
&trailing,
"private_key_bytes_follow_declared_fields",
"malformed_private_key",
),
(
"private-key-open-truncated",
&canonical[..canonical.len() - 1],
"private_key_ends_before_declared_fields",
"malformed_private_key",
),
(
"private-key-open-type-name-malformed",
&with_private_key_type(canonical, &ungrammatical_type_name()),
"private_key_type_name_violates_grammar",
"malformed_type_name",
),
];
for (case_id, bytes, condition, class) in before_authentication {
private_key_open_case(
corpus,
case_id,
bytes,
"private-key-a",
Err((condition, class)),
);
}
private_key_open_case(
corpus,
"private-key-open-given-long-public-key-file",
format!(
"{}\n",
longest_recipient_string(&longest_recipient_payload())
)
.as_bytes(),
"private-key-a",
Err((
"private_key_reader_given_public_key_file_longer_than_default_recipient_string_cap",
"wrong_key_file_type",
)),
);
let secret = opened.secret.as_slice();
let unsupported = seal_private_key_for_case(
"private-key-open-unsupported-type",
UNSUPPORTED_KEY_TYPE,
secret,
&opened.public,
b"",
);
let row = private_key_open_row(
corpus,
"private-key-open-unsupported-type",
&unsupported,
"private-key-a",
);
unsupported_key_type_case(
corpus,
row,
UNSUPPORTED_KEY_TYPE,
"private_key_type_not_supported",
);
let public = opened.public.as_slice();
let public_long = [public, &[FILLER]].concat();
let secret_long = [secret, &[FILLER]].concat();
for (case_id, secret_material, public_material, condition) in [
(
"private-key-open-x25519-public-len-short",
secret,
&public[..x25519::PUBLIC_KEY_SIZE - 1],
"private_key_x25519_public_material_not_32_bytes",
),
(
"private-key-open-x25519-public-len-long",
secret,
public_long.as_slice(),
"private_key_x25519_public_material_not_32_bytes",
),
(
"private-key-open-x25519-secret-len-short",
&secret[..x25519::PRIVATE_KEY_SIZE - 1],
public,
"private_key_x25519_secret_material_not_32_bytes",
),
(
"private-key-open-x25519-secret-len-long",
secret_long.as_slice(),
public,
"private_key_x25519_secret_material_not_32_bytes",
),
] {
let key = seal_private_key_for_case(
case_id,
x25519::TYPE_NAME,
secret_material,
public_material,
b"",
);
private_key_open_case(
corpus,
case_id,
&key,
"private-key-a",
Err((condition, "malformed_private_key")),
);
}
}
fn private_key_case_through_both_readers(
corpus: &mut Corpus,
suffix: &str,
bytes: &[u8],
capability_id: Option<&str>,
condition_id: &str,
diagnostic_class: &str,
) {
let rows = [
private_key_row(corpus, &format!("private-key-{suffix}"), bytes),
private_key_open_row(
corpus,
&format!("private-key-open-{suffix}"),
bytes,
"private-key-a",
),
];
for row in rows {
corpus.push_case(
row.capability_if_any(capability_id)
.reject(condition_id, diagnostic_class),
);
}
}
fn write_private_key_order_cases(
corpus: &mut Corpus,
canonical: &[u8],
opened: &x25519::OpenedX25519KeyFile,
) {
use crate::key::private::{
KEY_FLAGS_OFFSET, KIND_OFFSET, PRIVATE_KEY_HEADER_FIXED_SIZE,
PRIVATE_KEY_WRAPPED_SECRET_LEN_MAX, PUBLIC_LEN_OFFSET, VERSION_OFFSET,
WRAPPED_SECRET_LEN_OFFSET,
};
let mutated = |mutate: &dyn Fn(&mut Vec<u8>)| {
let mut bytes = canonical.to_vec();
mutate(&mut bytes);
assert_ne!(bytes, canonical, "mutation changed nothing");
bytes
};
let declare_encrypted_kind = |b: &mut Vec<u8>| b[KIND_OFFSET] = crate::format::KIND_ENCRYPTED;
let declare_newer_version = |b: &mut Vec<u8>| b[VERSION_OFFSET] = NEWER_VERSION;
let declare_reserved_version = |b: &mut Vec<u8>| b[VERSION_OFFSET] = RESERVED_VERSION;
let kdf_memory_above_max = |b: &mut Vec<u8>| set_kdf_memory_above_max(b);
let wrapped_secret_len_above_max = |b: &mut Vec<u8>| {
write_u32_be(
b,
WRAPPED_SECRET_LEN_OFFSET,
PRIVATE_KEY_WRAPPED_SECRET_LEN_MAX + 1,
)
};
let both_readers: [(&str, Vec<u8>, &str, &str); 10] = [
(
"order-size-before-magic",
mutated(&|b| {
b.truncate(PRIVATE_KEY_HEADER_FIXED_SIZE - 1);
break_magic(b);
}),
"private_key_shorter_than_fixed_header_and_magic_mismatch",
"malformed_private_key",
),
(
"order-magic-before-kind",
mutated(&|b| {
break_magic(b);
declare_encrypted_kind(b);
}),
"private_key_magic_mismatch_and_kind_not_private_key",
"not_a_key_file",
),
(
"order-kind-before-version",
mutated(&|b| {
declare_encrypted_kind(b);
declare_newer_version(b);
}),
"private_key_kind_not_private_key_and_version_unsupported",
"wrong_kind",
),
(
"order-kind-before-flags",
mutated(&|b| {
declare_encrypted_kind(b);
write_u16_be(b, KEY_FLAGS_OFFSET, RESERVED_FLAG_BIT);
}),
"private_key_kind_not_private_key_and_flags_nonzero",
"wrong_kind",
),
(
"order-kind-before-lengths",
mutated(&|b| {
declare_encrypted_kind(b);
wrapped_secret_len_above_max(b);
}),
"private_key_kind_not_private_key_and_wrapped_secret_len_above_structural_maximum",
"wrong_kind",
),
(
"order-kind-before-version-zero",
mutated(&|b| {
declare_encrypted_kind(b);
declare_reserved_version(b);
}),
"private_key_kind_not_private_key_and_version_reserved_zero",
"wrong_kind",
),
(
"order-version-zero-before-kdf-parameters",
mutated(&|b| {
declare_reserved_version(b);
kdf_memory_above_max(b);
}),
"private_key_version_reserved_zero_and_argon2id_memory_cost_above_structural_maximum",
"malformed_private_key",
),
(
"order-flags-before-kdf-parameters",
mutated(&|b| {
write_u16_be(b, KEY_FLAGS_OFFSET, RESERVED_FLAG_BIT);
kdf_memory_above_max(b);
}),
"private_key_flags_nonzero_and_argon2id_memory_cost_above_structural_maximum",
"malformed_private_key",
),
(
"order-lengths-before-kdf-parameters",
mutated(&|b| {
wrapped_secret_len_above_max(b);
kdf_memory_above_max(b);
}),
"private_key_wrapped_secret_len_above_structural_maximum_and_argon2id_memory_cost_above_structural_maximum",
"malformed_private_key",
),
(
"order-file-length-before-type-name",
with_trailing_filler(with_private_key_type(canonical, &ungrammatical_type_name())),
"private_key_bytes_follow_declared_fields_and_type_name_violates_grammar",
"malformed_private_key",
),
];
for (suffix, bytes, condition, class) in both_readers {
private_key_case_through_both_readers(corpus, suffix, &bytes, None, condition, class);
}
private_key_case_through_both_readers(
corpus,
"order-version-before-flags",
&mutated(&|b| {
declare_newer_version(b);
write_u16_be(b, KEY_FLAGS_OFFSET, RESERVED_FLAG_BIT);
}),
Some(&newer_version_capability(
wire_manifest::PRIVATE_KEY_VERSION_DOMAIN,
)),
"private_key_version_unsupported_and_key_flags_nonzero",
"unsupported_private_key_version",
);
let mut kdf_and_type_rules = with_private_key_region(
canonical,
PUBLIC_LEN_OFFSET,
&opened.public[..x25519::PUBLIC_KEY_SIZE - 1],
);
kdf_memory_above_max(&mut kdf_and_type_rules);
let across_unlock_only_steps = [
(
"order-kdf-parameters-before-file-length",
with_trailing_filler(mutated(&kdf_memory_above_max)),
"private_key_argon2id_memory_cost_above_structural_maximum_and_bytes_follow_declared_fields",
"invalid_kdf_parameters",
),
(
"order-file-length-before-type-support",
with_trailing_filler(with_private_key_type(canonical, UNSUPPORTED_KEY_TYPE)),
"private_key_bytes_follow_declared_fields_and_type_not_supported",
"malformed_private_key",
),
(
"order-kdf-parameters-before-type-rules",
kdf_and_type_rules,
"private_key_argon2id_memory_cost_above_structural_maximum_and_x25519_public_material_not_32_bytes",
"invalid_kdf_parameters",
),
];
for (suffix, bytes, condition, class) in across_unlock_only_steps {
private_key_case_through_both_readers(corpus, suffix, &bytes, None, condition, class);
}
let wrapped_secret_over_cap = |b: &mut Vec<u8>| {
write_u32_be(
b,
WRAPPED_SECRET_LEN_OFFSET,
KeyReadLimits::PRIVATE_KEY_WRAPPED_SECRET_LEN_DEFAULT + 1,
)
};
let unlock_cases: [(&str, Vec<u8>, &str, &str); 4] = [
(
"private-key-open-order-kdf-parameters-before-kdf-cap",
mutated(&kdf_memory_above_max),
"private_key_argon2id_memory_cost_above_structural_maximum_and_local_cap",
"invalid_kdf_parameters",
),
(
"private-key-open-order-kdf-parameters-before-wrapped-secret-cap",
mutated(&|b| {
kdf_memory_above_max(b);
wrapped_secret_over_cap(b);
}),
"private_key_argon2id_memory_cost_above_structural_maximum_and_wrapped_secret_over_local_cap",
"invalid_kdf_parameters",
),
(
"private-key-open-order-kdf-cap-before-file-length",
with_trailing_filler(mutated(&|b| {
write_private_key_kdf_params(b, kdf_params_one_past_default_memory_cap())
})),
"private_key_argon2id_memory_above_default_local_cap_and_bytes_follow_declared_fields",
"resource_cap_exceeded",
),
(
"private-key-open-order-wrapped-secret-cap-before-file-length",
mutated(&wrapped_secret_over_cap),
"private_key_wrapped_secret_over_local_cap_and_ends_before_declared_fields",
"resource_cap_exceeded",
),
];
for (case_id, bytes, condition, class) in unlock_cases {
private_key_open_case(
corpus,
case_id,
&bytes,
"private-key-a",
Err((condition, class)),
);
}
private_key_open_case(
corpus,
"private-key-open-order-type-name-before-authentication",
&with_private_key_type(canonical, &ungrammatical_type_name()),
"private-key-a-wrong-unlock",
Err((
"private_key_type_name_violates_grammar_and_wrong_unlock_passphrase",
"malformed_type_name",
)),
);
let public_short = &opened.public[..x25519::PUBLIC_KEY_SIZE - 1];
let sealed_cases = [
(
"private-key-open-order-authentication-before-ext",
x25519::TYPE_NAME,
opened.public.as_slice(),
tlv_value_running_past_region(UNKNOWN_IGNORABLE_TAG),
"private-key-a-wrong-unlock",
"private_key_wrong_unlock_passphrase_and_ext_value_runs_past_region",
"private_key_unlock_failed",
),
(
"private-key-open-order-authentication-before-critical-ext",
x25519::TYPE_NAME,
opened.public.as_slice(),
unknown_critical_tlv_region(),
"private-key-a-wrong-unlock",
"private_key_wrong_unlock_passphrase_and_ext_unknown_critical_tag",
"private_key_unlock_failed",
),
(
"private-key-open-order-ext-before-type-support",
UNSUPPORTED_KEY_TYPE,
opened.public.as_slice(),
tlv_value_running_past_region(UNKNOWN_IGNORABLE_TAG),
"private-key-a",
"private_key_ext_value_runs_past_region_and_type_not_supported",
"malformed_tlv",
),
(
"private-key-open-order-critical-ext-before-type-support",
UNSUPPORTED_KEY_TYPE,
opened.public.as_slice(),
unknown_critical_tlv_region(),
"private-key-a",
"private_key_ext_unknown_critical_tag_and_type_not_supported",
"unknown_critical_tlv",
),
(
"private-key-open-order-ext-structure-before-critical-tag",
x25519::TYPE_NAME,
opened.public.as_slice(),
critical_tag_then_malformed_entry(b"critical"),
"private-key-a",
"private_key_ext_value_runs_past_region_after_unknown_critical_tag",
"malformed_tlv",
),
(
"private-key-open-order-ext-before-type-rules",
x25519::TYPE_NAME,
public_short,
tlv_value_running_past_region(UNKNOWN_IGNORABLE_TAG),
"private-key-a",
"private_key_ext_value_runs_past_region_and_x25519_public_material_not_32_bytes",
"malformed_tlv",
),
(
"private-key-open-order-critical-ext-before-type-rules",
x25519::TYPE_NAME,
public_short,
unknown_critical_tlv_region(),
"private-key-a",
"private_key_ext_unknown_critical_tag_and_x25519_public_material_not_32_bytes",
"unknown_critical_tlv",
),
];
for (case_id, type_name, public_material, ext_bytes, credential, condition, class) in
sealed_cases
{
let key = seal_private_key_for_case(
case_id,
type_name,
opened.secret.as_slice(),
public_material,
&ext_bytes,
);
let row = private_key_open_row(corpus, case_id, &key, credential).reject(condition, class);
corpus.push_case(row);
}
}
struct FcaEntry {
kind: u8,
mode: u16,
path: String,
entry_ext: Vec<u8>,
content: Vec<u8>,
}
impl FcaEntry {
fn file(path: &str, content: &[u8]) -> Self {
Self {
kind: crate::archive::format::KIND_FILE,
mode: SOURCE_FILE_MODE,
path: path.to_string(),
entry_ext: Vec::new(),
content: content.to_vec(),
}
}
fn dir(path: &str) -> Self {
Self {
kind: crate::archive::format::KIND_DIR,
mode: SOURCE_DIR_MODE,
path: path.to_string(),
entry_ext: Vec::new(),
content: Vec::new(),
}
}
fn with_entry_ext(mut self, ext: &[u8]) -> Self {
self.entry_ext = ext.to_vec();
self
}
fn wire_len(&self) -> usize {
crate::archive::format::FCA_ENTRY_FIXED_SIZE + self.path.len() + self.entry_ext.len()
}
}
fn extraction_listing(entries: &[FcaEntry]) -> Vec<u8> {
let mut ordered: Vec<&FcaEntry> = entries.iter().collect();
ordered.sort_by(|a, b| {
let depth = |e: &FcaEntry| e.path.split('/').count();
depth(a).cmp(&depth(b)).then_with(|| a.path.cmp(&b.path))
});
let mut out = String::new();
for entry in ordered {
if entry.kind == crate::archive::format::KIND_DIR {
out.push_str(&format!("d - - {}\n", entry.path));
} else {
out.push_str(&format!(
"f {} {} {}\n",
entry.content.len(),
sha3_hex(&entry.content),
entry.path
));
}
}
out.into_bytes()
}
const FCA_OFF_VERSION: usize = 4;
const FCA_OFF_FLAGS: usize = FCA_OFF_VERSION + 1;
const FCA_OFF_ENTRY_COUNT: usize = FCA_OFF_FLAGS + 2;
const FCA_OFF_ARCHIVE_EXT_LEN: usize = FCA_OFF_ENTRY_COUNT + 4;
const FCA_OFF_MANIFEST_LEN: usize = FCA_OFF_ARCHIVE_EXT_LEN + 4;
const FCA_OFF_TOTAL_FILE_BYTES: usize = {
let at = FCA_OFF_MANIFEST_LEN + 4;
assert!(at + 8 == crate::archive::format::FCA_HEADER_SIZE);
at
};
fn build_fca(entries: &[FcaEntry], archive_ext: &[u8]) -> Vec<u8> {
let manifest_len: usize = entries.iter().map(FcaEntry::wire_len).sum();
let total_file_bytes: u64 = entries.iter().map(|e| e.content.len() as u64).sum();
let mut out = Vec::new();
out.extend_from_slice(b"FCA\0");
out.push(0x01);
out.extend_from_slice(&0u16.to_be_bytes());
out.extend_from_slice(&(entries.len() as u32).to_be_bytes());
out.extend_from_slice(&(archive_ext.len() as u32).to_be_bytes());
out.extend_from_slice(&(manifest_len as u32).to_be_bytes());
out.extend_from_slice(&total_file_bytes.to_be_bytes());
assert_eq!(out.len(), crate::archive::format::FCA_HEADER_SIZE);
out.extend_from_slice(archive_ext);
for entry in entries {
out.push(entry.kind);
out.push(0); out.extend_from_slice(&entry.mode.to_be_bytes());
out.extend_from_slice(&(entry.path.len() as u16).to_be_bytes());
out.extend_from_slice(&(entry.entry_ext.len() as u32).to_be_bytes());
out.extend_from_slice(&(entry.content.len() as u64).to_be_bytes());
out.extend_from_slice(entry.path.as_bytes());
out.extend_from_slice(&entry.entry_ext);
}
for entry in entries {
out.extend_from_slice(&entry.content);
}
out
}
fn fca_case(
corpus: &mut Corpus,
case_id: &str,
fca: &[u8],
outcome: Result<Vec<u8>, (&str, &str)>,
) {
fca_case_with_capability(corpus, case_id, fca, None, outcome);
}
fn fca_capability_case(
corpus: &mut Corpus,
case_id: &str,
fca: &[u8],
capability_id: &str,
condition_id: &str,
diagnostic_class: &str,
) {
fca_case_with_capability(
corpus,
case_id,
fca,
Some(capability_id),
Err((condition_id, diagnostic_class)),
);
}
fn fca_case_with_capability(
corpus: &mut Corpus,
case_id: &str,
fca: &[u8],
capability_id: Option<&str>,
outcome: Result<Vec<u8>, (&str, &str)>,
) {
let scope = case_scope(case_id);
let file_key = FileKey::generate().expect("file key");
let entries = [argon2id_entry(&file_key)];
let (built, stream_nonce_hex, payload_key_digest) = craft_header(&file_key, &entries, b"");
let payload = encode_payload(&built.payload_key, &built.stream_nonce, fca, false);
let bytes = assemble_fcr(&built, &payload);
let artifact_ref = corpus.write_ref(&format!("artifacts/fcr/{case_id}.fcr"), &bytes);
let origin_id = format!("origin-{case_id}");
corpus.push_origin(OriginRow {
origin_id: origin_id.clone(),
origin_kind: "fcr_payload",
anchor_case_id: case_id.to_string(),
payload_key_ref: "-".to_string(),
payload_key_sha3_256: payload_key_digest,
stream_nonce_hex,
});
drop(scope);
let row = CaseRow::fcr(case_id, &artifact_ref)
.origin(&origin_id)
.fabricated()
.credential("passphrase-main");
let row = match capability_id {
Some(capability) => row.capability(capability),
None => row,
};
let row = match outcome {
Ok(expected) => {
let expected_ref =
corpus.write_ref(&format!("expected/plaintext/{case_id}.bin"), &expected);
row.accept(&expected_ref)
}
Err((condition, class)) => row.reject(condition, class),
};
corpus.push_case(row);
}
fn nested_chain(components: &[String], content: &[u8]) -> Vec<FcaEntry> {
let mut entries = Vec::with_capacity(components.len());
let mut path = String::new();
for (index, component) in components.iter().enumerate() {
if index > 0 {
path.push('/');
}
path.push_str(component);
if index + 1 == components.len() {
entries.push(FcaEntry::file(&path, content));
} else {
entries.push(FcaEntry::dir(&path));
}
}
entries
}
fn fabricated_accept_fcr_case(
corpus: &mut Corpus,
case_id: &str,
credential_id: &str,
source: &Path,
built: BuiltFcr,
) {
let artifact_ref = corpus.write_ref(&format!("artifacts/fcr/{case_id}.fcr"), &built.bytes);
let expected_ref = corpus.write_ref(
&format!("expected/plaintext/{case_id}.bin"),
&fs::read(source).expect("read source for expected plaintext"),
);
let origin_id = format!("origin-{case_id}");
corpus.push_origin(OriginRow {
origin_id: origin_id.clone(),
origin_kind: "fcr_payload",
anchor_case_id: case_id.to_string(),
payload_key_ref: "-".to_string(),
payload_key_sha3_256: built.payload_key_sha3_256,
stream_nonce_hex: built.stream_nonce_hex,
});
corpus.push_case(
CaseRow::fcr(case_id, &artifact_ref)
.origin(&origin_id)
.fabricated()
.credential(credential_id)
.accept(&expected_ref),
);
}
fn fca_mutated_case(
corpus: &mut Corpus,
case_id: &str,
condition_id: &str,
diagnostic_class: &str,
mutate: impl FnOnce(&mut Vec<u8>),
) {
let mut fca = build_fca(&[FcaEntry::file("p.txt", b"fca payload")], b"");
mutate(&mut fca);
fca_case(corpus, case_id, &fca, Err((condition_id, diagnostic_class)));
}
fn write_fca_cases(corpus: &mut Corpus) {
use crate::crypto::tlv::tlv_bytes;
fca_case(
corpus,
"fca-file-root-valid",
&build_fca(&[FcaEntry::file("p.txt", b"fca payload")], b""),
Ok(b"fca payload".to_vec()),
);
let tree = || {
[
FcaEntry::dir("root"),
FcaEntry::file("root/a.txt", b"first"),
FcaEntry::dir("root/sub"),
FcaEntry::file("root/sub/deep.txt", b"nested content"),
]
};
let canonical = tree();
fca_case(
corpus,
"fca-directory-root-valid",
&build_fca(&canonical, b""),
Ok(extraction_listing(&canonical)),
);
let [root, a, sub, deep] = tree();
let noncanonical = [root, sub, deep, a];
fca_case(
corpus,
"fca-manifest-noncanonical-order-valid",
&build_fca(&noncanonical, b""),
Ok(extraction_listing(&noncanonical)),
);
let header_cases: [ByteMutationCase; 6] = [
(
"fca-bad-magic",
Box::new(|b: &mut Vec<u8>| break_magic(b)),
"fca_magic_mismatch",
"malformed_archive",
),
(
"fca-version-zero",
Box::new(|b: &mut Vec<u8>| b[FCA_OFF_VERSION] = RESERVED_VERSION),
"fca_version_reserved_zero",
"malformed_archive",
),
(
"fca-flags-nonzero",
Box::new(|b: &mut Vec<u8>| write_u16_be(b, FCA_OFF_FLAGS, RESERVED_FLAG_BIT)),
"fca_flags_nonzero",
"malformed_archive",
),
(
"fca-entry-count-zero",
Box::new(|b: &mut Vec<u8>| write_u32_be(b, FCA_OFF_ENTRY_COUNT, 0)),
"fca_entry_count_zero",
"malformed_archive",
),
(
"fca-manifest-len-zero",
Box::new(|b: &mut Vec<u8>| write_u32_be(b, FCA_OFF_MANIFEST_LEN, 0)),
"fca_manifest_len_zero",
"malformed_archive",
),
(
"fca-total-bytes-disagree",
Box::new(|b: &mut Vec<u8>| {
b[FCA_OFF_TOTAL_FILE_BYTES..FCA_OFF_TOTAL_FILE_BYTES + 8]
.copy_from_slice(&999u64.to_be_bytes())
}),
"fca_total_file_bytes_disagrees_with_entries",
"malformed_archive",
),
];
for (case_id, mutate, condition, class) in header_cases {
fca_mutated_case(corpus, case_id, condition, class, mutate);
}
let header_size = crate::archive::format::FCA_HEADER_SIZE;
fca_mutated_case(
corpus,
"fca-fixed-header-truncated",
"fca_fixed_header_truncated",
"malformed_archive",
|b| b.truncate(header_size - 1),
);
let mut cut_in_archive_ext = build_fca(
&[FcaEntry::file("p.txt", b"fca payload")],
&tlv_bytes(UNKNOWN_IGNORABLE_TAG, b"ext"),
);
let archive_ext_end =
header_size + be_u32_at(&cut_in_archive_ext, FCA_OFF_ARCHIVE_EXT_LEN) as usize;
cut_in_archive_ext.truncate(archive_ext_end - 1);
fca_case(
corpus,
"fca-archive-ext-region-truncated",
&cut_in_archive_ext,
Err(("fca_archive_ext_region_truncated", "malformed_archive")),
);
fca_mutated_case(
corpus,
"fca-manifest-region-truncated",
"fca_manifest_region_truncated",
"malformed_archive",
|b| {
let manifest_end = header_size
+ be_u32_at(b, FCA_OFF_ARCHIVE_EXT_LEN) as usize
+ be_u32_at(b, FCA_OFF_MANIFEST_LEN) as usize;
b.truncate(manifest_end - 1);
},
);
let mut newer = build_fca(&[FcaEntry::file("p.txt", b"fca payload")], b"");
newer[crate::archive::format::FCA_MAGIC.len()] = NEWER_VERSION;
fca_capability_case(
corpus,
"fca-newer-version",
&newer,
&newer_version_capability(wire_manifest::FCA_VERSION_DOMAIN),
"fca_version_unsupported",
"unsupported_fca_version",
);
let tree_cases: [(&str, Vec<FcaEntry>, &str, &str); 7] = [
(
"fca-duplicate-paths",
vec![
FcaEntry::dir("root"),
FcaEntry::file("root/a.txt", b"one"),
FcaEntry::file("root/a.txt", b"two"),
],
"fca_duplicate_entry_path",
"invalid_archive_tree",
),
(
"fca-ascii-case-collision",
vec![
FcaEntry::dir("root"),
FcaEntry::file("root/a.txt", b"one"),
FcaEntry::file("root/A.txt", b"two"),
],
"fca_paths_collide_ignoring_ascii_case",
"invalid_archive_tree",
),
(
"fca-nfc-collision",
vec![
FcaEntry::dir("root"),
FcaEntry::file("root/\u{e9}.txt", b"one"),
FcaEntry::file("root/e\u{301}.txt", b"two"),
],
"fca_paths_collide_under_nfc",
"invalid_archive_tree",
),
(
"fca-nfc-and-ascii-case-collision",
vec![
FcaEntry::dir("root"),
FcaEntry::file("root/\u{e9}.TXT", b"one"),
FcaEntry::file("root/e\u{301}.txt", b"two"),
],
"fca_paths_collide_under_nfc_ignoring_ascii_case",
"invalid_archive_tree",
),
(
"fca-missing-parent",
vec![
FcaEntry::dir("root"),
FcaEntry::file("root/sub/a.txt", b"x"),
],
"fca_entry_parent_absent",
"invalid_archive_tree",
),
(
"fca-child-under-file",
vec![
FcaEntry::dir("root"),
FcaEntry::file("root/a.txt", b"x"),
FcaEntry::file("root/a.txt/b.txt", b"y"),
],
"fca_child_under_file_path",
"invalid_archive_tree",
),
(
"fca-multiple-roots",
vec![FcaEntry::dir("one"), FcaEntry::dir("two")],
"fca_more_than_one_top_level_root",
"invalid_archive_tree",
),
];
for (case_id, entries, condition, class) in tree_cases {
fca_case(
corpus,
case_id,
&build_fca(&entries, b""),
Err((condition, class)),
);
}
let path_cases: [(&str, &str, &str); 15] = [
("fca-path-absolute", "/abs.txt", "fca_path_is_absolute"),
(
"fca-path-parent-component",
"a/../b.txt",
"fca_path_has_parent_component",
),
(
"fca-path-current-component",
"a/./b.txt",
"fca_path_has_current_component",
),
(
"fca-path-trailing-slash",
"a.txt/",
"fca_path_has_trailing_separator",
),
(
"fca-path-repeated-slash",
"a//b.txt",
"fca_path_has_repeated_separator",
),
("fca-path-backslash", "a\\b.txt", "fca_path_has_backslash"),
(
"fca-path-windows-reserved-name",
"CON.txt",
"fca_path_uses_windows_device_name",
),
(
"fca-path-trailing-dot",
"name.",
"fca_path_component_ends_with_dot",
),
(
"fca-path-trailing-space",
"name ",
"fca_path_component_ends_with_space",
),
(
"fca-path-windows-reserved-char",
"a:b.txt",
"fca_path_uses_windows_reserved_character",
),
(
"fca-path-ascii-control",
"a\u{1}b.txt",
"fca_path_contains_ascii_control",
),
(
"fca-path-delete-byte",
"a\u{7f}b.txt",
"fca_path_contains_delete",
),
(
"fca-path-c1-control",
"a\u{85}b.txt",
"fca_path_contains_c1_control",
),
(
"fca-path-bidi-override",
"a\u{202e}b.txt",
"fca_path_contains_bidi_control",
),
(
"fca-path-line-separator",
"a\u{2028}b.txt",
"fca_path_contains_line_separator",
),
];
for (case_id, path, condition) in path_cases {
fca_case(
corpus,
case_id,
&build_fca(&[FcaEntry::file(path, b"x")], b""),
Err((condition, "unsafe_archive_path")),
);
}
let over_long = "c".repeat(crate::archive::path::FCA_COMPONENT_MAX_BYTES + 1);
fca_case(
corpus,
"fca-path-component-over-max",
&build_fca(&[FcaEntry::file(&over_long, b"x")], b""),
Err(("fca_path_component_over_max_bytes", "unsafe_archive_path")),
);
let unicode = build_fca(
&[FcaEntry::file("naรฏve-ฮฉ-ๆฅๆฌ.txt", b"unicode content")],
b"",
);
fca_case(
corpus,
"fca-path-valid-unicode",
&unicode,
Ok(b"unicode content".to_vec()),
);
let marked = [
FcaEntry::dir("root"),
FcaEntry::file("root/a\u{200e}b\u{200f}c\u{61c}d.txt", b"marked content"),
];
fca_case(
corpus,
"fca-path-direction-marks-valid",
&build_fca(&marked, b""),
Ok(extraction_listing(&marked)),
);
fca_mutated_case(
corpus,
"fca-entry-mode-above-permission-bits",
"fca_entry_mode_outside_permission_bits",
"malformed_archive",
|b| {
let mode_at = crate::archive::format::FCA_HEADER_SIZE + 2;
write_u16_be(b, mode_at, 0o1777);
},
);
fca_case(
corpus,
"fca-directory-entry-nonzero-size",
&build_fca(
&[FcaEntry::dir("root"), {
let mut entry = FcaEntry::dir("root/sub");
entry.content = b"unreachable".to_vec();
entry
}],
b"",
),
Err(("fca_directory_entry_declares_content", "malformed_archive")),
);
fca_mutated_case(
corpus,
"fca-path-nul-byte",
"fca_path_contains_nul",
"unsafe_archive_path",
|b| {
let path_at = crate::archive::format::FCA_HEADER_SIZE
+ crate::archive::format::FCA_ENTRY_FIXED_SIZE;
b[path_at] = 0x00;
},
);
let entry_ext = |ext: &[u8]| {
build_fca(
&[FcaEntry::file("p.txt", b"fca payload").with_entry_ext(ext)],
b"",
)
};
let archive_ext = |ext: &[u8]| build_fca(&[FcaEntry::file("p.txt", b"fca payload")], ext);
fca_case(
corpus,
"fca-archive-ext-ignorable",
&archive_ext(&tlv_bytes(UNKNOWN_IGNORABLE_TAG, b"archive")),
Ok(b"fca payload".to_vec()),
);
fca_case(
corpus,
"fca-entry-ext-ignorable",
&entry_ext(&tlv_bytes(UNKNOWN_IGNORABLE_TAG, b"entry")),
Ok(b"fca payload".to_vec()),
);
fca_case(
corpus,
"fca-archive-ext-critical",
&archive_ext(&tlv_bytes(UNKNOWN_CRITICAL_TAG, b"archive")),
Err((
"fca_archive_ext_unknown_critical_tag",
"unknown_critical_tlv",
)),
);
fca_case(
corpus,
"fca-entry-ext-critical",
&entry_ext(&tlv_bytes(UNKNOWN_CRITICAL_TAG, b"entry")),
Err(("fca_entry_ext_unknown_critical_tag", "unknown_critical_tlv")),
);
fca_case(
corpus,
"fca-archive-ext-malformed",
&archive_ext(&[0x00, 0x01, 0x00]),
Err(("fca_archive_ext_entry_header_truncated", "malformed_tlv")),
);
fca_case(
corpus,
"fca-entry-ext-malformed",
&entry_ext(&[0x00, 0x01, 0x00]),
Err(("fca_entry_ext_entry_header_truncated", "malformed_tlv")),
);
fca_case(
corpus,
"fca-archive-ext-structure-before-critical-tag",
&archive_ext(&critical_tag_then_malformed_entry(b"archive")),
Err((
"fca_archive_ext_value_runs_past_region_after_unknown_critical_tag",
"malformed_tlv",
)),
);
fca_case(
corpus,
"fca-entry-ext-structure-before-critical-tag",
&entry_ext(&critical_tag_then_malformed_entry(b"entry")),
Err((
"fca_entry_ext_value_runs_past_region_after_unknown_critical_tag",
"malformed_tlv",
)),
);
fca_case(
corpus,
"fca-archive-ext-reserved-tag",
&archive_ext(&tlv_bytes(IGNORABLE_HALF_RESERVED_TAG, b"r")),
Err(("fca_archive_ext_tag_reserved", "malformed_tlv")),
);
fca_case(
corpus,
"fca-entry-ext-reserved-tag",
&entry_ext(&tlv_bytes(IGNORABLE_HALF_RESERVED_TAG, b"r")),
Err(("fca_entry_ext_tag_reserved", "malformed_tlv")),
);
fca_mutated_case(
corpus,
"fca-content-short",
"fca_content_region_ends_before_declared_size",
"malformed_archive",
|b| {
b.pop();
},
);
fca_mutated_case(
corpus,
"fca-content-trailing",
"fca_content_region_has_trailing_bytes",
"malformed_archive",
|b| b.extend_from_slice(b"extra"),
);
fca_case(
corpus,
"fca-entry-kind-invalid",
&build_fca(
&[{
let mut e = FcaEntry::file("p.txt", b"x");
e.kind = 0x07;
e
}],
b"",
),
Err(("fca_entry_kind_unknown", "malformed_archive")),
);
}
fn write_resource_policy_cases(corpus: &mut Corpus, keys: &CorpusKeys) {
let staging = tempfile::tempdir().expect("source dir");
let source = write_source(staging.path(), SOURCE_FILE_NAME, 32);
let depth_cap = ArchiveLimits::PATH_DEPTH_DEFAULT as usize;
let deep: String = std::iter::repeat_n("d", depth_cap + 1)
.collect::<Vec<_>>()
.join("/");
fca_case(
corpus,
"fca-path-depth-over-default-cap",
&build_fca(&[FcaEntry::file(&deep, b"x")], b""),
Err(("fca_path_depth_above_default_cap", "resource_cap_exceeded")),
);
let at_depth = nested_chain(&vec!["d".to_string(); depth_cap], b"x");
fca_case(
corpus,
"fca-path-depth-at-default-cap",
&build_fca(&at_depth, b""),
Ok(extraction_listing(&at_depth)),
);
let bytes_cap = ArchiveLimits::PATH_BYTES_DEFAULT as usize;
let component = "c".repeat(200);
let full_components = (bytes_cap + 1) / (component.len() + 1);
let last = "c".repeat((bytes_cap + 1) % (component.len() + 1));
let long = format!("{component}/").repeat(full_components) + &last;
assert!(
long.len() == bytes_cap + 1 && !last.is_empty() && full_components < depth_cap,
"the path must be one byte past the byte cap and inside the depth cap"
);
fca_case(
corpus,
"fca-path-bytes-over-default-cap",
&build_fca(&[FcaEntry::file(&long, b"x")], b""),
Err(("fca_path_bytes_above_default_cap", "resource_cap_exceeded")),
);
let region_cap = ArchiveLimits::ARCHIVE_EXT_BYTES_DEFAULT as usize;
assert_eq!(
region_cap,
ArchiveLimits::ENTRY_EXT_BYTES_DEFAULT as usize,
"one oversized region serves both caps only while they agree"
);
let oversized = ignorable_tlv_region(region_cap + 1, FCA_EXT_FILLER);
let at_cap = ignorable_tlv_region(region_cap, FCA_EXT_FILLER);
fca_case(
corpus,
"fca-archive-ext-over-default-cap",
&build_fca(&[FcaEntry::file("p.txt", b"x")], &oversized),
Err((
"fca_archive_ext_bytes_above_default_cap",
"resource_cap_exceeded",
)),
);
fca_case(
corpus,
"fca-archive-ext-at-default-cap",
&build_fca(&[FcaEntry::file("p.txt", b"fca payload")], &at_cap),
Ok(b"fca payload".to_vec()),
);
fca_case(
corpus,
"fca-entry-ext-over-default-cap",
&build_fca(
&[FcaEntry::file("p.txt", b"x").with_entry_ext(&oversized)],
b"",
),
Err((
"fca_entry_ext_bytes_above_default_cap",
"resource_cap_exceeded",
)),
);
fca_case(
corpus,
"fca-entry-ext-at-default-cap",
&build_fca(
&[FcaEntry::file("p.txt", b"fca payload").with_entry_ext(&at_cap)],
b"",
),
Ok(b"fca payload".to_vec()),
);
let header_caps: [ByteMutationCase; 3] = [
(
"fca-entry-count-over-default-cap",
Box::new(|b: &mut Vec<u8>| {
write_u32_be(
b,
FCA_OFF_ENTRY_COUNT,
ArchiveLimits::ENTRY_COUNT_DEFAULT + 1,
)
}),
"fca_entry_count_above_default_cap",
"resource_cap_exceeded",
),
(
"fca-manifest-len-over-default-cap",
Box::new(|b: &mut Vec<u8>| {
write_u32_be(
b,
FCA_OFF_MANIFEST_LEN,
ArchiveLimits::MANIFEST_BYTES_DEFAULT + 1,
)
}),
"fca_manifest_len_above_default_cap",
"resource_cap_exceeded",
),
(
"fca-total-bytes-over-default-cap",
Box::new(|b: &mut Vec<u8>| {
b[FCA_OFF_TOTAL_FILE_BYTES..FCA_OFF_TOTAL_FILE_BYTES + 8].copy_from_slice(
&(ArchiveLimits::TOTAL_PLAINTEXT_BYTES_DEFAULT + 1).to_be_bytes(),
)
}),
"fca_total_file_bytes_above_default_cap",
"resource_cap_exceeded",
),
];
for (case_id, mutate, condition, class) in header_caps {
fca_mutated_case(corpus, case_id, condition, class, mutate);
}
let count_cap = crate::HeaderReadLimits::RECIPIENT_COUNT_DEFAULT;
let scope = case_scope("header-recipient-count-over-default-cap");
let file_key = FileKey::generate().expect("file key");
let many: Vec<RecipientEntry> = (0..count_cap + 1)
.map(|_| x25519_entry(&keys.public_a, &file_key))
.collect();
let built = build_fcr_with_entries(&source, &file_key, &many, b"");
drop(scope);
commit_crafted_reject(
corpus,
"header-recipient-count-over-default-cap",
"private-key-a",
None,
"recipient_count_above_default_cap",
"resource_cap_exceeded",
built,
);
let scope = case_scope("header-recipient-count-at-default-cap");
let file_key = FileKey::generate().expect("file key");
let mut at_count: Vec<RecipientEntry> = (0..count_cap - 1)
.map(|_| x25519_entry(&keys.public_b, &file_key))
.collect();
at_count.push(x25519_entry(&keys.public_a, &file_key));
assert_eq!(at_count.len(), count_cap as usize, "exactly the count cap");
let built = build_fcr_with_entries(&source, &file_key, &at_count, b"");
drop(scope);
fabricated_accept_fcr_case(
corpus,
"header-recipient-count-at-default-cap",
"private-key-a",
&source,
built,
);
let body_cap = crate::HeaderReadLimits::RECIPIENT_BODY_LEN_DEFAULT as usize;
let scope = case_scope("recipient-body-over-default-cap");
let file_key = FileKey::generate().expect("file key");
let mut big_body = unknown_entry(false);
big_body.body = vec![FILLER; body_cap + 1];
let entries = [big_body, argon2id_entry(&file_key)];
let built = build_fcr_with_entries(&source, &file_key, &entries, b"");
drop(scope);
commit_crafted_reject(
corpus,
"recipient-body-over-default-cap",
"passphrase-main",
None,
"recipient_body_len_above_default_cap",
"resource_cap_exceeded",
built,
);
let scope = case_scope("recipient-body-at-default-cap");
let file_key = FileKey::generate().expect("file key");
let mut at_body = unknown_entry(false);
at_body.body = vec![FILLER; body_cap];
let entries = [at_body, x25519_entry(&keys.public_a, &file_key)];
let built = build_fcr_with_entries(&source, &file_key, &entries, b"");
drop(scope);
fabricated_accept_fcr_case(
corpus,
"recipient-body-at-default-cap",
"private-key-a",
&source,
built,
);
}
fn write_lowered_limit_cases(
corpus: &mut Corpus,
sources: &Path,
keys: &CorpusKeys,
base: &MutationBase,
) {
write_small_artifact_cap_cases(corpus, keys, base);
write_lowered_cap_cases(corpus, sources, keys);
}
fn write_small_artifact_cap_cases(corpus: &mut Corpus, keys: &CorpusKeys, base: &MutationBase) {
use crate::key::private::{KDF_PARAMS_OFFSET, WRAPPED_SECRET_LEN_OFFSET};
use crate::key::public::{
encode_recipient_groups_for_tests, non_canonical_padding_groups_for_tests,
recipient_payload_for_tests,
};
let caps: BTreeMap<&str, u64> = small_artifact_caps().into_iter().collect();
let private_key = fs::read(corpus.root.join(&keys.private_a)).expect("read private key");
let plaintext = fs::read(
corpus
.root
.join(format!("expected/plaintext/{}.bin", base.case_id)),
)
.expect("read the mutation base's plaintext");
let recipient = std::str::from_utf8(&keys.public_a)
.expect("public.key is UTF-8")
.trim_end_matches('\n');
let material = decode_public_key_file(&keys.public_a)
.to_x25519_bytes()
.expect("decode the corpus public key");
let body_kdf_params = OFF_FIRST_ARGON2ID_KDF_PARAMS;
let key_kdf_params = KDF_PARAMS_OFFSET;
let kdf = KdfParams::test_fast_default();
assert_eq!(
u64::from(be_u32_at(&base.bytes, OFF_HEADER_LEN)),
caps["max_header_len"]
);
assert_eq!(plaintext.len() as u64, caps["max_total_plaintext_bytes"]);
assert_eq!(recipient.len() as u64, caps["max_recipient_string_chars"]);
assert_eq!(
u64::from(be_u32_at(&private_key, WRAPPED_SECRET_LEN_OFFSET)),
caps["max_private_key_wrapped_secret_len"]
);
for stored in [
&base.bytes[body_kdf_params..body_kdf_params + KDF_PARAMS_SIZE],
&private_key[key_kdf_params..key_kdf_params + KDF_PARAMS_SIZE],
] {
assert_eq!(
stored,
kdf.to_bytes(),
"the artifacts store the fast parameters"
);
}
let over_time = KdfParams {
mem_cost: kdf.mem_cost / 2,
time_cost: kdf.time_cost * 2,
..kdf
};
let over_lanes = KdfParams {
lanes: kdf.lanes + 1,
..kdf
};
for params in [over_time, over_lanes] {
let exceeded = [
u64::from(params.mem_cost) > caps["max_kdf_mem_kib"],
u64::from(params.time_cost) > caps["max_kdf_time"],
u64::from(params.lanes) > caps["max_kdf_lanes"],
u64::from(params.mem_cost) * u64::from(params.time_cost) > caps["max_kdf_work"],
];
assert_eq!(exceeded.iter().filter(|over| **over).count(), 1);
}
let payload = recipient_payload_for_tests(
crate::format::WRITER_KEYPAIR_SUITE.public_key_version(),
x25519::TYPE_NAME,
&material,
)
.expect("build the corpus key's recipient payload");
let [_, surplus_group] = non_canonical_padding_groups_for_tests(&payload);
let over_cap_recipient =
encode_recipient_groups_for_tests(&surplus_group).expect("encode a surplus group");
assert_eq!(
over_cap_recipient.len() as u64,
caps["max_recipient_string_chars"] + 1
);
under_limit_profile(corpus, SMALL_ARTIFACT_LIMIT_PROFILE_ID, |corpus| {
let artifact_ref =
corpus.write_ref("artifacts/fcr/fcr-at-small-artifact-caps.fcr", &base.bytes);
let expected_ref = corpus.write_ref(
"expected/plaintext/fcr-at-small-artifact-caps.bin",
&plaintext,
);
corpus.push_case(
CaseRow::fcr("fcr-at-small-artifact-caps", &artifact_ref)
.origin(&base.origin_id)
.credential("passphrase-main")
.accept(&expected_ref),
);
public_key_case(
corpus,
"public-key-at-small-artifact-caps",
&keys.public_a,
Ok(material),
);
private_key_open_case(
corpus,
"private-key-open-at-small-artifact-caps",
&private_key,
"private-key-a",
Ok(material),
);
for (case_id, params, condition) in [
(
"argon2id-kdf-time-over-small-artifact-cap",
over_time,
"argon2id_time_cost_above_local_cap",
),
(
"argon2id-kdf-lanes-over-small-artifact-cap",
over_lanes,
"argon2id_lanes_above_local_cap",
),
] {
mutate_fcr(
corpus,
base,
case_id,
"passphrase-main",
condition,
"resource_cap_exceeded",
|b| {
b[body_kdf_params..body_kdf_params + KDF_PARAMS_SIZE]
.copy_from_slice(¶ms.to_bytes())
},
);
}
for (case_id, params, condition) in [
(
"private-key-kdf-time-over-small-artifact-cap",
over_time,
"private_key_argon2id_time_cost_above_local_cap",
),
(
"private-key-kdf-lanes-over-small-artifact-cap",
over_lanes,
"private_key_argon2id_lanes_above_local_cap",
),
] {
let mut bytes = private_key.clone();
bytes[key_kdf_params..key_kdf_params + KDF_PARAMS_SIZE]
.copy_from_slice(¶ms.to_bytes());
private_key_open_case(
corpus,
case_id,
&bytes,
"private-key-a",
Err((condition, "resource_cap_exceeded")),
);
}
public_key_case(
corpus,
"public-key-recipient-string-over-small-artifact-cap",
over_cap_recipient.as_bytes(),
Err((
"public_key_recipient_string_above_local_cap",
"resource_cap_exceeded",
)),
);
});
}
fn write_lowered_cap_cases(corpus: &mut Corpus, sources: &Path, keys: &CorpusKeys) {
use crate::crypto::tlv::tlv_bytes;
under_limit_profile(corpus, LOWERED_TLV_VALUE_LIMIT_PROFILE_ID, |corpus| {
let content = b"fca payload";
let value =
|len: u64| tlv_bytes(UNKNOWN_IGNORABLE_TAG, &vec![FCA_EXT_FILLER; len as usize]);
let archive_ext = |len| build_fca(&[FcaEntry::file("p.txt", content)], &value(len));
let entry_ext = |len| {
build_fca(
&[FcaEntry::file("p.txt", content).with_entry_ext(&value(len))],
b"",
)
};
let cap = LOWERED_TLV_VALUE_CAP;
fca_case(
corpus,
"fca-archive-ext-tlv-value-over-lowered-cap",
&archive_ext(cap + 1),
Err(("fca_archive_ext_tlv_value_above_local_cap", "malformed_tlv")),
);
fca_case(
corpus,
"fca-archive-ext-tlv-value-at-lowered-cap",
&archive_ext(cap),
Ok(content.to_vec()),
);
fca_case(
corpus,
"fca-entry-ext-tlv-value-over-lowered-cap",
&entry_ext(cap + 1),
Err(("fca_entry_ext_tlv_value_above_local_cap", "malformed_tlv")),
);
fca_case(
corpus,
"fca-entry-ext-tlv-value-at-lowered-cap",
&entry_ext(cap),
Ok(content.to_vec()),
);
});
under_limit_profile(corpus, LOWERED_TOTAL_ENTRY_EXT_LIMIT_PROFILE_ID, |corpus| {
let half = LOWERED_TOTAL_ENTRY_EXT_CAP as usize / 2;
let region = |len: usize| ignorable_tlv_region(len, FCA_EXT_FILLER);
let tree = |file_region: usize| {
[
FcaEntry::dir("root").with_entry_ext(®ion(half)),
FcaEntry::file("root/p.txt", b"fca payload").with_entry_ext(®ion(file_region)),
]
};
fca_case(
corpus,
"fca-total-entry-ext-over-lowered-cap",
&build_fca(&tree(half + 1), b""),
Err((
"fca_total_entry_ext_bytes_above_local_cap",
"resource_cap_exceeded",
)),
);
let at_cap = tree(half);
fca_case(
corpus,
"fca-total-entry-ext-at-lowered-cap",
&build_fca(&at_cap, b""),
Ok(extraction_listing(&at_cap)),
);
});
under_limit_profile(corpus, LOWERED_HEADER_MAC_WORK_LIMIT_PROFILE_ID, |corpus| {
let cap = lowered_header_mac_work_cap();
let case_id = "header-mac-work-at-lowered-cap";
let scope = case_scope(case_id);
let file_key = FileKey::generate().expect("file key");
let opener = x25519_entry(&keys.public_a, &file_key);
let mut padded = unknown_entry(false);
let unpadded = header_mac_work(1, header_len_of(&[padded.clone(), opener.clone()]));
let padding = usize::try_from(cap - unpadded).expect("the padding is small");
padded.body.resize(padded.body.len() + padding, FILLER);
let entries = [padded, opener];
let source = write_source(sources, SOURCE_FILE_NAME, 32);
let built = build_fcr_with_entries(&source, &file_key, &entries, b"");
drop(scope);
assert_eq!(
header_mac_work(1, be_u32_at(&built.bytes, OFF_HEADER_LEN) as usize),
cap,
"the accepted file does exactly the capped work"
);
fabricated_accept_fcr_case(corpus, case_id, "private-key-a", &source, built);
let case_id = "header-mac-work-over-lowered-cap";
let scope = case_scope(case_id);
let file_key = FileKey::generate().expect("file key");
let entries = [
unknown_entry(false),
x25519_entry(&keys.public_b, &file_key),
x25519_entry(&keys.public_a, &file_key),
];
assert_eq!(
header_mac_work(2, header_len_of(&entries)),
cap + 1,
"the refused file does one byte more than the capped work"
);
crafted_reject_case(
corpus,
sources,
case_id,
"private-key-a",
None,
"header_mac_work_above_local_cap",
"resource_cap_exceeded",
&file_key,
&entries,
b"",
);
drop(scope);
write_header_mac_work_order_cases(corpus, sources, keys);
});
}
fn write_header_mac_work_order_cases(corpus: &mut Corpus, sources: &Path, keys: &CorpusKeys) {
let padding = |crossing: usize| {
move |entries: &[RecipientEntry]| {
padding_past_lowered_header_mac_work_cap(entries, crossing)
}
};
let for_a = |file_key: &FileKey| x25519_entry(&keys.public_a, file_key);
seeded_crafted_reject_case(
corpus,
sources,
"header-mac-work-order-mixing-before-cap",
"passphrase-main",
None,
(
"argon2id_mixed_with_another_recipient_and_header_mac_work_above_local_cap",
"incompatible_recipients",
),
&|file_key| vec![for_a(file_key), argon2id_entry(file_key), for_a(file_key)],
&padding(1),
);
let opened = seeded_crafted_reject_case(
corpus,
sources,
"header-mac-work-over-lowered-cap-native-entries",
"private-key-a",
None,
("header_mac_work_above_local_cap", "resource_cap_exceeded"),
&|file_key| vec![for_a(file_key), for_a(file_key)],
&padding(2),
);
seeded_crafted_reject_case(
corpus,
sources,
"header-mac-work-order-cap-before-passphrase-unwrap",
"passphrase-wrong",
None,
(
"header_mac_work_above_local_cap_and_argon2id_wrong_passphrase",
"resource_cap_exceeded",
),
&|file_key| vec![argon2id_entry(file_key)],
&padding(1),
);
recredential_case(
corpus,
&opened,
"header-mac-work-order-cap-before-private-key-unlock",
"private-key-a-wrong-unlock",
"header_mac_work_above_local_cap_and_private_key_wrong_unlock_passphrase",
"resource_cap_exceeded",
);
recredential_case(
corpus,
&opened,
"header-mac-work-order-cap-before-x25519-unwrap",
"private-key-b",
"header_mac_work_above_local_cap_and_x25519_recipient_key_does_not_open_any_slot",
"resource_cap_exceeded",
);
mutate_fcr(
corpus,
&opened,
"header-mac-work-order-cap-before-x25519-shared-secret",
"private-key-a",
"header_mac_work_above_local_cap_and_x25519_all_zero_shared_secret",
"resource_cap_exceeded",
|b| {
for index in 0..usize::from(be_u16_at(b, OFF_RECIPIENT_COUNT)) {
let ephemeral = entry_offset(b, index)
+ crate::recipient::entry::ENTRY_HEADER_SIZE
+ x25519::TYPE_NAME.len()
+ x25519::EPHEMERAL_PUBLIC_KEY_OFFSET;
b[ephemeral..ephemeral + x25519::PUBLIC_KEY_SIZE]
.copy_from_slice(&SMALL_ORDER_EPHEMERAL);
}
},
);
mutate_fcr(
corpus,
&opened,
"header-mac-work-order-cap-before-header-mac",
"private-key-a",
"header_mac_work_above_local_cap_and_header_mac_tag_modified",
"resource_cap_exceeded",
|b| modify_header_mac(b),
);
}
fn write_stream_kat_cases(corpus: &mut Corpus) {
const KAT_CASES: [&str; 5] = [
"stream-empty",
"stream-one-byte",
"stream-exact-65536",
"stream-two-chunk-65537",
"stream-multi-chunk",
];
for case_id in KAT_CASES {
let read = |suffix: &str| -> Vec<u8> {
let reference = format!("kat/stream/{case_id}.{suffix}.bin");
fs::read(corpus.root.join(&reference)).unwrap_or_else(|e| {
panic!("{reference}: {e}. Run tools/stream_kat_oracle.py first.")
})
};
let key = read("payload-key");
corpus.push_origin(OriginRow {
origin_id: format!("origin-{case_id}"),
origin_kind: "stream_kat",
anchor_case_id: case_id.to_string(),
payload_key_ref: format!("kat/stream/{case_id}.payload-key.bin"),
payload_key_sha3_256: sha3_hex(&key),
stream_nonce_hex: hex(&oracle_nonce_prefix(case_id)),
});
corpus.push_case(
CaseRow::new(
case_id,
"stream_encrypt_kat",
&format!("kat/stream/{case_id}.input.bin"),
)
.fabricated()
.credential("none")
.transcript_equal(&format!("kat/stream/{case_id}.ciphertext.bin")),
);
}
}
fn oracle_nonce_prefix(case_id: &str) -> [u8; STREAM_NONCE_SIZE] {
let byte = match case_id {
"stream-empty" => 0x50,
"stream-one-byte" => 0x51,
"stream-exact-65536" => 0x52,
"stream-two-chunk-65537" => 0x53,
"stream-multi-chunk" => 0x54,
other => panic!("no nonce prefix recorded for {other}"),
};
[byte; STREAM_NONCE_SIZE]
}
#[test]
fn replay_stream_kats() {
if !wire_corpus_present() {
return;
}
let root = wire_dir();
let cases = read_table(&root, "cases.tsv");
let origins = read_table(&root, "origins.tsv");
let revision: u32 = fs::read_to_string(root.join("CORPUS-REVISION"))
.expect("read CORPUS-REVISION")
.trim()
.parse()
.expect("CORPUS-REVISION is a number");
let withdrawn: BTreeSet<String> = read_table(&root, "errata.tsv")
.iter()
.filter(|e| {
e["effective_corpus_revision"]
.parse::<u32>()
.expect("errata.tsv: effective_corpus_revision is not a number")
<= revision
})
.map(|e| e["affected_case_id"].clone())
.collect();
let owed = |case: &&BTreeMap<String, String>| {
case["case_type"] == "stream_encrypt_kat" && !withdrawn.contains(&case["case_id"])
};
let mut replayed = 0usize;
for case in cases.iter().filter(owed) {
let case_id = &case["case_id"];
assert_eq!(case["outcome"], "transcript_equal", "{case_id}: outcome");
assert_eq!(
case["expectation_scope"], "invariant",
"{case_id}: a STREAM known-answer row must be invariant"
);
let origin = origins
.iter()
.find(|o| &o["anchor_case_id"] == case_id)
.unwrap_or_else(|| panic!("{case_id}: no origin anchored to this case"));
assert_eq!(
origin["origin_kind"], "stream_kat",
"{case_id}: origin kind"
);
let key_bytes: [u8; 32] =
read_committed(&root, origin, "payload_key_ref", "payload_key_sha3_256")
.try_into()
.expect("payload key is 32 bytes");
let nonce = decode_hex_array::<STREAM_NONCE_SIZE>(&origin["stream_nonce_hex"]);
let plaintext = read_committed(&root, case, "artifact_ref", "artifact_sha3_256");
let expected = read_committed(&root, case, "expected_ref", "expected_sha3_256");
let payload_key = crate::crypto::keys::PayloadKey::from_bytes_for_tests(key_bytes);
let mut produced = Vec::new();
{
let mut writer =
crate::crypto::stream::payload_encryptor(&payload_key, &nonce, &mut produced);
writer.write_all(&plaintext).expect("write KAT plaintext");
writer.finish().expect("finish KAT transcript");
}
assert_eq!(
produced, expected,
"{case_id}: transcript differs from the independent oracle"
);
let mut recovered = Vec::new();
crate::crypto::stream::payload_decryptor(&payload_key, &nonce, expected.as_slice())
.read_to_end(&mut recovered)
.expect("decrypt KAT transcript");
assert_eq!(
recovered, plaintext,
"{case_id}: decrypted plaintext differs from the committed input"
);
replayed += 1;
}
let declared = cases.iter().filter(owed).count();
assert_eq!(
replayed, declared,
"every STREAM known-answer row must be replayed"
);
assert!(
replayed > 0,
"the corpus declares no STREAM known-answer cases"
);
println!("wire corpus: {replayed} STREAM known-answer row(s) replayed against the source");
}
#[test]
fn replay_fcr_payload_origins() {
if !wire_corpus_present() {
return;
}
let root = wire_dir();
let cases: BTreeMap<String, BTreeMap<String, String>> = read_table(&root, "cases.tsv")
.into_iter()
.map(|row| (row["case_id"].clone(), row))
.collect();
let mut nonces = BTreeMap::new();
let mut derived = 0usize;
let mut unreachable = Vec::new();
for origin in read_table(&root, "origins.tsv")
.iter()
.filter(|o| o["origin_kind"] == "fcr_payload")
{
let origin_id = &origin["origin_id"];
assert_eq!(
origin["payload_key_ref"], "-",
"{origin_id}: an .fcr payload origin commits to its key without naming a file"
);
if let Some(first) = nonces.insert(origin["stream_nonce_hex"].clone(), origin_id.clone()) {
panic!("{origin_id}: nonce prefix repeats {first}");
}
let anchor = cases
.get(&origin["anchor_case_id"])
.unwrap_or_else(|| panic!("{origin_id}: anchor case is not declared"));
let bytes = read_committed(&root, anchor, "artifact_ref", "artifact_sha3_256");
let Ok(header) = crate::container::read_encrypted_header(
&mut bytes.as_slice(),
crate::HeaderReadLimits::default()
.max_header_len(crate::format::HEADER_LEN_MAX)
.max_recipient_count(crate::format::RECIPIENT_COUNT_MAX)
.max_recipient_body_len(crate::format::BODY_LEN_MAX)
.max_header_mac_work_bytes(u64::MAX),
) else {
unreachable.push(origin_id.clone());
continue;
};
assert_eq!(
hex(&header.fixed.stream_nonce),
origin["stream_nonce_hex"],
"{origin_id}: stored nonce is not the one the anchor header carries"
);
let Some(file_key) =
unwrap_any_slot(&root, &header.recipient_entries, &anchor["credential_id"])
else {
assert_ne!(
anchor["outcome"], "accept",
"{origin_id}: an accepted anchor must open with its own credential"
);
unreachable.push(origin_id.clone());
continue;
};
let DerivedSubkeys { payload_key, .. } =
derive_subkeys(&file_key, &header.fixed.stream_nonce).expect("derive subkeys");
assert_eq!(
sha3_hex(payload_key.expose()),
origin["payload_key_sha3_256"],
"{origin_id}: derived payload key does not match its committed commitment"
);
derived += 1;
}
assert!(
derived > 0,
"no .fcr payload origin was reachable, so no commitment was evidenced"
);
assert!(
derived > unreachable.len(),
"only {derived} of {} .fcr payload origins were reachable: {unreachable:?}",
derived + unreachable.len()
);
println!(
"wire corpus: {derived} .fcr payload origin(s) checked against their commitments, \
{} not reachable with the corpus credentials",
unreachable.len()
);
}
fn credential_row(root: &Path, credential_id: &str) -> BTreeMap<String, String> {
read_table(root, "credentials.tsv")
.into_iter()
.find(|row| row["credential_id"] == credential_id)
.unwrap_or_else(|| panic!("{credential_id}: not declared in credentials.tsv"))
}
fn credential_passphrase(
root: &Path,
credential: &BTreeMap<String, String>,
ref_column: &str,
digest_column: &str,
) -> Passphrase {
Passphrase::new(
String::from_utf8(read_committed(root, credential, ref_column, digest_column))
.expect("a passphrase credential is UTF-8"),
)
}
pub(crate) enum CorpusCredential {
None,
Passphrase(Passphrase),
PrivateKey {
key: PathBuf,
unlock: Passphrase,
},
}
pub(crate) fn read_credential(root: &Path, credential_id: &str) -> CorpusCredential {
let credential = credential_row(root, credential_id);
match credential["kind"].as_str() {
"none" => CorpusCredential::None,
"passphrase" => CorpusCredential::Passphrase(credential_passphrase(
root,
&credential,
"primary_ref",
"primary_sha3_256",
)),
"private_key" => {
assert_ne!(
credential["secret_ref"], "-",
"{credential_id}: a private_key credential names its unlock passphrase"
);
read_committed(root, &credential, "primary_ref", "primary_sha3_256");
CorpusCredential::PrivateKey {
key: root.join(&credential["primary_ref"]),
unlock: credential_passphrase(root, &credential, "secret_ref", "secret_sha3_256"),
}
}
other => panic!("{credential_id}: unsupported credential kind {other}"),
}
}
fn unwrap_any_slot(
root: &Path,
entries: &[RecipientEntry],
credential_id: &str,
) -> Option<FileKey> {
let (passphrase, private_key) = match read_credential(root, credential_id) {
CorpusCredential::None => return None,
CorpusCredential::Passphrase(passphrase) => (Some(passphrase), None),
CorpusCredential::PrivateKey { key, unlock } => match x25519::open_x25519_key_file(
&key,
&unlock,
None,
crate::KeyReadLimits::default(),
&|_| {},
) {
Ok(opened) => (None, Some(opened.secret)),
Err(_) => return None,
},
};
entries.iter().find_map(
|entry| match (entry.type_name.as_str(), &passphrase, &private_key) {
(argon2id::TYPE_NAME, Some(passphrase), _) => {
let body = entry.body.as_slice().try_into().ok()?;
argon2id::unwrap(body, passphrase, None, &|_| {}).ok()
}
(x25519::TYPE_NAME, _, Some(secret)) => {
let body = entry.body.as_slice().try_into().ok()?;
x25519::unwrap(body, secret).ok()
}
_ => None,
},
)
}
fn decode_hex_array<const N: usize>(text: &str) -> [u8; N] {
assert_eq!(text.len(), N * 2, "expected {N} bytes of hexadecimal");
let mut out = [0u8; N];
for (index, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(&text[index * 2..index * 2 + 2], 16).expect("hexadecimal");
}
out
}