use std::collections::BTreeSet;
use std::sync::OnceLock;
use bech32::primitives::decode::CheckedHrpstring;
use bech32::{Checksum, Fe32};
use ferrocrypt_test_support::wire_manifest::{
FCA_ARCHIVE_TLV_DOMAIN, FCA_ENTRY_TLV_DOMAIN, FCA_VERSION_DOMAIN, KEY_TYPE_DOMAIN,
OUTER_TLV_DOMAIN, OUTER_VERSION_DOMAIN, PRIVATE_KEY_TLV_DOMAIN, PRIVATE_KEY_VERSION_DOMAIN,
PUBLIC_KEY_VERSION_DOMAIN, RECIPIENT_TYPE_DOMAIN, capability_parts, field, subject_version,
};
use super::{
Breaks, CheckList, Direction, LoadedReadings, ProfileCaps, ReaderCapabilities, Row, across,
assert_specification_report, corpus_root, evidence, length_rule_breaks, limit_profiles,
load_readings, rejected_cases, reopens, stray_open_pairs, type_name_breaks,
};
use crate::format::{read_u16_be, read_u32_be};
use crate::key::private::has_private_key_signature;
use crate::key::public::{
KEY_MATERIAL_LEN_MAX, PAYLOAD_HEADER_SIZE, PAYLOAD_KEY_MATERIAL_LEN_OFFSET,
PAYLOAD_TYPE_NAME_LEN_OFFSET, PAYLOAD_VERSION_OFFSET, PUBLIC_KEY_CHECKSUM_SIZE,
PUBLIC_KEY_FILE_READ_CAP_BYTES, PUBLIC_KEY_VERSION, RECIPIENT_HRP, RECIPIENT_STRING_LEN_MAX,
compute_checksum,
};
use crate::recipient::name::TYPE_NAME_MAX_LEN;
use crate::recipient::native::x25519;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum Pk {
FileTooLong,
PrivateKeySignature,
NotUtf8,
NotAscii,
StringTooLong,
StringCap,
NotBech32,
PayloadShort,
Padding,
VersionZero,
VersionUnsupported,
Lengths,
PayloadLength,
TypeName,
Checksum,
TypeSupport,
TypeRules,
}
use Pk::*;
const PAYLOAD: [Pk; 10] = [
PayloadShort,
Padding,
VersionZero,
VersionUnsupported,
Lengths,
PayloadLength,
TypeName,
Checksum,
TypeSupport,
TypeRules,
];
const UNDECODABLE: [Pk; 4] = [PrivateKeySignature, NotUtf8, NotAscii, NotBech32];
fn exclusive(a: Pk, b: Pk) -> bool {
across(a, b, &[VersionZero], &[VersionUnsupported])
|| across(a, b, &[TypeSupport, TypeName], &[TypeRules])
}
const SHADOWED_BY_PAYLOAD_LENGTH: [Pk; 2] = [TypeName, TypeSupport];
fn shadowed(earlier: Pk, later: Pk) -> bool {
earlier == PayloadShort && SHADOWED_BY_PAYLOAD_LENGTH.contains(&later)
}
fn both_breakable(reading: Reading, a: Pk, b: Pk) -> bool {
(reading.lenient || !across(a, b, &UNDECODABLE, &PAYLOAD))
&& (reading.support_of_any_name || !across(a, b, &[TypeName], &[TypeSupport]))
}
struct PublicKeyList;
impl CheckList for PublicKeyList {
type Check = Pk;
fn order() -> &'static [Pk] {
&[
FileTooLong,
PrivateKeySignature,
NotUtf8,
NotAscii,
StringTooLong,
StringCap,
NotBech32,
PayloadShort,
Padding,
VersionZero,
VersionUnsupported,
Lengths,
PayloadLength,
TypeName,
Checksum,
TypeSupport,
TypeRules,
]
}
fn class(check: Pk) -> &'static str {
match check {
PrivateKeySignature => "wrong_key_file_type",
NotUtf8 => "not_a_key_file",
StringCap => "resource_cap_exceeded",
VersionUnsupported => "unsupported_public_key_version",
TypeName => "malformed_type_name",
TypeSupport => "unsupported_key_type",
FileTooLong | NotAscii | StringTooLong | NotBech32 | PayloadShort | Padding
| VersionZero | Lengths | PayloadLength | Checksum | TypeRules => {
"malformed_public_key"
}
}
}
fn claimed(earlier: Pk, later: Pk) -> bool {
!exclusive(earlier, later) && !shadowed(earlier, later)
}
fn directions(_group: usize) -> &'static [Direction] {
&[Direction::FrontToBack]
}
}
#[derive(Default)]
struct Capabilities {
versions: BTreeSet<u8>,
key_types: BTreeSet<String>,
}
impl ReaderCapabilities for Capabilities {
type Check = Pk;
fn declaring(run: &[String]) -> (Self, BTreeSet<Pk>) {
let mut capabilities = Self::default();
let mut changed = BTreeSet::new();
for capability_id in run {
let (domain, subject) = capability_parts(capability_id)
.unwrap_or_else(|| panic!("{capability_id}: not a capability ID"));
match domain {
PUBLIC_KEY_VERSION_DOMAIN => {
let version = subject_version(subject)
.unwrap_or_else(|| panic!("{capability_id}: not a version"));
capabilities.versions.insert(version);
changed.insert(VersionUnsupported);
}
KEY_TYPE_DOMAIN => {
capabilities.key_types.insert(subject.to_string());
changed.extend([TypeSupport, TypeRules]);
}
OUTER_VERSION_DOMAIN
| RECIPIENT_TYPE_DOMAIN
| FCA_VERSION_DOMAIN
| PRIVATE_KEY_VERSION_DOMAIN
| OUTER_TLV_DOMAIN
| PRIVATE_KEY_TLV_DOMAIN
| FCA_ARCHIVE_TLV_DOMAIN
| FCA_ENTRY_TLV_DOMAIN => {}
other => panic!("{capability_id}: unknown capability domain {other}"),
}
}
(capabilities, changed)
}
}
impl Capabilities {
fn supports_version(&self, version: u8) -> bool {
version == PUBLIC_KEY_VERSION || self.versions.contains(&version)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Tail {
Declared,
ChecksumAtEnd,
MaterialToChecksum,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Reading {
lenient: bool,
tail: Tail,
capped: bool,
support_of_any_name: bool,
}
impl Reading {
const SPECIFICATION: Reading = Reading {
lenient: false,
tail: Tail::Declared,
capped: false,
support_of_any_name: false,
};
fn all() -> Vec<Reading> {
let mut readings = Vec::new();
for lenient in [false, true] {
for tail in [
Tail::Declared,
Tail::ChecksumAtEnd,
Tail::MaterialToChecksum,
] {
for capped in [false, true] {
for support_of_any_name in [false, true] {
readings.push(Reading {
lenient,
tail,
capped,
support_of_any_name,
});
}
}
}
}
readings
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum AnyLengthBech32 {}
impl Checksum for AnyLengthBech32 {
type MidstateRepr = <bech32::Bech32 as Checksum>::MidstateRepr;
const CHECKSUM_LENGTH: usize = <bech32::Bech32 as Checksum>::CHECKSUM_LENGTH;
const CODE_LENGTH: usize = usize::MAX;
const GENERATOR_SH: [Self::MidstateRepr; 5] = <bech32::Bech32 as Checksum>::GENERATOR_SH;
const TARGET_RESIDUE: Self::MidstateRepr = <bech32::Bech32 as Checksum>::TARGET_RESIDUE;
}
fn is_recipient_bech32(string: &[u8]) -> bool {
std::str::from_utf8(string).is_ok_and(|text| {
!text.bytes().any(|byte| byte.is_ascii_uppercase())
&& CheckedHrpstring::new::<AnyLengthBech32>(text)
.is_ok_and(|checked| checked.hrp() == RECIPIENT_HRP)
})
}
fn data_groups(string: &[u8]) -> Option<Vec<u8>> {
let separator = string.iter().rposition(|&byte| byte == b'1')?;
let data = &string[separator + 1..];
let checksum_length = <bech32::Bech32 as Checksum>::CHECKSUM_LENGTH;
data.get(..data.len().checked_sub(checksum_length)?)?
.iter()
.map(|&byte| {
Fe32::from_char(char::from(byte.to_ascii_lowercase()))
.ok()
.map(Fe32::to_u8)
})
.collect()
}
fn groups_to_bytes(groups: &[u8]) -> (Vec<u8>, bool) {
const GROUP_BITS: u32 = 5;
let mut bytes = Vec::new();
let mut held = 0u32;
let mut bits = 0u32;
for &group in groups {
held = (held << GROUP_BITS) | u32::from(group);
bits += GROUP_BITS;
if bits >= u8::BITS {
bits -= u8::BITS;
bytes.push((held >> bits) as u8);
held &= (1 << bits) - 1;
}
}
(bytes, bits < GROUP_BITS && held == 0)
}
fn longer_than(string: &[u8], limit: usize) -> Option<bool> {
let by_bytes = string.len() > limit;
let by_chars = String::from_utf8_lossy(string).chars().count() > limit;
match (by_bytes, by_chars) {
(true, true) => Some(true),
(false, false) => None,
_ => Some(false),
}
}
struct Decoded {
breaks: Breaks<Pk>,
version: Option<u8>,
type_name: Option<Vec<u8>>,
}
fn reading_breaks(
bytes: &[u8],
profile: &Row,
capabilities: &Capabilities,
reading: Reading,
) -> Vec<Decoded> {
let mut breaks = Breaks::default();
let read = if reading.capped {
&bytes[..bytes.len().min(PUBLIC_KEY_FILE_READ_CAP_BYTES + 1)]
} else {
bytes
};
if read.len() > PUBLIC_KEY_FILE_READ_CAP_BYTES {
breaks.add(FileTooLong, true);
}
if has_private_key_signature(read) {
breaks.add(PrivateKeySignature, true);
}
if std::str::from_utf8(read).is_err() {
breaks.add(NotUtf8, true);
}
let stripped = read.strip_suffix(b"\n").unwrap_or(read);
let mut variants = vec![string_breaks(
breaks.clone(),
stripped,
profile,
capabilities,
reading,
)];
if read.len() < bytes.len() && stripped.len() < read.len() {
variants.push(string_breaks(breaks, read, profile, capabilities, reading));
}
variants
}
fn string_breaks(
mut breaks: Breaks<Pk>,
string: &[u8],
profile: &Row,
capabilities: &Capabilities,
reading: Reading,
) -> Decoded {
if !string.is_ascii() {
breaks.add(NotAscii, true);
}
if let Some(certain) = longer_than(string, RECIPIENT_STRING_LEN_MAX) {
breaks.add(StringTooLong, certain);
}
let cap = ProfileCaps::new(profile)
.key_read()
.recipient_string_chars();
if let Some(certain) = longer_than(string, cap) {
breaks.add(StringCap, certain);
}
let bech32 = is_recipient_bech32(string);
if !bech32 {
breaks.add(NotBech32, true);
}
let decoded = (bech32 || reading.lenient)
.then(|| data_groups(string))
.flatten()
.map(|groups| groups_to_bytes(&groups));
let Some((payload, canonical)) = decoded else {
return Decoded {
breaks,
version: None,
type_name: None,
};
};
if payload.len() < PAYLOAD_HEADER_SIZE + PUBLIC_KEY_CHECKSUM_SIZE {
breaks.add(PayloadShort, true);
}
if !canonical {
breaks.add(Padding, true);
}
let version = payload.get(PAYLOAD_VERSION_OFFSET).copied();
match version {
Some(0) => breaks.add(VersionZero, true),
Some(version) if !capabilities.supports_version(version) => {
breaks.add(VersionUnsupported, true)
}
_ => {}
}
let type_name_len = read_u16_be(&payload, PAYLOAD_TYPE_NAME_LEN_OFFSET)
.ok()
.map(usize::from);
let material_len = read_u32_be(&payload, PAYLOAD_KEY_MATERIAL_LEN_OFFSET)
.ok()
.map(|len| len as usize);
let name_len_wrong = type_name_len.is_some_and(|len| !(1..=TYPE_NAME_MAX_LEN).contains(&len));
let material_len_wrong = material_len.is_some_and(|len| len > KEY_MATERIAL_LEN_MAX as usize);
if name_len_wrong || material_len_wrong {
breaks.add(Lengths, true);
}
let (Some(type_name_len), Some(material_len)) = (type_name_len, material_len) else {
return Decoded {
breaks,
version,
type_name: None,
};
};
let fields = PAYLOAD_HEADER_SIZE + type_name_len + material_len;
if payload.len() != fields + PUBLIC_KEY_CHECKSUM_SIZE {
breaks.add(PayloadLength, true);
}
let name_end = PAYLOAD_HEADER_SIZE + type_name_len;
let Some(type_name) = payload.get(PAYLOAD_HEADER_SIZE..name_end) else {
return Decoded {
breaks,
version,
type_name: None,
};
};
let native = type_name == x25519::TYPE_NAME.as_bytes();
let added =
std::str::from_utf8(type_name).is_ok_and(|name| capabilities.key_types.contains(name));
let named = type_name_breaks(type_name, native || added, reading.support_of_any_name);
if let Some(certain) = named.grammar {
breaks.add(TypeName, certain);
}
let checksum_from_end = payload.len().checked_sub(PUBLIC_KEY_CHECKSUM_SIZE);
let (material, stored) = match reading.tail {
Tail::Declared => (
payload.get(name_end..fields),
payload.get(fields..fields + PUBLIC_KEY_CHECKSUM_SIZE),
),
Tail::ChecksumAtEnd => (
payload.get(name_end..fields),
checksum_from_end.and_then(|start| payload.get(start..)),
),
Tail::MaterialToChecksum => (
checksum_from_end.and_then(|end| payload.get(name_end..end)),
checksum_from_end.and_then(|start| payload.get(start..)),
),
};
if let (Some(version), Some(material), Some(stored)) = (version, material, stored) {
if stored != compute_checksum(version, type_name, material) {
breaks.add(Checksum, true);
}
}
if let Some(certain) = named.support {
breaks.add(TypeSupport, certain);
}
if native {
let declared_wrong = material_len != x25519::PUBLIC_KEY_SIZE;
let length_rule = match reading.tail {
Tail::MaterialToChecksum => length_rule_breaks(
declared_wrong,
material.map(|material| material.len() != x25519::PUBLIC_KEY_SIZE),
),
Tail::Declared | Tail::ChecksumAtEnd => declared_wrong.then_some(true),
};
if let Some(certain) = length_rule {
breaks.add(TypeRules, certain);
}
let key_rule = material
.and_then(|material| <&[u8; x25519::PUBLIC_KEY_SIZE]>::try_from(material).ok())
.is_some_and(|key| {
x25519::is_zero_public_key(key) || !x25519::is_canonical_public_key_encoding(key)
});
if key_rule {
breaks.add(TypeRules, true);
}
}
Decoded {
breaks,
version,
type_name: Some(type_name.to_vec()),
}
}
const KEPT_BY_NEWER_VERSIONS: [Pk; 9] = [
FileTooLong,
PrivateKeySignature,
NotUtf8,
NotAscii,
StringTooLong,
StringCap,
NotBech32,
PayloadShort,
Padding,
];
fn case_breaks(
row: &Row,
bytes: &[u8],
profile: &Row,
capabilities: &Capabilities,
reading: Reading,
) -> Breaks<Pk> {
let variants = reading_breaks(bytes, profile, capabilities, reading);
for Decoded { breaks, .. } in &variants {
assert!(
!breaks.is_broken(PayloadShort)
|| !SHADOWED_BY_PAYLOAD_LENGTH
.into_iter()
.any(|check| breaks.is_broken(check))
|| breaks.certain.contains(&(PayloadLength, None)),
"{} under {reading:?}: a payload too short for its fixed fields holds its type \
name but declares no more than it holds",
field(row, "case_id")
);
}
let mut variants = variants.into_iter();
let Decoded {
breaks,
version,
type_name,
} = variants.next().expect("a reading has a variant");
let mut breaks =
Breaks::merge(std::iter::once(breaks).chain(variants.map(|variant| variant.breaks)));
assert!(
!type_name.is_some_and(|name| {
std::str::from_utf8(&name).is_ok_and(|name| capabilities.key_types.contains(name))
}),
"{}: a reader that declares a key type applies rules this list does not know",
field(row, "case_id")
);
if version.is_some_and(|version| {
version != PUBLIC_KEY_VERSION && capabilities.supports_version(version)
}) {
breaks.as_newer_version(&KEPT_BY_NEWER_VERSIONS);
}
if reading == Reading::SPECIFICATION {
assert_specification_report::<PublicKeyList>(row, &breaks);
}
breaks
}
fn loaded_cases() -> &'static LoadedReadings<Pk, Reading> {
static CASES: OnceLock<LoadedReadings<Pk, Reading>> = OnceLock::new();
CASES.get_or_init(|| {
let root = corpus_root().expect("the corpus is on disk");
let profiles = limit_profiles(&root);
let cases = rejected_cases(&root, "public_key_decode");
load_readings::<Capabilities, _>(&cases, &Reading::all(), |row, bytes, run, reading| {
let (capabilities, _) = Capabilities::declaring(run);
let profile = &profiles[field(row, "limit_profile_id")];
case_breaks(row, bytes, profile, &capabilities, reading)
})
})
}
#[test]
fn the_corpus_fixes_every_claimed_public_key_check_order() {
if corpus_root().is_none() {
return;
}
let open = stray_open_pairs::<PublicKeyList, Capabilities, _>(loaded_cases(), both_breakable);
assert!(
open.is_empty(),
"the corpus leaves these `public.key` check pairs open: {open:#?}"
);
}
#[test]
fn a_shadowed_pair_shows_through_a_claimed_pair() {
let position = |check: Pk| {
PublicKeyList::order()
.iter()
.position(|&other| other == check)
.expect("a check of the list")
};
for later in SHADOWED_BY_PAYLOAD_LENGTH {
assert!(position(PayloadShort) < position(PayloadLength));
assert!(position(PayloadLength) < position(later));
assert_eq!(
PublicKeyList::class(PayloadLength),
PublicKeyList::class(PayloadShort)
);
assert_ne!(
PublicKeyList::class(PayloadLength),
PublicKeyList::class(later)
);
assert!(PublicKeyList::claimed(PayloadLength, later));
}
}
#[test]
fn leaving_out_the_witnesses_reopens_their_pair() {
if corpus_root().is_none() {
return;
}
let lenient = Reading {
lenient: true,
..Reading::SPECIFICATION
};
let specification: &[(&str, (Pk, Pk))] = &[
("public-key-order-ascii-before-cap", (NotAscii, StringCap)),
(
"public-key-order-cap-before-payload-size",
(StringCap, PayloadShort),
),
(
"public-key-order-padding-before-version",
(Padding, VersionUnsupported),
),
(
"public-key-order-type-name-before-internal-checksum",
(TypeName, Checksum),
),
(
"public-key-order-internal-checksum-before-type-support",
(Checksum, TypeSupport),
),
(
"public-key-order-version-before-type-rules",
(VersionUnsupported, TypeRules),
),
];
let lenient_only: &[(&str, (Pk, Pk))] = &[(
"public-key-order-bech32-before-version",
(NotBech32, VersionUnsupported),
)];
let witnessed = [
(Reading::SPECIFICATION, specification),
(lenient, lenient_only),
];
for (reading, witnesses) in witnessed {
let (_, cases) = loaded_cases()
.readings
.iter()
.find(|(loaded, _)| *loaded == reading)
.expect("every reading is loaded");
let base = evidence::<PublicKeyList>(cases, &[]);
for &(witness, pair) in witnesses {
assert!(
reopens::<PublicKeyList>(&base, &[witness], pair),
"without {witness}, {pair:?} should be open for a reader of {reading:?}"
);
}
}
}