use std::collections::BTreeSet;
use crate::cbor::{decode_canonical_cbor, encode_canonical_cbor, CborValue};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ErrorCode {
MalformedCbor,
SchemaTypeMismatch,
SchemaMissingRequired,
SchemaUnknownField,
SchemaInvalidLiteral,
SchemaEmptyRecord,
HashDigestLengthMismatch,
UnsupportedHashAlg,
UnsupportedMerkleCommitAlg,
InvalidUri,
ChunkTooLarge,
UnauthenticatedCipherForbidden,
UnsupportedAeadAlg,
NonceLengthMismatch,
UnsupportedEnvelopeScheme,
EncSlotsEmpty,
EncSlotInvalidShape,
UnsupportedKemAlg,
EncKemRequired,
KemEpkLengthMismatch,
KemCtLengthMismatch,
WrapLengthMismatch,
EncSlotsMacInvalidLength,
EncSlotsMacRequired,
EncSlotsRequired,
EncExclusivityViolation,
EncNoKeyPath,
EncRequiresContentHash,
EncPassphraseAlgUnsupported,
EncPassphraseSaltTooShort,
EncPassphraseSaltTooLong,
EncPassphraseArgon2ParamsTooLow,
EncPassphraseParamsExceedPolicy,
MalformedSigCoseSign1,
SignatureUnsupported,
SigEntryInvalidShape,
SigEntryKidCoseKeyConflict,
SigPrivateKeyLeaked,
SupersedesTxInvalidLength,
ExtensionUnsupportedCritical,
CritShapeInvalid,
MetadataNotFound,
InsufficientConfirmations,
SignatureInvalid,
SignerKeyUnresolved,
WalletAddressMismatch,
UriTargetForbidden,
UriIntegrityMismatch,
UriFetchFailed,
ContentUnavailable,
CiphertextUnavailable,
ProviderUnavailable,
ServiceIndependenceViolation,
WrongDecryptionInputShape,
WrongRecipientKey,
TamperedHeader,
TamperedCiphertext,
KdfDerivationFailed,
SchemaMerkleLeafCountMismatch,
SchemaMerkleLeavesFormatUnsupported,
SchemaMerkleLeavesMalformed,
MerkleRootMismatch,
MerkleLeavesUnavailable,
MerkleLeavesInformativeForm,
MerkleUnsupported,
OutOfProfileSkipped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Severity {
Error,
Warning,
Info,
}
pub const STRUCTURAL_ERROR_CODES: &[ErrorCode] = &[
ErrorCode::MalformedCbor,
ErrorCode::SchemaTypeMismatch,
ErrorCode::SchemaMissingRequired,
ErrorCode::SchemaUnknownField,
ErrorCode::SchemaInvalidLiteral,
ErrorCode::SchemaEmptyRecord,
ErrorCode::HashDigestLengthMismatch,
ErrorCode::UnsupportedHashAlg,
ErrorCode::UnsupportedMerkleCommitAlg,
ErrorCode::InvalidUri,
ErrorCode::ChunkTooLarge,
ErrorCode::UnauthenticatedCipherForbidden,
ErrorCode::UnsupportedAeadAlg,
ErrorCode::NonceLengthMismatch,
ErrorCode::UnsupportedEnvelopeScheme,
ErrorCode::EncSlotsEmpty,
ErrorCode::EncSlotInvalidShape,
ErrorCode::UnsupportedKemAlg,
ErrorCode::EncKemRequired,
ErrorCode::KemEpkLengthMismatch,
ErrorCode::KemCtLengthMismatch,
ErrorCode::WrapLengthMismatch,
ErrorCode::EncSlotsMacInvalidLength,
ErrorCode::EncSlotsMacRequired,
ErrorCode::EncSlotsRequired,
ErrorCode::EncExclusivityViolation,
ErrorCode::EncNoKeyPath,
ErrorCode::EncRequiresContentHash,
ErrorCode::EncPassphraseAlgUnsupported,
ErrorCode::EncPassphraseSaltTooShort,
ErrorCode::EncPassphraseSaltTooLong,
ErrorCode::EncPassphraseArgon2ParamsTooLow,
ErrorCode::EncPassphraseParamsExceedPolicy,
ErrorCode::MalformedSigCoseSign1,
ErrorCode::SignatureUnsupported,
ErrorCode::SigEntryInvalidShape,
ErrorCode::SigEntryKidCoseKeyConflict,
ErrorCode::SigPrivateKeyLeaked,
ErrorCode::SupersedesTxInvalidLength,
ErrorCode::ExtensionUnsupportedCritical,
ErrorCode::CritShapeInvalid,
];
pub const VERIFIER_ERROR_CODES: &[ErrorCode] = &[
ErrorCode::MetadataNotFound,
ErrorCode::InsufficientConfirmations,
ErrorCode::SignatureInvalid,
ErrorCode::SignerKeyUnresolved,
ErrorCode::WalletAddressMismatch,
ErrorCode::UriTargetForbidden,
ErrorCode::UriIntegrityMismatch,
ErrorCode::UriFetchFailed,
ErrorCode::ContentUnavailable,
ErrorCode::CiphertextUnavailable,
ErrorCode::ProviderUnavailable,
ErrorCode::ServiceIndependenceViolation,
ErrorCode::WrongDecryptionInputShape,
ErrorCode::WrongRecipientKey,
ErrorCode::TamperedHeader,
ErrorCode::TamperedCiphertext,
ErrorCode::KdfDerivationFailed,
ErrorCode::SchemaMerkleLeafCountMismatch,
ErrorCode::SchemaMerkleLeavesFormatUnsupported,
ErrorCode::SchemaMerkleLeavesMalformed,
ErrorCode::MerkleRootMismatch,
ErrorCode::MerkleLeavesUnavailable,
ErrorCode::MerkleLeavesInformativeForm,
ErrorCode::MerkleUnsupported,
ErrorCode::OutOfProfileSkipped,
];
impl ErrorCode {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
ErrorCode::MalformedCbor => "MALFORMED_CBOR",
ErrorCode::SchemaTypeMismatch => "SCHEMA_TYPE_MISMATCH",
ErrorCode::SchemaMissingRequired => "SCHEMA_MISSING_REQUIRED",
ErrorCode::SchemaUnknownField => "SCHEMA_UNKNOWN_FIELD",
ErrorCode::SchemaInvalidLiteral => "SCHEMA_INVALID_LITERAL",
ErrorCode::SchemaEmptyRecord => "SCHEMA_EMPTY_RECORD",
ErrorCode::HashDigestLengthMismatch => "HASH_DIGEST_LENGTH_MISMATCH",
ErrorCode::UnsupportedHashAlg => "UNSUPPORTED_HASH_ALG",
ErrorCode::UnsupportedMerkleCommitAlg => "UNSUPPORTED_MERKLE_COMMIT_ALG",
ErrorCode::InvalidUri => "INVALID_URI",
ErrorCode::ChunkTooLarge => "CHUNK_TOO_LARGE",
ErrorCode::UnauthenticatedCipherForbidden => "UNAUTHENTICATED_CIPHER_FORBIDDEN",
ErrorCode::UnsupportedAeadAlg => "UNSUPPORTED_AEAD_ALG",
ErrorCode::NonceLengthMismatch => "NONCE_LENGTH_MISMATCH",
ErrorCode::UnsupportedEnvelopeScheme => "UNSUPPORTED_ENVELOPE_SCHEME",
ErrorCode::EncSlotsEmpty => "ENC_SLOTS_EMPTY",
ErrorCode::EncSlotInvalidShape => "ENC_SLOT_INVALID_SHAPE",
ErrorCode::UnsupportedKemAlg => "UNSUPPORTED_KEM_ALG",
ErrorCode::EncKemRequired => "ENC_KEM_REQUIRED",
ErrorCode::KemEpkLengthMismatch => "KEM_EPK_LENGTH_MISMATCH",
ErrorCode::KemCtLengthMismatch => "KEM_CT_LENGTH_MISMATCH",
ErrorCode::WrapLengthMismatch => "WRAP_LENGTH_MISMATCH",
ErrorCode::EncSlotsMacInvalidLength => "ENC_SLOTS_MAC_INVALID_LENGTH",
ErrorCode::EncSlotsMacRequired => "ENC_SLOTS_MAC_REQUIRED",
ErrorCode::EncSlotsRequired => "ENC_SLOTS_REQUIRED",
ErrorCode::EncExclusivityViolation => "ENC_EXCLUSIVITY_VIOLATION",
ErrorCode::EncNoKeyPath => "ENC_NO_KEY_PATH",
ErrorCode::EncRequiresContentHash => "ENC_REQUIRES_CONTENT_HASH",
ErrorCode::EncPassphraseAlgUnsupported => "ENC_PASSPHRASE_ALG_UNSUPPORTED",
ErrorCode::EncPassphraseSaltTooShort => "ENC_PASSPHRASE_SALT_TOO_SHORT",
ErrorCode::EncPassphraseSaltTooLong => "ENC_PASSPHRASE_SALT_TOO_LONG",
ErrorCode::EncPassphraseArgon2ParamsTooLow => "ENC_PASSPHRASE_ARGON2_PARAMS_TOO_LOW",
ErrorCode::EncPassphraseParamsExceedPolicy => "ENC_PASSPHRASE_PARAMS_EXCEED_POLICY",
ErrorCode::MalformedSigCoseSign1 => "MALFORMED_SIG_COSE_SIGN1",
ErrorCode::SignatureUnsupported => "SIGNATURE_UNSUPPORTED",
ErrorCode::SigEntryInvalidShape => "SIG_ENTRY_INVALID_SHAPE",
ErrorCode::SigEntryKidCoseKeyConflict => "SIG_ENTRY_KID_COSE_KEY_CONFLICT",
ErrorCode::SigPrivateKeyLeaked => "SIG_PRIVATE_KEY_LEAKED",
ErrorCode::SupersedesTxInvalidLength => "SUPERSEDES_TX_INVALID_LENGTH",
ErrorCode::ExtensionUnsupportedCritical => "EXTENSION_UNSUPPORTED_CRITICAL",
ErrorCode::CritShapeInvalid => "CRIT_SHAPE_INVALID",
ErrorCode::MetadataNotFound => "METADATA_NOT_FOUND",
ErrorCode::InsufficientConfirmations => "INSUFFICIENT_CONFIRMATIONS",
ErrorCode::SignatureInvalid => "SIGNATURE_INVALID",
ErrorCode::SignerKeyUnresolved => "SIGNER_KEY_UNRESOLVED",
ErrorCode::WalletAddressMismatch => "WALLET_ADDRESS_MISMATCH",
ErrorCode::UriTargetForbidden => "URI_TARGET_FORBIDDEN",
ErrorCode::UriIntegrityMismatch => "URI_INTEGRITY_MISMATCH",
ErrorCode::UriFetchFailed => "URI_FETCH_FAILED",
ErrorCode::ContentUnavailable => "CONTENT_UNAVAILABLE",
ErrorCode::CiphertextUnavailable => "CIPHERTEXT_UNAVAILABLE",
ErrorCode::ProviderUnavailable => "PROVIDER_UNAVAILABLE",
ErrorCode::ServiceIndependenceViolation => "SERVICE_INDEPENDENCE_VIOLATION",
ErrorCode::WrongDecryptionInputShape => "WRONG_DECRYPTION_INPUT_SHAPE",
ErrorCode::WrongRecipientKey => "WRONG_RECIPIENT_KEY",
ErrorCode::TamperedHeader => "TAMPERED_HEADER",
ErrorCode::TamperedCiphertext => "TAMPERED_CIPHERTEXT",
ErrorCode::KdfDerivationFailed => "KDF_DERIVATION_FAILED",
ErrorCode::SchemaMerkleLeafCountMismatch => "SCHEMA_MERKLE_LEAF_COUNT_MISMATCH",
ErrorCode::SchemaMerkleLeavesFormatUnsupported => {
"SCHEMA_MERKLE_LEAVES_FORMAT_UNSUPPORTED"
}
ErrorCode::SchemaMerkleLeavesMalformed => "SCHEMA_MERKLE_LEAVES_MALFORMED",
ErrorCode::MerkleRootMismatch => "MERKLE_ROOT_MISMATCH",
ErrorCode::MerkleLeavesUnavailable => "MERKLE_LEAVES_UNAVAILABLE",
ErrorCode::MerkleLeavesInformativeForm => "MERKLE_LEAVES_INFORMATIVE_FORM",
ErrorCode::MerkleUnsupported => "MERKLE_UNSUPPORTED",
ErrorCode::OutOfProfileSkipped => "OUT_OF_PROFILE_SKIPPED",
}
}
#[must_use]
pub const fn severity(self) -> Severity {
match self {
ErrorCode::SignatureUnsupported
| ErrorCode::InsufficientConfirmations
| ErrorCode::MerkleLeavesInformativeForm
| ErrorCode::MerkleUnsupported
| ErrorCode::OutOfProfileSkipped => Severity::Info,
ErrorCode::UriFetchFailed | ErrorCode::MerkleLeavesUnavailable => Severity::Warning,
_ => Severity::Error,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PoeRecord {
pub v: u64,
pub items: Option<Vec<ItemEntry>>,
pub merkle: Option<Vec<MerkleCommit>>,
pub supersedes: Option<Vec<u8>>,
pub sigs: Option<Vec<SigEntry>>,
pub crit: Option<Vec<String>>,
pub extensions: Vec<(String, CborValue)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemEntry {
pub hashes: Vec<(String, Vec<u8>)>,
pub uris: Option<Vec<Vec<String>>>,
pub enc: Option<EncryptionEnvelope>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MerkleCommit {
pub alg: String,
pub root: Vec<u8>,
pub leaf_count: u64,
pub uris: Option<Vec<Vec<String>>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptionEnvelope {
pub scheme: u64,
pub aead: String,
pub nonce: Vec<u8>,
pub kem: Option<String>,
pub slots: Option<Vec<Slot>>,
pub slots_mac: Option<Vec<u8>>,
pub passphrase: Option<PassphraseBlock>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Slot {
pub epk: Option<Vec<u8>>,
pub kem_ct: Option<Vec<Vec<u8>>>,
pub wrap: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PassphraseBlock {
pub alg: String,
pub salt: Vec<u8>,
pub params: Vec<(String, u64)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SigEntry {
pub cose_sign1: Vec<Vec<u8>>,
pub cose_key: Option<Vec<Vec<u8>>>,
}
pub fn encode_poe_record(record: &PoeRecord) -> Result<Vec<u8>, crate::cbor::CanonicalCborError> {
encode_canonical_cbor(&record_to_cbor(record, true))
}
pub fn encode_record_body_for_signing(
record: &PoeRecord,
) -> Result<Vec<u8>, crate::cbor::CanonicalCborError> {
encode_canonical_cbor(&record_to_cbor(record, false))
}
fn record_to_cbor(record: &PoeRecord, include_sigs: bool) -> CborValue {
let mut pairs: Vec<(CborValue, CborValue)> = Vec::new();
pairs.push((CborValue::text("v"), CborValue::Unsigned(record.v)));
if let Some(items) = &record.items {
pairs.push((
CborValue::text("items"),
CborValue::Array(items.iter().map(item_to_cbor).collect()),
));
}
if let Some(merkle) = &record.merkle {
pairs.push((
CborValue::text("merkle"),
CborValue::Array(merkle.iter().map(merkle_to_cbor).collect()),
));
}
if let Some(supersedes) = &record.supersedes {
pairs.push((
CborValue::text("supersedes"),
CborValue::Bytes(supersedes.clone()),
));
}
if include_sigs {
if let Some(sigs) = &record.sigs {
pairs.push((
CborValue::text("sigs"),
CborValue::Array(sigs.iter().map(sig_entry_to_cbor).collect()),
));
}
}
if let Some(crit) = &record.crit {
pairs.push((
CborValue::text("crit"),
CborValue::Array(crit.iter().map(CborValue::text).collect()),
));
}
for (key, value) in &record.extensions {
pairs.push((CborValue::text(key.clone()), value.clone()));
}
CborValue::Map(pairs)
}
fn item_to_cbor(item: &ItemEntry) -> CborValue {
let mut pairs: Vec<(CborValue, CborValue)> = Vec::new();
let hashes = item
.hashes
.iter()
.map(|(alg, digest)| {
(
CborValue::text(alg.clone()),
CborValue::Bytes(digest.clone()),
)
})
.collect();
pairs.push((CborValue::text("hashes"), CborValue::Map(hashes)));
if let Some(uris) = &item.uris {
pairs.push((CborValue::text("uris"), uris_to_cbor(uris)));
}
if let Some(enc) = &item.enc {
pairs.push((CborValue::text("enc"), envelope_to_cbor(enc)));
}
CborValue::Map(pairs)
}
fn merkle_to_cbor(commit: &MerkleCommit) -> CborValue {
let mut pairs: Vec<(CborValue, CborValue)> = vec![
(CborValue::text("alg"), CborValue::text(commit.alg.clone())),
(
CborValue::text("root"),
CborValue::Bytes(commit.root.clone()),
),
(
CborValue::text("leaf_count"),
CborValue::Unsigned(commit.leaf_count),
),
];
if let Some(uris) = &commit.uris {
pairs.push((CborValue::text("uris"), uris_to_cbor(uris)));
}
CborValue::Map(pairs)
}
fn uris_to_cbor(uris: &[Vec<String>]) -> CborValue {
CborValue::Array(
uris.iter()
.map(|chunks| CborValue::Array(chunks.iter().map(CborValue::text).collect()))
.collect(),
)
}
fn envelope_to_cbor(enc: &EncryptionEnvelope) -> CborValue {
let mut pairs: Vec<(CborValue, CborValue)> = vec![
(CborValue::text("scheme"), CborValue::Unsigned(enc.scheme)),
(CborValue::text("aead"), CborValue::text(enc.aead.clone())),
(
CborValue::text("nonce"),
CborValue::Bytes(enc.nonce.clone()),
),
];
if let Some(kem) = &enc.kem {
pairs.push((CborValue::text("kem"), CborValue::text(kem.clone())));
}
if let Some(slots) = &enc.slots {
pairs.push((
CborValue::text("slots"),
CborValue::Array(slots.iter().map(slot_to_cbor).collect()),
));
}
if let Some(slots_mac) = &enc.slots_mac {
pairs.push((
CborValue::text("slots_mac"),
CborValue::Bytes(slots_mac.clone()),
));
}
if let Some(passphrase) = &enc.passphrase {
pairs.push((
CborValue::text("passphrase"),
passphrase_to_cbor(passphrase),
));
}
CborValue::Map(pairs)
}
fn slot_to_cbor(slot: &Slot) -> CborValue {
let wrap = CborValue::Bytes(slot.wrap.clone().unwrap_or_default());
if let Some(kem_ct) = &slot.kem_ct {
return CborValue::Map(vec![
(
CborValue::text("kem_ct"),
CborValue::Array(kem_ct.iter().map(|c| CborValue::Bytes(c.clone())).collect()),
),
(CborValue::text("wrap"), wrap),
]);
}
CborValue::Map(vec![
(
CborValue::text("epk"),
CborValue::Bytes(slot.epk.clone().unwrap_or_default()),
),
(CborValue::text("wrap"), wrap),
])
}
fn passphrase_to_cbor(pp: &PassphraseBlock) -> CborValue {
let params = pp
.params
.iter()
.map(|(name, value)| (CborValue::text(name.clone()), CborValue::Unsigned(*value)))
.collect();
CborValue::Map(vec![
(CborValue::text("alg"), CborValue::text(pp.alg.clone())),
(CborValue::text("salt"), CborValue::Bytes(pp.salt.clone())),
(CborValue::text("params"), CborValue::Map(params)),
])
}
fn sig_entry_to_cbor(entry: &SigEntry) -> CborValue {
let mut pairs: Vec<(CborValue, CborValue)> = vec![(
CborValue::text("cose_sign1"),
CborValue::Array(
entry
.cose_sign1
.iter()
.map(|c| CborValue::Bytes(c.clone()))
.collect(),
),
)];
if let Some(cose_key) = &entry.cose_key {
pairs.push((
CborValue::text("cose_key"),
CborValue::Array(
cose_key
.iter()
.map(|c| CborValue::Bytes(c.clone()))
.collect(),
),
));
}
CborValue::Map(pairs)
}
const CHUNK_MAX_BYTES: usize = 64;
#[must_use]
pub fn chunk_bytes(value: &[u8]) -> Vec<Vec<u8>> {
if value.is_empty() {
return vec![Vec::new()];
}
value.chunks(CHUNK_MAX_BYTES).map(<[u8]>::to_vec).collect()
}
#[must_use]
pub fn bytes_chunk_array_concat(chunks: &[Vec<u8>]) -> Vec<u8> {
let mut out = Vec::with_capacity(chunks.iter().map(Vec::len).sum());
for chunk in chunks {
out.extend_from_slice(chunk);
}
out
}
#[must_use]
pub fn chunk_uri(uri: &str) -> Vec<String> {
let bytes = uri.as_bytes();
if bytes.is_empty() {
return vec![String::new()];
}
if bytes.len() <= CHUNK_MAX_BYTES {
return vec![uri.to_string()];
}
let mut chunks: Vec<String> = Vec::new();
let mut cursor = 0;
while cursor < bytes.len() {
let mut end = (cursor + CHUNK_MAX_BYTES).min(bytes.len());
while end < bytes.len() && (bytes[end] & 0xc0) == 0x80 {
end -= 1;
}
chunks.push(String::from_utf8_lossy(&bytes[cursor..end]).into_owned());
cursor = end;
}
chunks
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReconstructUriResult {
Ok(String),
Invalid,
}
#[must_use]
pub fn reconstruct_chunked_uri(chunks: &[String]) -> ReconstructUriResult {
let mut merged: Vec<u8> = Vec::new();
for chunk in chunks {
merged.extend_from_slice(chunk.as_bytes());
}
match String::from_utf8(merged) {
Ok(uri) => ReconstructUriResult::Ok(uri),
Err(_) => ReconstructUriResult::Invalid,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationIssue {
pub code: ErrorCode,
pub severity: Severity,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidateResult {
Ok {
record: Box<PoeRecord>,
info: Vec<ValidationIssue>,
warnings: Vec<ValidationIssue>,
},
Fail {
issues: Vec<ValidationIssue>,
},
}
impl ValidateResult {
#[must_use]
pub fn is_ok(&self) -> bool {
matches!(self, ValidateResult::Ok { .. })
}
#[must_use]
pub fn codes(&self) -> BTreeSet<ErrorCode> {
match self {
ValidateResult::Ok { info, warnings, .. } => {
info.iter().chain(warnings.iter()).map(|i| i.code).collect()
}
ValidateResult::Fail { issues } => issues.iter().map(|i| i.code).collect(),
}
}
}
const HASH_ALGS: &[(&str, usize)] = &[("sha2-256", 32), ("blake2b-256", 32)];
const MERKLE_COMMIT_ALGS: &[(&str, usize)] = &[("rfc9162-sha256", 32)];
const AEAD_NONCE_LENGTHS: &[(&str, usize)] = &[("xchacha20-poly1305", 24)];
const PASSPHRASE_ALGS: &[&str] = &["argon2id"];
const KNOWN_SIG_ALG_IDS: &[i64] = &[-8, -19];
const COSE_KEY_PRIVATE_MATERIAL_LABELS: &[i64] = &[-4];
const REGISTERED_RECORD_KEYS: &[&str] = &["v", "items", "merkle", "supersedes", "sigs", "crit"];
const REGISTERED_ITEM_KEYS: &[&str] = &["hashes", "uris", "enc"];
const REGISTERED_ENC_KEYS: &[&str] = &[
"scheme",
"aead",
"kem",
"nonce",
"slots",
"slots_mac",
"passphrase",
];
const REGISTERED_PASSPHRASE_KEYS: &[&str] = &["alg", "salt", "params"];
const REGISTERED_SLOT_KEYS: &[&str] = &["epk", "kem_ct", "wrap"];
const REGISTERED_SIG_ENTRY_KEYS: &[&str] = &["cose_sign1", "cose_key"];
const REGISTERED_MERKLE_COMMIT_KEYS: &[&str] = &["alg", "root", "leaf_count", "uris"];
const MLKEM768X25519_ENC_LENGTH: usize = 1120;
#[derive(Debug, Clone, Copy)]
struct KemSlotDescriptor {
field: &'static str,
field_length: usize,
wrap_length: usize,
}
fn kem_slot_descriptor(kem: &str) -> Option<KemSlotDescriptor> {
match kem {
"x25519" => Some(KemSlotDescriptor {
field: "epk",
field_length: 32,
wrap_length: 48,
}),
"mlkem768x25519" => Some(KemSlotDescriptor {
field: "kem_ct",
field_length: MLKEM768X25519_ENC_LENGTH,
wrap_length: 48,
}),
_ => None,
}
}
fn registry_lookup(registry: &[(&str, usize)], key: &str) -> Option<usize> {
registry
.iter()
.find(|(k, _)| *k == key)
.map(|(_, len)| *len)
}
fn is_extension_key(key: &str) -> bool {
let core = key.strip_suffix('\n').unwrap_or(key);
if core.contains('\n') {
return false;
}
if let Some(rest) = core.strip_prefix("x-") {
if !rest.is_empty() {
return true;
}
}
let bytes = core.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_lowercase() {
i += 1;
}
i >= 1 && i < bytes.len() && bytes[i] == b'-' && i + 1 < bytes.len()
}
fn is_unauthenticated_cipher(aead: &str) -> bool {
let lower = aead.to_ascii_lowercase();
let bytes = lower.as_bytes();
let is_delim = |b: u8| b == b'-' || b == b'_';
let is_end_boundary =
|after: usize| after == bytes.len() || (after + 1 == bytes.len() && bytes[after] == b'\n');
for mode in ["cbc", "ctr", "ecb", "cfb", "ofb"] {
let m = mode.as_bytes();
let mut start = 0;
while let Some(rel) = find_subslice(&bytes[start..], m) {
let idx = start + rel;
let before_ok = idx == 0 || is_delim(bytes[idx - 1]);
let after = idx + m.len();
let after_ok = is_end_boundary(after) || is_delim(bytes[after]);
if before_ok && after_ok {
return true;
}
start = idx + 1;
}
}
for legacy in ["rc4", "des", "3des"] {
let l = legacy.as_bytes();
if bytes.starts_with(l) {
let after = l.len();
if is_end_boundary(after) || is_delim(bytes[after]) {
return true;
}
}
}
false
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack.windows(needle.len()).position(|w| w == needle)
}
#[must_use]
pub fn validate_poe_record(bytes: &[u8]) -> ValidateResult {
let decoded = match decode_canonical_cbor(bytes) {
Ok(value) => value,
Err(cause) => {
return ValidateResult::Fail {
issues: vec![issue(
ErrorCode::MalformedCbor,
format!("cbor decode failed: {cause}"),
)],
};
}
};
let mut issues: Vec<ValidationIssue> = Vec::new();
let mut info: Vec<ValidationIssue> = Vec::new();
let record_map = match as_map(&decoded) {
Some(map) => map,
None => {
return ValidateResult::Fail {
issues: vec![issue(
ErrorCode::SchemaTypeMismatch,
"top-level value must be a CBOR map".to_string(),
)],
};
}
};
check_record_top_level_keys(record_map, &mut issues, &mut info);
match map_get(record_map, "v") {
None => issues.push(issue(
ErrorCode::SchemaMissingRequired,
"missing required field 'v'".to_string(),
)),
Some(v_val) => {
if !matches!(v_val, CborValue::Unsigned(1)) {
issues.push(issue(
ErrorCode::SchemaInvalidLiteral,
"v must be the unsigned integer 1".to_string(),
));
}
}
}
let mut items_non_empty = false;
let mut merkle_non_empty = false;
if let Some(items_raw) = map_get(record_map, "items") {
match items_raw {
CborValue::Array(items) => {
if !items.is_empty() {
items_non_empty = true;
for item in items {
validate_item_entry(item, &mut issues);
}
}
}
_ => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"items must be an array".to_string(),
)),
}
}
if let Some(merkle_raw) = map_get(record_map, "merkle") {
match merkle_raw {
CborValue::Array(commits) => {
if !commits.is_empty() {
merkle_non_empty = true;
for commit in commits {
validate_merkle_commit(commit, &mut issues);
}
}
}
_ => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"merkle must be an array".to_string(),
)),
}
}
if !items_non_empty && !merkle_non_empty {
issues.push(issue(
ErrorCode::SchemaEmptyRecord,
"record carries neither items (>=1) nor merkle (>=1)".to_string(),
));
}
if let Some(supersedes) = map_get(record_map, "supersedes") {
validate_supersedes(supersedes, &mut issues);
}
if map_get(record_map, "crit").is_some() {
validate_crit(record_map, &mut issues);
}
if let Some(sigs) = map_get(record_map, "sigs") {
validate_sigs(sigs, &mut issues, &mut info);
}
if !issues.is_empty() {
return ValidateResult::Fail { issues };
}
match record_from_cbor(&decoded) {
Some(record) => ValidateResult::Ok {
record: Box::new(record),
info,
warnings: Vec::new(),
},
None => ValidateResult::Fail {
issues: vec![issue(
ErrorCode::SchemaTypeMismatch,
"record decode produced an unexpected shape".to_string(),
)],
},
}
}
fn issue(code: ErrorCode, message: String) -> ValidationIssue {
ValidationIssue {
code,
severity: code.severity(),
message,
}
}
fn as_map(value: &CborValue) -> Option<&[(CborValue, CborValue)]> {
match value {
CborValue::Map(pairs) => Some(pairs),
_ => None,
}
}
fn map_get<'a>(pairs: &'a [(CborValue, CborValue)], key: &str) -> Option<&'a CborValue> {
pairs.iter().find_map(|(k, v)| match k {
CborValue::Text(t) if t == key => Some(v),
_ => None,
})
}
fn map_has(pairs: &[(CborValue, CborValue)], key: &str) -> bool {
map_get(pairs, key).is_some()
}
fn as_bytes(value: &CborValue) -> Option<&[u8]> {
match value {
CborValue::Bytes(b) => Some(b),
_ => None,
}
}
fn as_text(value: &CborValue) -> Option<&str> {
match value {
CborValue::Text(t) => Some(t),
_ => None,
}
}
fn check_record_top_level_keys(
record_map: &[(CborValue, CborValue)],
issues: &mut Vec<ValidationIssue>,
info: &mut Vec<ValidationIssue>,
) {
for (key, _) in record_map {
match key {
CborValue::Text(k) => {
if REGISTERED_RECORD_KEYS.contains(&k.as_str()) {
continue;
}
if is_extension_key(k) {
info.push(issue(
ErrorCode::OutOfProfileSkipped,
format!("top-level extension key '{k}' preserved but not interpreted"),
));
} else {
issues.push(issue(
ErrorCode::SchemaUnknownField,
format!("unknown record field: '{k}'"),
));
}
}
_ => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"top-level key must be a text string".to_string(),
)),
}
}
}
fn check_unknown_keys(
map: &[(CborValue, CborValue)],
allowed: &[&str],
label: &str,
issues: &mut Vec<ValidationIssue>,
) {
for (key, _) in map {
let ok = matches!(key, CborValue::Text(k) if allowed.contains(&k.as_str()));
if !ok {
issues.push(issue(
ErrorCode::SchemaUnknownField,
format!("unknown {label} field"),
));
}
}
}
fn validate_item_entry(item: &CborValue, issues: &mut Vec<ValidationIssue>) {
let item_map = match as_map(item) {
Some(map) => map,
None => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"item entry must be a map".to_string(),
));
return;
}
};
check_unknown_keys(item_map, REGISTERED_ITEM_KEYS, "item", issues);
let hashes_raw = map_get(item_map, "hashes");
match hashes_raw {
Some(CborValue::Map(m)) if !m.is_empty() => {
for (alg_key, digest) in m {
validate_hash_map_entry(alg_key, digest, issues);
}
}
_ => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"hashes must be a non-empty CBOR map of <alg-id> -> <digest>".to_string(),
)),
}
if let Some(uris) = map_get(item_map, "uris") {
validate_item_uris(uris, issues);
}
if let Some(enc) = map_get(item_map, "enc") {
let has_content_hash = matches!(hashes_raw, Some(CborValue::Map(m))
if m.iter().any(|(k, _)| matches!(k, CborValue::Text(t)
if HASH_ALGS.iter().any(|(alg, _)| alg == t))));
if !has_content_hash {
issues.push(issue(
ErrorCode::EncRequiresContentHash,
"item carries `enc` but `hashes` has no content-hash entry (sha2-256 or blake2b-256)"
.to_string(),
));
} else {
validate_encryption(enc, issues);
}
}
}
fn validate_hash_map_entry(alg: &CborValue, digest: &CborValue, issues: &mut Vec<ValidationIssue>) {
let alg_str = match alg {
CborValue::Text(t) => t.as_str(),
_ => {
issues.push(issue(
ErrorCode::UnsupportedHashAlg,
"hash alg must be a text string".to_string(),
));
return;
}
};
let expected = match registry_lookup(HASH_ALGS, alg_str) {
Some(len) => len,
None => {
issues.push(issue(
ErrorCode::UnsupportedHashAlg,
format!("unknown hash alg: {alg_str}"),
));
return;
}
};
let digest_bytes = match as_bytes(digest) {
Some(b) => b,
None => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
format!("hashes[{alg_str}] value must be CBOR bytes"),
));
return;
}
};
if digest_bytes.len() != expected {
issues.push(issue(
ErrorCode::HashDigestLengthMismatch,
format!(
"hashes[{alg_str}] digest length {} != {expected}",
digest_bytes.len()
),
));
}
}
fn validate_item_uris(raw: &CborValue, issues: &mut Vec<ValidationIssue>) {
match raw {
CborValue::Array(uris) if !uris.is_empty() => {
for chunks in uris {
validate_one_uri(chunks, issues);
}
}
_ => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"uris must be a non-empty array of chunked-tstr-arrays".to_string(),
)),
}
}
fn validate_one_uri(chunks: &CborValue, issues: &mut Vec<ValidationIssue>) {
let chunk_arr = match chunks {
CborValue::Array(c) if !c.is_empty() => c,
_ => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"each URI must be a non-empty array of tstr chunks (<=64B each)".to_string(),
));
return;
}
};
let mut typed: Vec<String> = Vec::with_capacity(chunk_arr.len());
let mut type_ok = true;
for chunk in chunk_arr {
match chunk {
CborValue::Text(s) => {
let byte_len = s.len();
if !(1..=64).contains(&byte_len) {
issues.push(issue(
ErrorCode::ChunkTooLarge,
format!("chunk length {byte_len} not in [1, 64]"),
));
type_ok = false;
} else {
typed.push(s.clone());
}
}
_ => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"chunked-tstr element must be a text string".to_string(),
));
type_ok = false;
}
}
}
if !type_ok {
return;
}
let uri = match reconstruct_chunked_uri(&typed) {
ReconstructUriResult::Ok(uri) => uri,
ReconstructUriResult::Invalid => {
issues.push(issue(
ErrorCode::InvalidUri,
"URI chunk reconstruction failed".to_string(),
));
return;
}
};
if uri.contains('#') {
issues.push(issue(
ErrorCode::InvalidUri,
"URI contains a fragment identifier ('#'), which is forbidden".to_string(),
));
return;
}
if !is_absolute_uri(&uri) {
issues.push(issue(
ErrorCode::InvalidUri,
"URI is not absolute (missing scheme://hierarchical-part)".to_string(),
));
return;
}
if !is_permitted_scheme(&uri) {
issues.push(issue(
ErrorCode::InvalidUri,
"unsupported URI scheme; v1 PoE URI set is {ar://, ipfs://}".to_string(),
));
return;
}
let scheme_folded = fold_scheme_lowercase(&uri);
if scheme_folded.starts_with("ar://") {
if !is_arweave_txid(&scheme_folded) {
issues.push(issue(
ErrorCode::InvalidUri,
"ar:// URI does not match `^ar://[A-Za-z0-9_-]{43}$` \
(43-char base64url txid, no path/query/fragment)"
.to_string(),
));
}
} else if let Some(rest) = scheme_folded.strip_prefix("ipfs://") {
let cid = rest.split('/').next().unwrap_or("");
if !is_valid_cid(cid) {
issues.push(issue(
ErrorCode::InvalidUri,
"ipfs:// URI is not a valid CID under the Label 309 profile".to_string(),
));
}
}
}
fn fold_scheme_lowercase(uri: &str) -> String {
match uri.find("://") {
Some(i) => {
let end = i + "://".len();
let mut out = uri[..end].to_ascii_lowercase();
out.push_str(&uri[end..]);
out
}
None => uri.to_string(),
}
}
fn is_permitted_scheme(uri: &str) -> bool {
let lower = uri.to_ascii_lowercase();
lower.starts_with("ar://") || lower.starts_with("ipfs://")
}
fn is_absolute_uri(uri: &str) -> bool {
let scheme_end = match uri.find("://") {
Some(i) => i,
None => return false,
};
if scheme_end == 0 {
return false;
}
let scheme = &uri.as_bytes()[..scheme_end];
if !scheme[0].is_ascii_alphabetic() {
return false;
}
scheme
.iter()
.all(|&b| b.is_ascii_alphanumeric() || b == b'+' || b == b'.' || b == b'-')
}
fn is_arweave_txid(uri: &str) -> bool {
let rest = match uri.strip_prefix("ar://") {
Some(r) => r,
None => return false,
};
rest.len() == 43
&& rest
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
fn validate_encryption(enc: &CborValue, issues: &mut Vec<ValidationIssue>) {
let enc_map = match as_map(enc) {
Some(map) => map,
None => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"enc must be a map".to_string(),
));
return;
}
};
check_unknown_keys(enc_map, REGISTERED_ENC_KEYS, "enc", issues);
if !matches!(map_get(enc_map, "scheme"), Some(CborValue::Unsigned(1))) {
issues.push(issue(
ErrorCode::UnsupportedEnvelopeScheme,
"enc.scheme must be the unsigned integer 1".to_string(),
));
}
let aead = match map_get(enc_map, "aead").and_then(as_text) {
Some(a) => a,
None => {
issues.push(issue(
ErrorCode::UnsupportedAeadAlg,
"unknown aead alg".to_string(),
));
return;
}
};
if is_unauthenticated_cipher(aead) {
issues.push(issue(
ErrorCode::UnauthenticatedCipherForbidden,
format!("{aead} is an unauthenticated cipher"),
));
return;
}
let nonce_len = match registry_lookup(AEAD_NONCE_LENGTHS, aead) {
Some(len) => len,
None => {
issues.push(issue(
ErrorCode::UnsupportedAeadAlg,
format!("unknown aead alg: {aead}"),
));
return;
}
};
let has_kem = map_has(enc_map, "kem");
let mut kem_resolved: Option<&str> = None;
if has_kem {
match map_get(enc_map, "kem").and_then(as_text) {
Some(kem) if kem_slot_descriptor(kem).is_some() => kem_resolved = Some(kem),
_ => issues.push(issue(
ErrorCode::UnsupportedKemAlg,
"unknown kem alg".to_string(),
)),
}
}
match map_get(enc_map, "nonce") {
Some(CborValue::Bytes(nonce)) => {
if nonce.len() != nonce_len {
issues.push(issue(
ErrorCode::NonceLengthMismatch,
format!("nonce length {} != {nonce_len} for {aead}", nonce.len()),
));
}
}
_ => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"nonce must be bytes".to_string(),
)),
}
let has_slots = map_has(enc_map, "slots");
let has_slots_mac = map_has(enc_map, "slots_mac");
let has_passphrase = map_has(enc_map, "passphrase");
if has_slots {
match map_get(enc_map, "slots") {
Some(CborValue::Array(slots)) => {
if slots.is_empty() {
issues.push(issue(
ErrorCode::EncSlotsEmpty,
"slots must be a non-empty array".to_string(),
));
} else if let Some(kem) = kem_resolved {
for slot in slots {
validate_slot(slot, kem, issues);
}
}
}
_ => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"slots must be an array".to_string(),
)),
}
}
if has_slots_mac {
match map_get(enc_map, "slots_mac") {
Some(CborValue::Bytes(mac)) => {
if mac.len() != 32 {
issues.push(issue(
ErrorCode::EncSlotsMacInvalidLength,
format!("slots_mac length {} != 32", mac.len()),
));
}
}
_ => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"slots_mac must be bytes".to_string(),
)),
}
}
if has_slots && has_passphrase {
issues.push(issue(
ErrorCode::EncExclusivityViolation,
"enc combines slots with passphrase; exactly one MUST be present".to_string(),
));
}
if has_slots && !has_slots_mac {
issues.push(issue(
ErrorCode::EncSlotsMacRequired,
"enc.slots present but enc.slots_mac absent".to_string(),
));
}
if has_slots_mac && !has_slots {
issues.push(issue(
ErrorCode::EncSlotsRequired,
"enc.slots_mac present but enc.slots absent".to_string(),
));
}
if has_slots && !has_kem {
issues.push(issue(
ErrorCode::EncKemRequired,
"enc.slots present but enc.kem absent".to_string(),
));
}
if !has_slots && !has_passphrase {
issues.push(issue(
ErrorCode::EncNoKeyPath,
"enc requires either slots or passphrase".to_string(),
));
}
if has_passphrase {
if let Some(pp) = map_get(enc_map, "passphrase") {
validate_passphrase(pp, issues);
}
}
}
fn validate_slot(slot: &CborValue, kem: &str, issues: &mut Vec<ValidationIssue>) {
let slot_map = match as_map(slot) {
Some(map) => map,
None => {
issues.push(issue(
ErrorCode::EncSlotInvalidShape,
"recipient slot must be a map".to_string(),
));
return;
}
};
let descriptor = match kem_slot_descriptor(kem) {
Some(d) => d,
None => return,
};
let foreign_field = if descriptor.field == "epk" {
"kem_ct"
} else {
"epk"
};
if map_has(slot_map, foreign_field) {
issues.push(issue(
ErrorCode::EncSlotInvalidShape,
format!(
"slot carries '{foreign_field}' but kem='{kem}' expects '{}'",
descriptor.field
),
));
}
for (key, _) in slot_map {
let known = matches!(key, CborValue::Text(k) if REGISTERED_SLOT_KEYS.contains(&k.as_str()));
if !known {
issues.push(issue(
ErrorCode::EncSlotInvalidShape,
"slot carries an unexpected key".to_string(),
));
}
}
if descriptor.field == "epk" {
match map_get(slot_map, "epk") {
None => issues.push(issue(
ErrorCode::EncSlotInvalidShape,
format!("slot for kem='{kem}' is missing required 'epk'"),
)),
Some(CborValue::Bytes(epk)) => {
if epk.len() != descriptor.field_length {
issues.push(issue(
ErrorCode::KemEpkLengthMismatch,
format!(
"epk length {} != {} for {kem}",
epk.len(),
descriptor.field_length
),
));
}
}
Some(_) => issues.push(issue(
ErrorCode::EncSlotInvalidShape,
"slot epk must be bytes".to_string(),
)),
}
} else if let Some(reassembled) = reassemble_kem_ct(map_get(slot_map, "kem_ct"), issues) {
if reassembled != descriptor.field_length {
issues.push(issue(
ErrorCode::KemCtLengthMismatch,
format!(
"kem_ct reassembles to {reassembled} bytes != {} for {kem}",
descriptor.field_length
),
));
}
}
match map_get(slot_map, "wrap") {
None => issues.push(issue(
ErrorCode::EncSlotInvalidShape,
format!("slot for kem='{kem}' is missing required 'wrap'"),
)),
Some(CborValue::Bytes(wrap)) => {
if wrap.len() != descriptor.wrap_length {
issues.push(issue(
ErrorCode::WrapLengthMismatch,
format!("wrap length {} != {}", wrap.len(), descriptor.wrap_length),
));
}
}
Some(_) => issues.push(issue(
ErrorCode::EncSlotInvalidShape,
"slot wrap must be bytes".to_string(),
)),
}
}
fn reassemble_kem_ct(raw: Option<&CborValue>, issues: &mut Vec<ValidationIssue>) -> Option<usize> {
let chunks = match raw {
None => {
issues.push(issue(
ErrorCode::EncSlotInvalidShape,
"hybrid slot is missing required 'kem_ct'".to_string(),
));
return None;
}
Some(CborValue::Array(c)) if !c.is_empty() => c,
Some(_) => {
issues.push(issue(
ErrorCode::EncSlotInvalidShape,
"kem_ct must be a non-empty array of byte chunks".to_string(),
));
return None;
}
};
let mut total = 0usize;
let mut shape_ok = true;
for chunk in chunks {
match chunk {
CborValue::Bytes(b) => {
if !(1..=64).contains(&b.len()) {
issues.push(issue(
ErrorCode::ChunkTooLarge,
format!("chunk length {} not in [1, 64]", b.len()),
));
shape_ok = false;
} else {
total += b.len();
}
}
_ => {
issues.push(issue(
ErrorCode::EncSlotInvalidShape,
"kem_ct chunk must be a byte string".to_string(),
));
shape_ok = false;
}
}
}
if shape_ok {
Some(total)
} else {
None
}
}
fn validate_passphrase(passphrase: &CborValue, issues: &mut Vec<ValidationIssue>) {
let pp = match as_map(passphrase) {
Some(map) => map,
None => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"passphrase must be a map".to_string(),
));
return;
}
};
check_unknown_keys(pp, REGISTERED_PASSPHRASE_KEYS, "passphrase", issues);
let alg = map_get(pp, "alg").and_then(as_text);
match alg {
Some(a) if PASSPHRASE_ALGS.contains(&a) => {}
_ => issues.push(issue(
ErrorCode::EncPassphraseAlgUnsupported,
"unknown passphrase alg".to_string(),
)),
}
match map_get(pp, "salt") {
Some(CborValue::Bytes(salt)) => {
if salt.len() < 16 {
issues.push(issue(
ErrorCode::EncPassphraseSaltTooShort,
format!("passphrase.salt length {} < 16", salt.len()),
));
} else if salt.len() > 64 {
issues.push(issue(
ErrorCode::EncPassphraseSaltTooLong,
format!("passphrase.salt length {} > 64", salt.len()),
));
}
}
_ => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"salt must be bytes".to_string(),
)),
}
let params = match map_get(pp, "params") {
Some(CborValue::Map(m)) => m,
Some(_) | None => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"params must be a map".to_string(),
));
return;
}
};
if alg == Some("argon2id") {
validate_argon2_params(params, issues);
}
}
fn validate_argon2_params(params: &[(CborValue, CborValue)], issues: &mut Vec<ValidationIssue>) {
for (key, _) in params {
let known = matches!(key, CborValue::Text(k) if matches!(k.as_str(), "m" | "t" | "p"));
if !known {
issues.push(issue(
ErrorCode::SchemaUnknownField,
"unknown argon2id params field".to_string(),
));
}
}
let int_param = |name: &str, issues: &mut Vec<ValidationIssue>| -> Option<u64> {
match map_get(params, name) {
Some(CborValue::Unsigned(n)) => Some(*n),
_ => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
format!("argon2id params.{name} must be a CBOR unsigned integer"),
));
None
}
}
};
if let Some(m) = int_param("m", issues) {
if m < 65_536 {
issues.push(issue(
ErrorCode::EncPassphraseArgon2ParamsTooLow,
"argon2id requires m >= 65536 KiB".to_string(),
));
}
}
if let Some(t) = int_param("t", issues) {
if t < 3 {
issues.push(issue(
ErrorCode::EncPassphraseArgon2ParamsTooLow,
"argon2id requires t >= 3".to_string(),
));
}
}
if let Some(p) = int_param("p", issues) {
if p < 1 {
issues.push(issue(
ErrorCode::EncPassphraseArgon2ParamsTooLow,
"argon2id requires p >= 1".to_string(),
));
}
}
}
fn validate_merkle_commit(commit: &CborValue, issues: &mut Vec<ValidationIssue>) {
let cm = match as_map(commit) {
Some(map) => map,
None => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"merkle entry must be a map".to_string(),
));
return;
}
};
check_unknown_keys(cm, REGISTERED_MERKLE_COMMIT_KEYS, "merkle entry", issues);
let mut alg_resolved: Option<&str> = None;
match map_get(cm, "alg") {
None => issues.push(issue(
ErrorCode::SchemaMissingRequired,
"merkle entry missing required 'alg'".to_string(),
)),
Some(CborValue::Text(alg)) => {
if registry_lookup(MERKLE_COMMIT_ALGS, alg).is_some() {
alg_resolved = Some(alg);
} else {
issues.push(issue(
ErrorCode::UnsupportedMerkleCommitAlg,
format!("unknown merkle commitment alg: {alg}"),
));
}
}
Some(_) => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"merkle entry 'alg' must be a text string".to_string(),
)),
}
match map_get(cm, "root") {
None => issues.push(issue(
ErrorCode::SchemaMissingRequired,
"merkle entry missing required 'root'".to_string(),
)),
Some(CborValue::Bytes(root)) => {
if let Some(alg) = alg_resolved {
let expected = registry_lookup(MERKLE_COMMIT_ALGS, alg).unwrap_or(0);
if root.len() != expected {
issues.push(issue(
ErrorCode::HashDigestLengthMismatch,
format!(
"merkle entry 'root' length {} != {expected} for {alg}",
root.len()
),
));
}
}
}
Some(_) => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"merkle entry 'root' must be CBOR bytes".to_string(),
)),
}
match map_get(cm, "leaf_count") {
None => issues.push(issue(
ErrorCode::SchemaMissingRequired,
"merkle entry missing required 'leaf_count'".to_string(),
)),
Some(CborValue::Unsigned(n)) if *n >= 1 => {}
Some(_) => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"merkle entry 'leaf_count' must be a CBOR unsigned integer >= 1".to_string(),
)),
}
if let Some(uris) = map_get(cm, "uris") {
validate_item_uris(uris, issues);
}
}
fn validate_supersedes(value: &CborValue, issues: &mut Vec<ValidationIssue>) {
match value {
CborValue::Bytes(b) if b.len() == 32 => {}
CborValue::Bytes(_) => issues.push(issue(
ErrorCode::SupersedesTxInvalidLength,
"supersedes must be a 32-byte transaction hash".to_string(),
)),
_ => issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"supersedes must be a byte string".to_string(),
)),
}
}
fn validate_crit(record_map: &[(CborValue, CborValue)], issues: &mut Vec<ValidationIssue>) {
let crit_arr = match map_get(record_map, "crit") {
Some(CborValue::Array(a)) if !a.is_empty() => a,
_ => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"crit must be a non-empty array of text strings".to_string(),
));
return;
}
};
let mut seen: Vec<String> = Vec::new();
for entry in crit_arr {
let name = match entry {
CborValue::Text(t) => t,
_ => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"crit entry must be a text string".to_string(),
));
continue;
}
};
let mut reason: Option<String> = None;
if REGISTERED_RECORD_KEYS.contains(&name.as_str()) {
reason = Some(format!(
"'{name}' is a base key and MUST NOT appear in crit[]"
));
} else if !is_extension_key(name) {
reason = Some(format!("'{name}' does not match the extension-key regex"));
} else if !map_has(record_map, name) {
reason = Some(format!(
"'{name}' is named in crit but absent from the record map"
));
} else if seen.contains(name) {
reason = Some(format!("'{name}' appears more than once in crit[]"));
}
seen.push(name.clone());
if let Some(reason) = reason {
issues.push(issue(ErrorCode::CritShapeInvalid, reason));
continue;
}
issues.push(issue(
ErrorCode::ExtensionUnsupportedCritical,
format!("crit entry '{name}' names an extension this validator does not implement"),
));
}
}
fn validate_sigs(
raw: &CborValue,
issues: &mut Vec<ValidationIssue>,
info: &mut Vec<ValidationIssue>,
) {
let entries = match raw {
CborValue::Array(a) => a,
_ => {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"sigs must be an array".to_string(),
));
return;
}
};
if entries.is_empty() {
issues.push(issue(
ErrorCode::SchemaTypeMismatch,
"sigs must be a non-empty array when present".to_string(),
));
return;
}
for entry in entries {
validate_sig_entry(entry, issues, info);
}
}
fn validate_sig_entry(
entry: &CborValue,
issues: &mut Vec<ValidationIssue>,
info: &mut Vec<ValidationIssue>,
) {
let entry_map = match as_map(entry) {
Some(map) => map,
None => {
issues.push(issue(
ErrorCode::SigEntryInvalidShape,
"each sigs entry must be a CBOR map { cose_sign1, cose_key? }".to_string(),
));
return;
}
};
let cose_sign1_chunks = match map_get(entry_map, "cose_sign1") {
None => {
issues.push(issue(
ErrorCode::SigEntryInvalidShape,
"sigs entry missing required 'cose_sign1' field".to_string(),
));
None
}
Some(raw) if is_chunked_bytes_shape(raw) => {
let chunks = chunked_bytes(raw);
validate_bytes_chunk_lengths(&chunks, issues);
Some(chunks)
}
Some(_) => {
issues.push(issue(
ErrorCode::SigEntryInvalidShape,
"sigs[i].cose_sign1 must be a non-empty list of byte chunks".to_string(),
));
None
}
};
if let Some(cose_key_raw) = map_get(entry_map, "cose_key") {
if is_chunked_bytes_shape(cose_key_raw) {
let chunks = chunked_bytes(cose_key_raw);
validate_bytes_chunk_lengths(&chunks, issues);
validate_cose_key_blob(&chunks, issues);
} else {
issues.push(issue(
ErrorCode::SigEntryInvalidShape,
"sigs[i].cose_key must be a non-empty list of byte chunks".to_string(),
));
}
}
for (key, _) in entry_map {
let known = matches!(key, CborValue::Text(k)
if REGISTERED_SIG_ENTRY_KEYS.contains(&k.as_str()));
if !known {
issues.push(issue(
ErrorCode::SigEntryInvalidShape,
"sigs entry carries an unrecognised key (allowed: cose_sign1, cose_key)"
.to_string(),
));
}
}
if let Some(chunks) = cose_sign1_chunks {
check_cose_sign1(&chunks, entry_map, issues, info);
}
}
fn is_chunked_bytes_shape(value: &CborValue) -> bool {
matches!(value, CborValue::Array(a)
if !a.is_empty() && a.iter().all(|c| matches!(c, CborValue::Bytes(_))))
}
fn chunked_bytes(value: &CborValue) -> Vec<Vec<u8>> {
match value {
CborValue::Array(a) => a
.iter()
.filter_map(|c| match c {
CborValue::Bytes(b) => Some(b.clone()),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
fn validate_bytes_chunk_lengths(chunks: &[Vec<u8>], issues: &mut Vec<ValidationIssue>) {
for c in chunks {
if !(1..=64).contains(&c.len()) {
issues.push(issue(
ErrorCode::ChunkTooLarge,
format!("chunk length {} not in [1, 64]", c.len()),
));
}
}
}
fn validate_cose_key_blob(chunks: &[Vec<u8>], issues: &mut Vec<ValidationIssue>) {
let joined = bytes_chunk_array_concat(chunks);
let decoded = match decode_canonical_cbor(&joined) {
Ok(v) => v,
Err(_) => {
issues.push(issue(
ErrorCode::MalformedSigCoseSign1,
"cose_key failed to decode as cbor<COSE_Key>".to_string(),
));
return;
}
};
let key_map = match as_map(&decoded) {
Some(map) => map,
None => {
issues.push(issue(
ErrorCode::MalformedSigCoseSign1,
"cose_key did not decode to a CBOR map".to_string(),
));
return;
}
};
let leaks_private = key_map
.iter()
.any(|(k, _)| int_key(k).is_some_and(|n| COSE_KEY_PRIVATE_MATERIAL_LABELS.contains(&n)));
if leaks_private {
issues.push(issue(
ErrorCode::SigPrivateKeyLeaked,
"cose_key carries COSE_Key private-key material (label -4, the OKP/EC2 \
private scalar d); publishing a private key on the permanent ledger is forbidden"
.to_string(),
));
return;
}
if int_keyed_get(key_map, 1) != Some(&CborValue::Unsigned(1)) {
issues.push(issue(
ErrorCode::MalformedSigCoseSign1,
"cose_key kty (label 1) must be 1 (OKP)".to_string(),
));
return;
}
if int_keyed_get(key_map, -1) != Some(&CborValue::Unsigned(6)) {
issues.push(issue(
ErrorCode::MalformedSigCoseSign1,
"cose_key crv (label -1) must be 6 (Ed25519)".to_string(),
));
return;
}
match int_keyed_get(key_map, -2) {
Some(CborValue::Bytes(x)) if x.len() == 32 => {}
_ => issues.push(issue(
ErrorCode::MalformedSigCoseSign1,
"cose_key label -2 must be a 32-byte byte string (Ed25519 public key)".to_string(),
)),
}
}
fn check_cose_sign1(
chunks: &[Vec<u8>],
entry_map: &[(CborValue, CborValue)],
issues: &mut Vec<ValidationIssue>,
info: &mut Vec<ValidationIssue>,
) {
let merged = bytes_chunk_array_concat(chunks);
let cose = match decode_cose_sign1(&merged) {
Ok(c) => c,
Err(message) => {
issues.push(issue(ErrorCode::MalformedSigCoseSign1, message));
return;
}
};
if cose.payload_present {
issues.push(issue(
ErrorCode::MalformedSigCoseSign1,
"COSE_Sign1 payload must be null (detached); attached form forbidden".to_string(),
));
return;
}
let alg = cose
.protected_header
.as_ref()
.and_then(|h| int_keyed_get(h, 1))
.and_then(int_value);
if !alg.is_some_and(|a| KNOWN_SIG_ALG_IDS.contains(&a)) {
info.push(issue(
ErrorCode::SignatureUnsupported,
"alg not in KNOWN_SIG_ALG_IDS".to_string(),
));
}
let kid = cose
.protected_header
.as_ref()
.and_then(|h| int_keyed_get(h, 4));
let kid_32 = matches!(kid, Some(CborValue::Bytes(b)) if b.len() == 32);
if kid_32 && map_has(entry_map, "cose_key") {
issues.push(issue(
ErrorCode::SigEntryKidCoseKeyConflict,
"sigs[i] carries both a 32-byte protected `kid` (path 1) and an inline `cose_key` \
(path 2); paths are mutually exclusive"
.to_string(),
));
}
}
struct CoseSign1Decoded {
protected_header: Option<Vec<(CborValue, CborValue)>>,
payload_present: bool,
}
fn decode_cose_sign1(data: &[u8]) -> Result<CoseSign1Decoded, String> {
let arr = decode_canonical_cbor(data).map_err(|_| "cose decode failed".to_string())?;
let elems = match arr {
CborValue::Array(a) if a.len() == 4 => a,
_ => return Err("expected 4-element array".to_string()),
};
let protected_bytes = match &elems[0] {
CborValue::Bytes(b) => b.clone(),
_ => return Err("protected_bytes must be bytes".to_string()),
};
if !matches!(&elems[1], CborValue::Map(_)) {
return Err("unprotected header must be map".to_string());
}
let payload_present = match &elems[2] {
CborValue::Null => false,
CborValue::Bytes(_) => true,
_ => return Err("payload must be bytes or null".to_string()),
};
match &elems[3] {
CborValue::Bytes(sig) if sig.len() == 64 => {}
_ => return Err("signature must be 64 bytes".to_string()),
}
let protected_header = if protected_bytes.is_empty() {
None
} else {
let decoded = decode_canonical_cbor(&protected_bytes)
.map_err(|_| "protected header decode failed".to_string())?;
let map = match decoded {
CborValue::Map(m) => m,
_ => return Err("protected header must decode to map".to_string()),
};
if map.is_empty() {
return Err(
"empty protected header must encode as 0x40 (zero-length bstr)".to_string(),
);
}
let reencoded = encode_canonical_cbor(&CborValue::Map(map.clone()))
.map_err(|_| "protected header re-encode failed".to_string())?;
if reencoded != protected_bytes {
return Err("protected header bytes are not canonical CBOR".to_string());
}
Some(map)
};
Ok(CoseSign1Decoded {
protected_header,
payload_present,
})
}
fn int_key(key: &CborValue) -> Option<i64> {
int_value(key)
}
fn int_value(value: &CborValue) -> Option<i64> {
match value {
CborValue::Unsigned(n) => i64::try_from(*n).ok(),
CborValue::Negative(m) => i64::try_from(*m).ok().and_then(|m| (-1i64).checked_sub(m)),
_ => None,
}
}
fn int_keyed_get(map: &[(CborValue, CborValue)], label: i64) -> Option<&CborValue> {
map.iter().find_map(|(k, v)| {
if int_key(k) == Some(label) {
Some(v)
} else {
None
}
})
}
fn record_from_cbor(decoded: &CborValue) -> Option<PoeRecord> {
let map = as_map(decoded)?;
let mut record = PoeRecord {
v: match map_get(map, "v")? {
CborValue::Unsigned(n) => *n,
_ => return None,
},
..PoeRecord::default()
};
if let Some(CborValue::Array(items)) = map_get(map, "items") {
record.items = Some(items.iter().filter_map(item_from_cbor).collect());
}
if let Some(CborValue::Array(merkle)) = map_get(map, "merkle") {
record.merkle = Some(merkle.iter().filter_map(merkle_from_cbor).collect());
}
if let Some(CborValue::Bytes(s)) = map_get(map, "supersedes") {
record.supersedes = Some(s.clone());
}
if let Some(CborValue::Array(sigs)) = map_get(map, "sigs") {
record.sigs = Some(sigs.iter().filter_map(sig_from_cbor).collect());
}
if let Some(CborValue::Array(crit)) = map_get(map, "crit") {
record.crit = Some(
crit.iter()
.filter_map(|c| as_text(c).map(str::to_string))
.collect(),
);
}
for (key, value) in map {
if let CborValue::Text(k) = key {
if !REGISTERED_RECORD_KEYS.contains(&k.as_str()) {
record.extensions.push((k.clone(), value.clone()));
}
}
}
Some(record)
}
fn item_from_cbor(value: &CborValue) -> Option<ItemEntry> {
let map = as_map(value)?;
let hashes = match map_get(map, "hashes")? {
CborValue::Map(m) => m
.iter()
.filter_map(|(k, v)| Some((as_text(k)?.to_string(), as_bytes(v)?.to_vec())))
.collect(),
_ => return None,
};
let uris = match map_get(map, "uris") {
Some(CborValue::Array(u)) => Some(uris_from_cbor(u)),
_ => None,
};
let enc = map_get(map, "enc").and_then(envelope_from_cbor);
Some(ItemEntry { hashes, uris, enc })
}
fn uris_from_cbor(uris: &[CborValue]) -> Vec<Vec<String>> {
uris.iter()
.filter_map(|u| match u {
CborValue::Array(chunks) => Some(
chunks
.iter()
.filter_map(|c| as_text(c).map(str::to_string))
.collect(),
),
_ => None,
})
.collect()
}
fn envelope_from_cbor(value: &CborValue) -> Option<EncryptionEnvelope> {
let map = as_map(value)?;
Some(EncryptionEnvelope {
scheme: match map_get(map, "scheme")? {
CborValue::Unsigned(n) => *n,
_ => return None,
},
aead: as_text(map_get(map, "aead")?)?.to_string(),
nonce: as_bytes(map_get(map, "nonce")?)?.to_vec(),
kem: map_get(map, "kem").and_then(as_text).map(str::to_string),
slots: match map_get(map, "slots") {
Some(CborValue::Array(slots)) => {
Some(slots.iter().filter_map(slot_from_cbor).collect())
}
_ => None,
},
slots_mac: map_get(map, "slots_mac")
.and_then(as_bytes)
.map(<[u8]>::to_vec),
passphrase: map_get(map, "passphrase").and_then(passphrase_from_cbor),
})
}
fn slot_from_cbor(value: &CborValue) -> Option<Slot> {
let map = as_map(value)?;
Some(Slot {
epk: map_get(map, "epk").and_then(as_bytes).map(<[u8]>::to_vec),
kem_ct: match map_get(map, "kem_ct") {
Some(CborValue::Array(chunks)) => Some(
chunks
.iter()
.filter_map(|c| as_bytes(c).map(<[u8]>::to_vec))
.collect(),
),
_ => None,
},
wrap: map_get(map, "wrap").and_then(as_bytes).map(<[u8]>::to_vec),
})
}
fn passphrase_from_cbor(value: &CborValue) -> Option<PassphraseBlock> {
let map = as_map(value)?;
let params = match map_get(map, "params")? {
CborValue::Map(m) => m
.iter()
.filter_map(|(k, v)| match v {
CborValue::Unsigned(n) => Some((as_text(k)?.to_string(), *n)),
_ => None,
})
.collect(),
_ => return None,
};
Some(PassphraseBlock {
alg: as_text(map_get(map, "alg")?)?.to_string(),
salt: as_bytes(map_get(map, "salt")?)?.to_vec(),
params,
})
}
fn merkle_from_cbor(value: &CborValue) -> Option<MerkleCommit> {
let map = as_map(value)?;
Some(MerkleCommit {
alg: as_text(map_get(map, "alg")?)?.to_string(),
root: as_bytes(map_get(map, "root")?)?.to_vec(),
leaf_count: match map_get(map, "leaf_count")? {
CborValue::Unsigned(n) => *n,
_ => return None,
},
uris: match map_get(map, "uris") {
Some(CborValue::Array(u)) => Some(uris_from_cbor(u)),
_ => None,
},
})
}
fn sig_from_cbor(value: &CborValue) -> Option<SigEntry> {
let map = as_map(value)?;
let cose_sign1 = match map_get(map, "cose_sign1")? {
CborValue::Array(chunks) => chunks
.iter()
.filter_map(|c| as_bytes(c).map(<[u8]>::to_vec))
.collect(),
_ => return None,
};
let cose_key = match map_get(map, "cose_key") {
Some(CborValue::Array(chunks)) => Some(
chunks
.iter()
.filter_map(|c| as_bytes(c).map(<[u8]>::to_vec))
.collect(),
),
_ => None,
};
Some(SigEntry {
cose_sign1,
cose_key,
})
}
#[must_use]
pub fn is_valid_cid(cid: &str) -> bool {
if cid.is_empty() {
return false;
}
if cid.starts_with("Qm") {
if cid.len() != 46 {
return false;
}
return match decode_base58btc(cid) {
Some(decoded) => decoded.len() == 34 && decoded[0] == 0x12 && decoded[1] == 0x20,
None => false,
};
}
let mb_prefix = cid.as_bytes()[0] as char;
if !matches!(mb_prefix, 'b' | 'B' | 'f' | 'F' | 'z') {
return false;
}
let bytes = match decode_multibase(mb_prefix, &cid[1..]) {
Some(b) => b,
None => return false,
};
if bytes.len() < 4 {
return false;
}
let (version, pos) = match read_varint(&bytes, 0) {
Some(v) => v,
None => return false,
};
if version != 1 {
return false;
}
let (codec, pos) = match read_varint(&bytes, pos) {
Some(v) => v,
None => return false,
};
if !matches!(codec, 0x55 | 0x70 | 0x71) {
return false;
}
let (mh_code, pos) = match read_varint(&bytes, pos) {
Some(v) => v,
None => return false,
};
let (digest_len, pos) = match read_varint(&bytes, pos) {
Some(v) => v,
None => return false,
};
let expected = match mh_code {
0x12 | 0xb220 => 32usize,
_ => return false,
};
if digest_len as usize != expected {
return false;
}
pos + digest_len as usize == bytes.len()
}
fn read_varint(bytes: &[u8], start: usize) -> Option<(u64, usize)> {
let mut value: u64 = 0;
let mut shift = 0u32;
let mut i = start;
while i < bytes.len() {
let b = bytes[i];
value |= u64::from(b & 0x7f) << shift;
i += 1;
if b & 0x80 == 0 {
return Some((value, i));
}
shift += 7;
if shift > 28 {
return None;
}
}
None
}
fn decode_multibase(prefix: char, body: &str) -> Option<Vec<u8>> {
match prefix {
'b' => decode_base32(&body.to_ascii_lowercase(), false),
'B' => decode_base32(&body.to_ascii_uppercase(), true),
'f' => decode_base16(&body.to_ascii_lowercase()),
'F' => decode_base16(&body.to_ascii_uppercase()),
'z' => decode_base58btc(body),
_ => None,
}
}
fn decode_base16(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
let mut out = Vec::with_capacity(s.len() / 2);
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let hi = hex_digit(bytes[i])?;
let lo = hex_digit(bytes[i + 1])?;
out.push((hi << 4) | lo);
i += 2;
}
Some(out)
}
fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn decode_base32(s: &str, upper: bool) -> Option<Vec<u8>> {
let alphabet: &[u8] = if upper {
b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
} else {
b"abcdefghijklmnopqrstuvwxyz234567"
};
let trimmed = s.trim_end_matches('=');
let mut out: Vec<u8> = Vec::new();
let mut buf: u32 = 0;
let mut bits = 0u32;
for ch in trimmed.bytes() {
let idx = alphabet.iter().position(|&a| a == ch)? as u32;
buf = (buf << 5) | idx;
bits += 5;
if bits >= 8 {
bits -= 8;
out.push(((buf >> bits) & 0xff) as u8);
}
}
Some(out)
}
fn decode_base58btc(s: &str) -> Option<Vec<u8>> {
const ALPHABET: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
if s.is_empty() {
return Some(Vec::new());
}
let chars = s.as_bytes();
let mut zeros = 0;
while zeros < chars.len() && chars[zeros] == b'1' {
zeros += 1;
}
let size = (chars.len() - zeros) * 733 / 1000 + 1;
let mut b256 = vec![0u8; size];
let mut length = 0;
for &ch in &chars[zeros..] {
let mut carry = ALPHABET.iter().position(|&a| a == ch)? as u32;
let mut k = 0;
let mut j = size;
while j > 0 && (carry != 0 || k < length) {
j -= 1;
carry += 58 * u32::from(b256[j]);
b256[j] = (carry % 256) as u8;
carry /= 256;
k += 1;
}
length = k;
}
let mut it = size - length;
while it < size && b256[it] == 0 {
it += 1;
}
let mut out = vec![0u8; zeros];
out.extend_from_slice(&b256[it..]);
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn hash32(byte: u8) -> Vec<u8> {
vec![byte; 32]
}
fn minimal_record() -> PoeRecord {
PoeRecord {
v: 1,
items: Some(vec![ItemEntry {
hashes: vec![("sha2-256".to_string(), hash32(0xab))],
uris: None,
enc: None,
}]),
..PoeRecord::default()
}
}
#[test]
fn encode_minimal_round_trips_through_validator() {
let bytes = encode_poe_record(&minimal_record()).unwrap();
let result = validate_poe_record(&bytes);
assert!(result.is_ok());
}
#[test]
fn encode_is_deterministic_regardless_of_insertion_order() {
let mut record = minimal_record();
record.sigs = Some(vec![SigEntry {
cose_sign1: vec![vec![0u8; 60]],
cose_key: None,
}]);
let a = encode_poe_record(&record).unwrap();
let b = encode_poe_record(&record).unwrap();
assert_eq!(a, b);
}
#[test]
fn body_encoding_strips_sigs_only() {
let mut with_sigs = minimal_record();
with_sigs.sigs = Some(vec![SigEntry {
cose_sign1: vec![vec![0x99u8; 64]],
cose_key: None,
}]);
let body = encode_record_body_for_signing(&with_sigs).unwrap();
let without = encode_poe_record(&minimal_record()).unwrap();
assert_eq!(body, without);
}
#[test]
fn extension_key_regex_matches_expected() {
assert!(is_extension_key("x-note"));
assert!(is_extension_key("seal-foo"));
assert!(!is_extension_key("x-"));
assert!(!is_extension_key("UPPERCASE-FOO"));
assert!(!is_extension_key("nohyphen"));
}
#[test]
fn unauthenticated_cipher_detection() {
for aead in [
"aes-256-cbc",
"aes-128-cbc",
"AES-256-CBC",
"aes-256-ctr",
"aes-128-ecb",
"rc4",
"des-ede3-cbc",
] {
assert!(is_unauthenticated_cipher(aead), "{aead}");
}
for aead in ["aes-256-gcm", "chacha20-poly1305", "xchacha20-poly1305"] {
assert!(!is_unauthenticated_cipher(aead), "{aead}");
}
}
#[test]
fn cidv0_profile_accepts_known_cid() {
assert!(is_valid_cid(
"QmbFMke1KXqnYyBBWxB74N4c5SBnJMVAiMNRcGu6x1AwQH"
));
assert!(!is_valid_cid("mAYIKsomethingbase64"));
}
#[test]
fn error_code_strings_are_screaming_snake() {
assert_eq!(ErrorCode::MalformedCbor.code(), "MALFORMED_CBOR");
assert_eq!(
ErrorCode::EncSlotInvalidShape.code(),
"ENC_SLOT_INVALID_SHAPE"
);
assert_eq!(ErrorCode::SignatureUnsupported.severity(), Severity::Info);
}
}