use bech32::primitives::decode::CheckedHrpstring;
use bech32::{Bech32, Checksum, Hrp};
use sha3::{Digest, Sha3_256};
use crate::CryptoError;
use crate::error::{FormatDefect, UnsupportedVersion};
use crate::format::{
KeypairSuite, KeypairVersionRejection, WRITER_KEYPAIR_SUITE,
keypair_suite_from_public_key_version, keypair_suite_is_supported, read_u16_be, read_u32_be,
};
use crate::recipient::native::x25519::TYPE_NAME as X25519_TYPE_NAME;
use crate::recipient::{TYPE_NAME_MAX_LEN, validate_type_name_grammar};
pub const PUBLIC_KEY_VERSION: u8 = WRITER_KEYPAIR_SUITE.public_key_version();
pub const PUBLIC_KEY_V1_VERSION: u8 = KeypairSuite::V1.public_key_version();
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
pub(crate) const RECIPIENT_HRP: Hrp = Hrp::parse_unchecked("fcr");
pub(crate) const PUBLIC_KEY_CHECKSUM_DOMAIN: &[u8] = b"ferrocrypt/v1/public-key/checksum";
pub(crate) const PUBLIC_KEY_CHECKSUM_SIZE: usize = 16;
pub(crate) const PAYLOAD_HEADER_SIZE: usize = 1 + size_of::<u16>() + size_of::<u32>();
const PAYLOAD_VERSION_OFFSET: usize = 0;
const PAYLOAD_TYPE_NAME_LEN_OFFSET: usize = PAYLOAD_VERSION_OFFSET + 1;
const PAYLOAD_KEY_MATERIAL_LEN_OFFSET: usize = PAYLOAD_TYPE_NAME_LEN_OFFSET + size_of::<u16>();
const _: () = assert!(PAYLOAD_KEY_MATERIAL_LEN_OFFSET + size_of::<u32>() == PAYLOAD_HEADER_SIZE);
pub(crate) const RECIPIENT_STRING_LEN_MAX: usize = 20_000;
pub(crate) const PUBLIC_KEY_FILE_READ_CAP_BYTES: usize = RECIPIENT_STRING_LEN_MAX + 1;
pub const RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT: usize = 1_024;
const RECIPIENT_STRING_OVERHEAD_CHARS: usize = 3 + 1 + 6;
pub(crate) const KEY_MATERIAL_LEN_MAX: u32 = max_key_material_len();
const fn max_key_material_len() -> u32 {
let data_chars = RECIPIENT_STRING_LEN_MAX - RECIPIENT_STRING_OVERHEAD_CHARS;
let max_data_bytes = data_chars * 5 / 8;
let max_payload =
max_data_bytes - PAYLOAD_HEADER_SIZE - PUBLIC_KEY_CHECKSUM_SIZE - TYPE_NAME_MAX_LEN;
max_payload as u32
}
#[derive(Copy, Clone, PartialEq, Eq)]
enum Bech32V1 {}
impl Checksum for Bech32V1 {
type MidstateRepr = <Bech32 as Checksum>::MidstateRepr;
const CHECKSUM_LENGTH: usize = <Bech32 as Checksum>::CHECKSUM_LENGTH;
const CODE_LENGTH: usize = RECIPIENT_STRING_LEN_MAX;
const GENERATOR_SH: [Self::MidstateRepr; 5] = <Bech32 as Checksum>::GENERATOR_SH;
const TARGET_RESIDUE: Self::MidstateRepr = <Bech32 as Checksum>::TARGET_RESIDUE;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedRecipient {
pub keypair_suite: KeypairSuite,
pub type_name: String,
pub key_material: Vec<u8>,
}
pub(crate) fn encode_recipient_string(
type_name: &str,
key_material: &[u8],
) -> Result<String, CryptoError> {
encode_recipient_string_for_suite(WRITER_KEYPAIR_SUITE, type_name, key_material)
}
pub(crate) fn encode_recipient_string_for_suite(
suite: KeypairSuite,
type_name: &str,
key_material: &[u8],
) -> Result<String, CryptoError> {
let version = suite.public_key_version();
validate_type_name_grammar(type_name)?;
let type_name_bytes = type_name.as_bytes();
let type_name_len = u16::try_from(type_name_bytes.len())
.map_err(|_| CryptoError::InvalidFormat(FormatDefect::MalformedTypeName))?;
let key_material_len = u32::try_from(key_material.len()).map_err(|_| malformed_public_key())?;
check_key_material_len(key_material_len)?;
let cs = compute_checksum(version, type_name, key_material);
let total_data =
PAYLOAD_HEADER_SIZE + type_name_bytes.len() + key_material.len() + PUBLIC_KEY_CHECKSUM_SIZE;
let mut data = Vec::with_capacity(total_data);
data.push(version);
data.extend_from_slice(&type_name_len.to_be_bytes());
data.extend_from_slice(&key_material_len.to_be_bytes());
data.extend_from_slice(type_name_bytes);
data.extend_from_slice(key_material);
data.extend_from_slice(&cs);
bech32::encode::<Bech32V1>(RECIPIENT_HRP, &data)
.map_err(|_| CryptoError::InternalInvariant("Internal error: Bech32 encode failed"))
}
pub fn decode_recipient_string(
s: &str,
local_max_chars: usize,
) -> Result<DecodedRecipient, CryptoError> {
if !s.is_ascii() {
return Err(CryptoError::InvalidInput(
"Recipient string must be ASCII Bech32".to_string(),
));
}
let char_count = s.len(); if char_count > local_max_chars {
return Err(CryptoError::RecipientStringCapExceeded {
input_chars: u32::try_from(char_count).unwrap_or(u32::MAX),
local_cap: u32::try_from(local_max_chars).unwrap_or(u32::MAX),
});
}
if s.chars().any(|c| c.is_ascii_uppercase()) {
return Err(CryptoError::InvalidInput(
"Recipient string must be lowercase".to_string(),
));
}
let checked = CheckedHrpstring::new::<Bech32V1>(s)
.map_err(|_| CryptoError::InvalidInput(format!("Invalid recipient string: {s}")))?;
let hrp = checked.hrp();
if hrp != RECIPIENT_HRP {
return Err(CryptoError::InvalidInput(format!(
"Unexpected recipient prefix (want '{}', got '{}')",
RECIPIENT_HRP.as_str(),
hrp.as_str()
)));
}
let data: Vec<u8> = checked.byte_iter().collect();
check_payload_data_len(data.len())?;
let wire_version = data[PAYLOAD_VERSION_OFFSET];
let suite = public_key_wire_version_to_suite(wire_version)?;
ensure_public_key_suite_supported(suite)?;
let type_name_len = read_u16_be(&data, PAYLOAD_TYPE_NAME_LEN_OFFSET)?;
check_type_name_len(type_name_len)?;
let key_material_len = read_u32_be(&data, PAYLOAD_KEY_MATERIAL_LEN_OFFSET)?;
check_key_material_len(key_material_len)?;
check_total_payload_size(data.len(), type_name_len, key_material_len)?;
let type_name_start = PAYLOAD_HEADER_SIZE;
let type_name_end = type_name_start + type_name_len as usize;
let key_material_end = type_name_end + key_material_len as usize;
let checksum_end = key_material_end + PUBLIC_KEY_CHECKSUM_SIZE;
let type_name_bytes = &data[type_name_start..type_name_end];
let type_name = std::str::from_utf8(type_name_bytes)
.map_err(|_| CryptoError::InvalidFormat(FormatDefect::MalformedTypeName))?;
validate_type_name_grammar(type_name)?;
let key_material = data[type_name_end..key_material_end].to_vec();
let stored_checksum = &data[key_material_end..checksum_end];
let computed_checksum = compute_checksum(wire_version, type_name, &key_material);
if stored_checksum != computed_checksum {
return Err(malformed_public_key());
}
Ok(DecodedRecipient {
keypair_suite: suite,
type_name: type_name.to_owned(),
key_material,
})
}
fn public_key_wire_version_to_suite(version: u8) -> Result<KeypairSuite, CryptoError> {
keypair_suite_from_public_key_version(version).map_err(|r| match r {
KeypairVersionRejection::Reserved => malformed_public_key(),
KeypairVersionRejection::Older { version: v } => {
CryptoError::UnsupportedVersion(UnsupportedVersion::OlderPublicKey { version: v })
}
KeypairVersionRejection::Newer { version: v } => {
CryptoError::UnsupportedVersion(UnsupportedVersion::NewerPublicKey { version: v })
}
})
}
fn ensure_public_key_suite_supported(suite: KeypairSuite) -> Result<(), CryptoError> {
if keypair_suite_is_supported(suite) {
Ok(())
} else {
Err(CryptoError::UnsupportedVersion(
UnsupportedVersion::OlderPublicKey {
version: suite.public_key_version(),
},
))
}
}
pub(crate) fn decode_x25519_recipient(recipient: &str) -> Result<[u8; 32], CryptoError> {
Ok(decode_x25519_recipient_resolved(recipient)?.bytes)
}
pub(crate) fn decode_x25519_recipient_resolved(
recipient: &str,
) -> Result<ResolvedPublicKey, CryptoError> {
let decoded = decode_recipient_string(recipient, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT)?;
let suite = decoded.keypair_suite;
let bytes = decoded_x25519_bytes(decoded, malformed_public_key)?;
Ok(ResolvedPublicKey { suite, bytes })
}
fn decoded_x25519_bytes(
decoded: DecodedRecipient,
wrong_type_error: impl FnOnce() -> CryptoError,
) -> Result<[u8; 32], CryptoError> {
if decoded.type_name != X25519_TYPE_NAME {
return Err(wrong_type_error());
}
let bytes: [u8; 32] = decoded
.key_material
.as_slice()
.try_into()
.map_err(|_| malformed_public_key())?;
if crate::recipient::x25519::is_zero_public_key(&bytes) {
return Err(malformed_public_key());
}
Ok(bytes)
}
fn check_payload_data_len(data_len: usize) -> Result<(), CryptoError> {
if data_len < PAYLOAD_HEADER_SIZE + PUBLIC_KEY_CHECKSUM_SIZE {
return Err(malformed_public_key());
}
Ok(())
}
fn check_type_name_len(len: u16) -> Result<(), CryptoError> {
if len == 0 || (len as usize) > TYPE_NAME_MAX_LEN {
return Err(malformed_public_key());
}
Ok(())
}
fn check_key_material_len(len: u32) -> Result<(), CryptoError> {
if len > KEY_MATERIAL_LEN_MAX {
return Err(malformed_public_key());
}
Ok(())
}
fn check_total_payload_size(
data_len: usize,
type_name_len: u16,
key_material_len: u32,
) -> Result<(), CryptoError> {
let total_expected = PAYLOAD_HEADER_SIZE
.checked_add(type_name_len as usize)
.and_then(|v| v.checked_add(key_material_len as usize))
.and_then(|v| v.checked_add(PUBLIC_KEY_CHECKSUM_SIZE))
.ok_or_else(malformed_public_key)?;
if data_len != total_expected {
return Err(malformed_public_key());
}
Ok(())
}
fn public_key_hash(prefix: &[&[u8]], type_name: &str, key_material: &[u8]) -> [u8; 32] {
let mut hasher = Sha3_256::new();
for chunk in prefix {
hasher.update(chunk);
}
hasher.update(type_name.as_bytes());
hasher.update([0x00]);
hasher.update(key_material);
hasher.finalize().into()
}
fn compute_checksum(
version: u8,
type_name: &str,
key_material: &[u8],
) -> [u8; PUBLIC_KEY_CHECKSUM_SIZE] {
let full = public_key_hash(
&[PUBLIC_KEY_CHECKSUM_DOMAIN, &[version]],
type_name,
key_material,
);
let mut truncated = [0u8; PUBLIC_KEY_CHECKSUM_SIZE];
truncated.copy_from_slice(&full[..PUBLIC_KEY_CHECKSUM_SIZE]);
truncated
}
fn malformed_public_key() -> CryptoError {
CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)
}
pub(crate) fn fingerprint_bytes(type_name: &str, key_material: &[u8]) -> [u8; 32] {
public_key_hash(&[], type_name, key_material)
}
pub(crate) fn fingerprint_hex(type_name: &str, key_material: &[u8]) -> String {
hex_encode(&fingerprint_bytes(type_name, key_material))
}
pub(crate) fn read_public_key(path: &std::path::Path) -> Result<ResolvedPublicKey, CryptoError> {
let bytes = crate::fs::paths::read_file_capped(path, PUBLIC_KEY_FILE_READ_CAP_BYTES, || {
CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)
})?;
if bytes.is_empty() {
return Err(malformed_public_key());
}
if matches!(
crate::key::files::KeyFileKind::classify(&bytes),
crate::key::files::KeyFileKind::Private
) {
return Err(CryptoError::InvalidFormat(FormatDefect::WrongKeyFileType));
}
let contents = String::from_utf8(bytes)
.map_err(|_| CryptoError::InvalidFormat(FormatDefect::NotAKeyFile))?;
let recipient = contents.strip_suffix('\n').unwrap_or(&contents);
if recipient.bytes().any(|b| b.is_ascii_whitespace()) {
return Err(malformed_public_key());
}
let decoded = decode_recipient_string(recipient, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT)?;
let suite = decoded.keypair_suite;
let bytes = decoded_x25519_bytes(decoded, || {
CryptoError::InvalidFormat(FormatDefect::WrongKeyFileType)
})?;
Ok(ResolvedPublicKey { suite, bytes })
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PublicKey {
source: PublicKeySource,
}
#[derive(Debug, Clone)]
enum PublicKeySource {
KeyFile(std::path::PathBuf),
X25519 {
suite: KeypairSuite,
bytes: [u8; 32],
},
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ResolvedPublicKey {
pub suite: KeypairSuite,
pub bytes: [u8; 32],
}
impl PublicKey {
pub fn from_key_file(path: impl AsRef<std::path::Path>) -> Self {
Self {
source: PublicKeySource::KeyFile(path.as_ref().to_path_buf()),
}
}
pub fn from_bytes(bytes: [u8; 32]) -> Result<Self, CryptoError> {
if crate::recipient::x25519::is_zero_public_key(&bytes) {
return Err(malformed_public_key());
}
Ok(Self {
source: PublicKeySource::X25519 {
suite: WRITER_KEYPAIR_SUITE,
bytes,
},
})
}
pub fn from_recipient_string(recipient: &str) -> Result<Self, CryptoError> {
let resolved = decode_x25519_recipient_resolved(recipient)?;
Ok(Self {
source: PublicKeySource::X25519 {
suite: resolved.suite,
bytes: resolved.bytes,
},
})
}
pub fn fingerprint(&self) -> Result<String, CryptoError> {
let resolved = self.resolve()?;
Ok(fingerprint_hex(X25519_TYPE_NAME, &resolved.bytes))
}
pub fn to_recipient_string(&self) -> Result<String, CryptoError> {
let resolved = self.resolve()?;
encode_recipient_string_for_suite(resolved.suite, X25519_TYPE_NAME, &resolved.bytes)
}
pub fn to_bytes(&self) -> Result<[u8; 32], CryptoError> {
self.resolve().map(|resolved| resolved.bytes)
}
pub fn validate(&self) -> Result<(), CryptoError> {
self.resolve().map(|_| ())
}
fn resolve(&self) -> Result<ResolvedPublicKey, CryptoError> {
match &self.source {
PublicKeySource::KeyFile(path) => read_public_key(path),
PublicKeySource::X25519 { suite, bytes } => Ok(ResolvedPublicKey {
suite: *suite,
bytes: *bytes,
}),
}
}
}
impl std::str::FromStr for PublicKey {
type Err = CryptoError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_recipient_string(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn x25519_key() -> [u8; 32] {
[0x33u8; 32]
}
#[test]
fn public_key_wire_version_to_suite_classifies_v1_and_neighbours() {
assert_eq!(
public_key_wire_version_to_suite(PUBLIC_KEY_V1_VERSION).unwrap(),
KeypairSuite::V1,
);
match public_key_wire_version_to_suite(0x00) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => panic!("expected MalformedPublicKey for 0x00, got {other:?}"),
}
match public_key_wire_version_to_suite(0x02) {
Err(CryptoError::UnsupportedVersion(UnsupportedVersion::NewerPublicKey {
version: 0x02,
})) => {}
other => panic!("expected NewerPublicKey(0x02), got {other:?}"),
}
match public_key_wire_version_to_suite(0x7F) {
Err(CryptoError::UnsupportedVersion(UnsupportedVersion::NewerPublicKey {
version: 0x7F,
})) => {}
other => panic!("expected NewerPublicKey(0x7F), got {other:?}"),
}
}
#[test]
fn v1_recipient_string_decodes_with_keypair_suite_v1() {
let s = encode_recipient_string("x25519", &x25519_key()).unwrap();
let decoded = decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT).unwrap();
assert_eq!(decoded.keypair_suite, KeypairSuite::V1);
}
#[test]
fn public_and_private_v1_wire_encodings_share_keypair_suite() {
use crate::key::private::{PRIVATE_KEY_V1_VERSION, private_key_wire_version_to_suite};
let public_suite = public_key_wire_version_to_suite(PUBLIC_KEY_V1_VERSION).unwrap();
let private_suite = private_key_wire_version_to_suite(PRIVATE_KEY_V1_VERSION).unwrap();
assert_eq!(public_suite, private_suite);
assert_eq!(public_suite, KeypairSuite::V1);
}
#[test]
fn public_key_version_derives_from_keypair_suite() {
assert_eq!(
PUBLIC_KEY_VERSION,
WRITER_KEYPAIR_SUITE.public_key_version()
);
}
#[test]
fn round_trip_x25519() {
let key = x25519_key();
let s = encode_recipient_string("x25519", &key).unwrap();
assert!(s.starts_with("fcr1"));
let decoded = decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT).unwrap();
assert_eq!(decoded.type_name, "x25519");
assert_eq!(decoded.key_material, key);
}
#[test]
fn round_trip_lifts_default_bech32_code_length_cap() {
let large_key = vec![0xA5u8; 1024];
let s = encode_recipient_string("future", &large_key).unwrap();
assert!(
s.len() > 1023,
"expected encoded length > 1023, got {}",
s.len()
);
let decoded = decode_recipient_string(&s, RECIPIENT_STRING_LEN_MAX).unwrap();
assert_eq!(decoded.type_name, "future");
assert_eq!(decoded.key_material, large_key);
}
#[test]
fn encoded_string_is_lowercase() {
let s = encode_recipient_string("x25519", &x25519_key()).unwrap();
assert!(s.chars().all(|c| !c.is_ascii_uppercase()));
}
#[test]
fn encode_rejects_malformed_type_name() {
match encode_recipient_string("X25519", &x25519_key()) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedTypeName)) => {}
other => panic!("expected MalformedTypeName for uppercase, got {other:?}"),
}
}
#[test]
fn encode_rejects_oversized_key_material() {
let oversize = vec![0u8; (KEY_MATERIAL_LEN_MAX as usize) + 1];
match encode_recipient_string("x25519", &oversize) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => panic!("expected MalformedPublicKey, got {other:?}"),
}
}
#[test]
fn decode_rejects_uppercase_input() {
let s = encode_recipient_string("x25519", &x25519_key()).unwrap();
let upper = s.to_uppercase();
match decode_recipient_string(&upper, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidInput(_)) => {}
other => panic!("expected InvalidInput for uppercase, got {other:?}"),
}
}
#[test]
fn decode_rejects_local_cap_with_typed_variant() {
let s = encode_recipient_string("x25519", &x25519_key()).unwrap();
match decode_recipient_string(&s, 10) {
Err(CryptoError::RecipientStringCapExceeded {
input_chars,
local_cap,
}) => {
assert_eq!(input_chars as usize, s.len());
assert_eq!(local_cap, 10);
}
other => panic!("expected RecipientStringCapExceeded, got {other:?}"),
}
}
#[test]
fn decode_rejects_non_ascii_before_cap_check() {
let s = "fcr1日本語";
match decode_recipient_string(s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidInput(msg)) => {
assert!(msg.contains("ASCII Bech32"), "unexpected message: {msg}");
}
other => panic!("expected InvalidInput(ASCII Bech32), got {other:?}"),
}
}
#[test]
fn decode_rejects_non_ascii_with_byte_length_above_cap_as_invalid_input() {
let s = "日本語日本語日本";
assert_eq!(s.chars().count(), 8);
assert_eq!(s.len(), 24);
match decode_recipient_string(s, 10) {
Err(CryptoError::InvalidInput(msg)) => {
assert!(msg.contains("ASCII Bech32"), "unexpected message: {msg}");
}
other => panic!("expected InvalidInput(ASCII Bech32), got {other:?}"),
}
}
#[test]
fn decode_rejects_wrong_hrp() {
let other_hrp = Hrp::parse_unchecked("foo");
let data = b"abcdefghijklmnopqrstuvwxyz0123";
let s = bech32::encode::<Bech32>(other_hrp, data).unwrap();
match decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidInput(msg)) => {
assert!(msg.contains("Unexpected recipient prefix"), "msg: {msg}");
}
other => panic!("expected InvalidInput for HRP, got {other:?}"),
}
}
#[test]
fn decode_rejects_invalid_bech32() {
match decode_recipient_string(
"fcr1notavalidbech32",
RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT,
) {
Err(CryptoError::InvalidInput(_)) => {}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn decode_rejects_bech32m_strings() {
let key = x25519_key();
let cs = compute_checksum(PUBLIC_KEY_VERSION, "x25519", &key);
let mut data = Vec::new();
data.push(PUBLIC_KEY_VERSION);
data.extend_from_slice(&6u16.to_be_bytes());
data.extend_from_slice(&32u32.to_be_bytes());
data.extend_from_slice(b"x25519");
data.extend_from_slice(&key);
data.extend_from_slice(&cs);
let bech32m = bech32::encode::<bech32::Bech32m>(RECIPIENT_HRP, &data).unwrap();
match decode_recipient_string(&bech32m, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidInput(_)) => {}
other => panic!("expected InvalidInput for Bech32m, got {other:?}"),
}
}
#[test]
fn decode_rejects_non_utf8_type_name_bytes() {
let mut data = Vec::new();
data.push(PUBLIC_KEY_VERSION);
data.extend_from_slice(&6u16.to_be_bytes());
data.extend_from_slice(&0u32.to_be_bytes());
data.extend_from_slice(&[0xFFu8; 6]); data.extend_from_slice(&[0u8; PUBLIC_KEY_CHECKSUM_SIZE]);
let s = bech32::encode::<Bech32V1>(RECIPIENT_HRP, &data).unwrap();
match decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedTypeName)) => {}
other => panic!("expected MalformedTypeName for non-UTF-8, got {other:?}"),
}
}
#[test]
fn decode_rejects_internal_checksum_mismatch() {
let key = x25519_key();
let original = encode_recipient_string("x25519", &key).unwrap();
let checked = CheckedHrpstring::new::<Bech32>(&original).unwrap();
let mut data: Vec<u8> = checked.byte_iter().collect();
data[PAYLOAD_HEADER_SIZE] ^= 0x01;
let tampered = bech32::encode::<Bech32>(RECIPIENT_HRP, &data).unwrap();
match decode_recipient_string(&tampered, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => {
panic!("expected MalformedPublicKey for inner-checksum mismatch, got {other:?}")
}
}
}
#[test]
fn decode_rejects_truncated_payload() {
let too_short = vec![0u8; PAYLOAD_HEADER_SIZE + PUBLIC_KEY_CHECKSUM_SIZE - 1];
let s = bech32::encode::<Bech32>(RECIPIENT_HRP, &too_short).unwrap();
match decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => panic!("expected MalformedPublicKey for truncated, got {other:?}"),
}
}
#[test]
fn decode_rejects_zero_type_name_len() {
let mut data = Vec::new();
data.push(PUBLIC_KEY_VERSION);
data.extend_from_slice(&0u16.to_be_bytes()); data.extend_from_slice(&0u32.to_be_bytes()); data.extend_from_slice(&[0u8; PUBLIC_KEY_CHECKSUM_SIZE]);
let s = bech32::encode::<Bech32>(RECIPIENT_HRP, &data).unwrap();
match decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => panic!("expected MalformedPublicKey for zero type_name_len, got {other:?}"),
}
}
#[test]
fn decode_rejects_overlong_type_name_len() {
let mut data = Vec::new();
data.push(PUBLIC_KEY_VERSION);
data.extend_from_slice(&((TYPE_NAME_MAX_LEN as u16) + 1).to_be_bytes());
data.extend_from_slice(&0u32.to_be_bytes());
data.extend_from_slice(&[0u8; PUBLIC_KEY_CHECKSUM_SIZE]);
let s = bech32::encode::<Bech32>(RECIPIENT_HRP, &data).unwrap();
match decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => panic!("expected MalformedPublicKey, got {other:?}"),
}
}
#[test]
fn decode_rejects_oversized_key_material_len() {
let mut data = Vec::new();
data.push(PUBLIC_KEY_VERSION);
data.extend_from_slice(&6u16.to_be_bytes());
data.extend_from_slice(&(KEY_MATERIAL_LEN_MAX + 1).to_be_bytes());
data.extend_from_slice(b"x25519");
data.extend_from_slice(&[0u8; PUBLIC_KEY_CHECKSUM_SIZE]);
let s = bech32::encode::<Bech32>(RECIPIENT_HRP, &data).unwrap();
match decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => panic!("expected MalformedPublicKey, got {other:?}"),
}
}
#[test]
fn decode_rejects_total_size_mismatch() {
let mut data = Vec::new();
data.push(PUBLIC_KEY_VERSION);
data.extend_from_slice(&6u16.to_be_bytes());
data.extend_from_slice(&32u32.to_be_bytes());
data.extend_from_slice(b"x25519");
data.extend(std::iter::repeat_n(0u8, 32));
data.extend(std::iter::repeat_n(0u8, PUBLIC_KEY_CHECKSUM_SIZE));
data.push(0); let s = bech32::encode::<Bech32>(RECIPIENT_HRP, &data).unwrap();
match decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => panic!("expected MalformedPublicKey for total mismatch, got {other:?}"),
}
}
#[test]
fn decode_rejects_malformed_type_name_grammar() {
let key = x25519_key();
let cs = compute_checksum(PUBLIC_KEY_VERSION, "X25519", &key);
let mut data = Vec::new();
data.push(PUBLIC_KEY_VERSION);
data.extend_from_slice(&6u16.to_be_bytes());
data.extend_from_slice(&32u32.to_be_bytes());
data.extend_from_slice(b"X25519");
data.extend_from_slice(&key);
data.extend_from_slice(&cs);
let s = bech32::encode::<Bech32>(RECIPIENT_HRP, &data).unwrap();
match decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedTypeName)) => {}
other => panic!("expected MalformedTypeName for uppercase, got {other:?}"),
}
}
#[test]
fn fingerprint_is_deterministic() {
let key = x25519_key();
let a = fingerprint_bytes("x25519", &key);
let b = fingerprint_bytes("x25519", &key);
assert_eq!(a, b);
}
#[test]
fn fingerprint_separates_type_name_namespace() {
let key = x25519_key();
let a = fingerprint_bytes("x25519", &key);
let b = fingerprint_bytes("y25519", &key);
assert_ne!(a, b);
}
#[test]
fn fingerprint_is_independent_of_checksum_domain() {
let key = x25519_key();
let mut hasher = Sha3_256::new();
hasher.update(b"x25519");
hasher.update([0x00]);
hasher.update(key);
let expected: [u8; 32] = hasher.finalize().into();
assert_eq!(fingerprint_bytes("x25519", &key), expected);
}
#[test]
fn fingerprint_hex_is_64_lowercase_chars() {
let hex = fingerprint_hex("x25519", &x25519_key());
assert_eq!(hex.len(), 64);
assert!(
hex.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
);
}
#[test]
fn checksum_domain_is_canonical() {
assert_eq!(
PUBLIC_KEY_CHECKSUM_DOMAIN,
b"ferrocrypt/v1/public-key/checksum"
);
}
#[test]
fn recipient_hrp_is_canonical() {
assert_eq!(RECIPIENT_HRP.as_str(), "fcr");
}
#[test]
fn key_material_len_max_fits_within_spec_ceiling() {
let big_type_name = "a".repeat(TYPE_NAME_MAX_LEN);
let big_key = vec![0xA5u8; KEY_MATERIAL_LEN_MAX as usize];
let s = encode_recipient_string(&big_type_name, &big_key).unwrap();
assert!(
s.len() <= RECIPIENT_STRING_LEN_MAX,
"encoded length {} exceeds spec ceiling {}",
s.len(),
RECIPIENT_STRING_LEN_MAX
);
let one_too_big = vec![0u8; (KEY_MATERIAL_LEN_MAX as usize) + 1];
match encode_recipient_string(&big_type_name, &one_too_big) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => panic!("expected MalformedPublicKey for key_material > cap, got {other:?}"),
}
}
#[test]
fn key_material_len_max_matches_spec_text() {
assert_eq!(KEY_MATERIAL_LEN_MAX, 12_215);
}
#[test]
fn public_key_from_bytes_rejects_all_zero() {
match PublicKey::from_bytes([0u8; 32]) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => panic!("expected MalformedPublicKey for all-zero public_key, got {other:?}"),
}
}
#[test]
fn public_key_from_recipient_string_rejects_all_zero() {
let s = encode_recipient_string(X25519_TYPE_NAME, &[0u8; 32]).unwrap();
match PublicKey::from_recipient_string(&s) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => {
panic!("expected MalformedPublicKey for all-zero recipient string, got {other:?}")
}
}
}
#[test]
fn read_public_key_rejects_all_zero_on_disk() {
let s = encode_recipient_string(X25519_TYPE_NAME, &[0u8; 32]).unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), s.as_bytes()).unwrap();
match read_public_key(tmp.path()) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
other => {
panic!("expected MalformedPublicKey for all-zero on-disk public key, got {other:?}")
}
}
}
#[test]
fn from_bytes_pins_writer_keypair_suite() {
let pk = PublicKey::from_bytes(x25519_key()).unwrap();
let resolved = pk.resolve().unwrap();
assert_eq!(resolved.suite, WRITER_KEYPAIR_SUITE);
assert_eq!(resolved.bytes, x25519_key());
}
#[test]
fn from_recipient_string_preserves_suite_in_round_trip() {
let key = x25519_key();
let original =
encode_recipient_string_for_suite(KeypairSuite::V1, X25519_TYPE_NAME, &key).unwrap();
let pk = PublicKey::from_recipient_string(&original).unwrap();
let resolved = pk.resolve().unwrap();
assert_eq!(resolved.suite, KeypairSuite::V1);
assert_eq!(resolved.bytes, key);
let re_encoded = pk.to_recipient_string().unwrap();
assert_eq!(re_encoded, original);
}
#[test]
fn read_public_key_preserves_keypair_suite() {
let key = x25519_key();
let s =
encode_recipient_string_for_suite(KeypairSuite::V1, X25519_TYPE_NAME, &key).unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), s.as_bytes()).unwrap();
let resolved = read_public_key(tmp.path()).unwrap();
assert_eq!(resolved.suite, KeypairSuite::V1);
assert_eq!(resolved.bytes, key);
}
#[test]
fn from_bytes_to_recipient_string_uses_writer_suite_wire_byte() {
let pk = PublicKey::from_bytes(x25519_key()).unwrap();
let s = pk.to_recipient_string().unwrap();
let decoded = decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT).unwrap();
assert_eq!(decoded.keypair_suite, WRITER_KEYPAIR_SUITE);
}
#[test]
fn encode_recipient_string_for_suite_emits_supplied_suite() {
let key = x25519_key();
let s =
encode_recipient_string_for_suite(KeypairSuite::V1, X25519_TYPE_NAME, &key).unwrap();
let decoded = decode_recipient_string(&s, RECIPIENT_STRING_LEN_LOCAL_CAP_DEFAULT).unwrap();
assert_eq!(decoded.keypair_suite, KeypairSuite::V1);
}
}