use zerocopy::AsBytes;
use crate::crypto::{hash, SigningKey, VerifyingKey};
use crate::serializer::SignatureEntry;
use crate::types::AuthorId;
use crate::{AionError, Result};
pub const ARTIFACT_ENTRY_SIZE: usize = 128;
const MANIFEST_DOMAIN: &[u8] = b"AION_V2_MANIFEST_V1";
#[repr(u16)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashAlgorithm {
Blake3_256 = 1,
}
impl HashAlgorithm {
pub fn from_u16(value: u16) -> Result<Self> {
match value {
1 => Ok(Self::Blake3_256),
other => Err(AionError::InvalidFormat {
reason: format!("Unknown manifest hash algorithm: {other}"),
}),
}
}
}
#[repr(C)]
#[derive(Debug, Clone, Copy, AsBytes)]
pub struct ArtifactEntry {
pub name_offset: u64,
pub name_length: u32,
pub hash_algorithm: u16,
pub reserved1: [u8; 2],
pub size: u64,
pub hash: [u8; 32],
pub reserved2: [u8; 72],
}
const _: () = assert!(std::mem::size_of::<ArtifactEntry>() == ARTIFACT_ENTRY_SIZE);
impl ArtifactEntry {
#[must_use]
pub const fn new(name_offset: u64, name_length: u32, size: u64, hash: [u8; 32]) -> Self {
Self {
name_offset,
name_length,
hash_algorithm: HashAlgorithm::Blake3_256 as u16,
reserved1: [0; 2],
size,
hash,
reserved2: [0; 72],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArtifactHandle {
index: usize,
}
impl ArtifactHandle {
#[must_use]
pub const fn index(self) -> usize {
self.index
}
}
#[derive(Debug, Default)]
pub struct ArtifactManifestBuilder {
entries: Vec<ArtifactEntry>,
name_table: Vec<u8>,
}
impl ArtifactManifestBuilder {
#[must_use]
#[allow(clippy::missing_const_for_fn)] pub fn new() -> Self {
Self {
entries: Vec::new(),
name_table: Vec::new(),
}
}
#[must_use = "the returned ArtifactHandle is the only way to refer to this artifact by index later"]
#[allow(clippy::cast_possible_truncation)] pub fn add(&mut self, name: &str, bytes: &[u8]) -> ArtifactHandle {
let name_offset = self.name_table.len() as u64;
let name_length = name.len() as u32;
self.name_table.extend_from_slice(name.as_bytes());
self.name_table.push(0);
let digest = hash(bytes);
let entry = ArtifactEntry::new(name_offset, name_length, bytes.len() as u64, digest);
let index = self.entries.len();
self.entries.push(entry);
ArtifactHandle { index }
}
#[must_use]
pub fn build(self) -> ArtifactManifest {
let canonical = canonical_manifest_bytes(&self.entries, &self.name_table);
let manifest_id = hash(&canonical);
ArtifactManifest {
manifest_id,
entries: self.entries,
name_table: self.name_table,
}
}
}
#[derive(Debug, Clone)]
pub struct ArtifactManifest {
manifest_id: [u8; 32],
entries: Vec<ArtifactEntry>,
name_table: Vec<u8>,
}
impl ArtifactManifest {
#[must_use]
pub const fn manifest_id(&self) -> &[u8; 32] {
&self.manifest_id
}
#[must_use]
pub fn entries(&self) -> &[ArtifactEntry] {
&self.entries
}
#[must_use]
pub fn name_table(&self) -> &[u8] {
&self.name_table
}
#[must_use]
pub fn canonical_bytes(&self) -> Vec<u8> {
canonical_manifest_bytes(&self.entries, &self.name_table)
}
pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self> {
let manifest_id = crate::crypto::hash(bytes);
let body = bytes
.strip_prefix(MANIFEST_DOMAIN)
.ok_or_else(|| AionError::InvalidFormat {
reason: "manifest canonical bytes missing domain prefix".to_string(),
})?;
if body.len() < 8 {
return Err(AionError::InvalidFormat {
reason: "manifest canonical bytes truncated before entry count".to_string(),
});
}
let (count_bytes, rest) = body.split_at(8);
let mut count_arr = [0u8; 8];
count_arr.copy_from_slice(count_bytes);
let entry_count = u64::from_le_bytes(count_arr) as usize;
let entries_len = entry_count
.checked_mul(ARTIFACT_ENTRY_SIZE)
.ok_or_else(|| AionError::InvalidFormat {
reason: "manifest entry count overflows usize".to_string(),
})?;
if rest.len() < entries_len {
return Err(AionError::InvalidFormat {
reason: format!(
"manifest entries truncated: need {} bytes, have {}",
entries_len,
rest.len()
),
});
}
let (entries_slice, name_table_slice) = rest.split_at(entries_len);
let mut entries = Vec::with_capacity(entry_count);
for i in 0..entry_count {
let start =
i.checked_mul(ARTIFACT_ENTRY_SIZE)
.ok_or_else(|| AionError::InvalidFormat {
reason: "entry index overflow".to_string(),
})?;
let end =
start
.checked_add(ARTIFACT_ENTRY_SIZE)
.ok_or_else(|| AionError::InvalidFormat {
reason: "entry end overflow".to_string(),
})?;
let slice = entries_slice
.get(start..end)
.ok_or_else(|| AionError::InvalidFormat {
reason: "entry slice out of bounds".to_string(),
})?;
entries.push(parse_artifact_entry(slice)?);
}
Ok(Self {
manifest_id,
entries,
name_table: name_table_slice.to_vec(),
})
}
pub fn name_of(&self, entry: &ArtifactEntry) -> Result<&str> {
slice_name(&self.name_table, entry.name_offset, entry.name_length)
}
pub fn verify_artifact(&self, name: &str, bytes: &[u8]) -> Result<()> {
for entry in &self.entries {
let candidate = self.name_of(entry)?;
if candidate != name {
continue;
}
if bytes.len() as u64 != entry.size {
return Err(AionError::InvalidFormat {
reason: format!(
"artifact '{name}': size mismatch (expected {}, got {})",
entry.size,
bytes.len()
),
});
}
let digest = hash(bytes);
if digest != entry.hash {
return Err(AionError::InvalidFormat {
reason: format!("artifact '{name}': hash mismatch"),
});
}
return Ok(());
}
Err(AionError::InvalidFormat {
reason: format!("artifact '{name}' not found in manifest"),
})
}
}
fn slice_name(table: &[u8], offset: u64, length: u32) -> Result<&str> {
let start = usize::try_from(offset).map_err(|_| AionError::InvalidFormat {
reason: "manifest name_offset exceeds usize".to_string(),
})?;
let len = length as usize;
let end = start
.checked_add(len)
.ok_or_else(|| AionError::InvalidFormat {
reason: "manifest name_offset + name_length overflows".to_string(),
})?;
let slice = table
.get(start..end)
.ok_or_else(|| AionError::InvalidFormat {
reason: "manifest name slice out of bounds".to_string(),
})?;
std::str::from_utf8(slice).map_err(|e| AionError::InvalidFormat {
reason: format!("manifest name is not valid UTF-8: {e}"),
})
}
fn parse_artifact_entry(slice: &[u8]) -> Result<ArtifactEntry> {
if slice.len() != ARTIFACT_ENTRY_SIZE {
return Err(AionError::InvalidFormat {
reason: format!(
"artifact entry slice must be {ARTIFACT_ENTRY_SIZE} bytes, got {}",
slice.len()
),
});
}
let read_u64 = |from: usize| -> Result<u64> {
let end = from
.checked_add(8)
.ok_or_else(|| AionError::InvalidFormat {
reason: "u64 offset overflow".to_string(),
})?;
let bytes = slice
.get(from..end)
.ok_or_else(|| AionError::InvalidFormat {
reason: "u64 read out of bounds".to_string(),
})?;
let mut a = [0u8; 8];
a.copy_from_slice(bytes);
Ok(u64::from_le_bytes(a))
};
let name_offset = read_u64(0)?;
let size = read_u64(16)?;
let name_length = {
let bytes = slice.get(8..12).ok_or_else(|| AionError::InvalidFormat {
reason: "name_length out of bounds".to_string(),
})?;
let mut a = [0u8; 4];
a.copy_from_slice(bytes);
u32::from_le_bytes(a)
};
let hash_algorithm = {
let bytes = slice.get(12..14).ok_or_else(|| AionError::InvalidFormat {
reason: "hash_algorithm out of bounds".to_string(),
})?;
let mut a = [0u8; 2];
a.copy_from_slice(bytes);
u16::from_le_bytes(a)
};
let mut hash = [0u8; 32];
let hash_bytes = slice.get(24..56).ok_or_else(|| AionError::InvalidFormat {
reason: "hash bytes out of bounds".to_string(),
})?;
hash.copy_from_slice(hash_bytes);
let reserved1 = slice.get(14..16).ok_or_else(|| AionError::InvalidFormat {
reason: "reserved1 out of bounds".to_string(),
})?;
if reserved1.iter().any(|b| *b != 0) {
return Err(AionError::InvalidFormat {
reason: "ArtifactEntry reserved1 must be all zero".to_string(),
});
}
let reserved2 = slice.get(56..128).ok_or_else(|| AionError::InvalidFormat {
reason: "reserved2 out of bounds".to_string(),
})?;
if reserved2.iter().any(|b| *b != 0) {
return Err(AionError::InvalidFormat {
reason: "ArtifactEntry reserved2 must be all zero".to_string(),
});
}
Ok(ArtifactEntry {
name_offset,
name_length,
hash_algorithm,
reserved1: [0u8; 2],
size,
hash,
reserved2: [0u8; 72],
})
}
fn canonical_manifest_bytes(entries: &[ArtifactEntry], name_table: &[u8]) -> Vec<u8> {
let entries_len = entries
.len()
.checked_mul(ARTIFACT_ENTRY_SIZE)
.unwrap_or_else(|| std::process::abort());
let capacity = MANIFEST_DOMAIN
.len()
.saturating_add(8)
.saturating_add(entries_len)
.saturating_add(name_table.len());
let mut out = Vec::with_capacity(capacity);
out.extend_from_slice(MANIFEST_DOMAIN);
out.extend_from_slice(&(entries.len() as u64).to_le_bytes());
for entry in entries {
out.extend_from_slice(entry.as_bytes());
}
out.extend_from_slice(name_table);
out
}
pub const MANIFEST_SIGNATURE_DOMAIN: &[u8] = b"AION_V2_MANIFEST_SIG_V1\0";
#[must_use]
pub fn canonical_manifest_signature_message(
manifest: &ArtifactManifest,
signer: AuthorId,
) -> Vec<u8> {
let capacity = MANIFEST_SIGNATURE_DOMAIN
.len()
.saturating_add(32)
.saturating_add(8);
let mut msg = Vec::with_capacity(capacity);
msg.extend_from_slice(MANIFEST_SIGNATURE_DOMAIN);
msg.extend_from_slice(manifest.manifest_id());
msg.extend_from_slice(&signer.as_u64().to_le_bytes());
msg
}
#[must_use]
pub fn sign_manifest(
manifest: &ArtifactManifest,
signer: AuthorId,
signing_key: &SigningKey,
) -> SignatureEntry {
let message = canonical_manifest_signature_message(manifest, signer);
let signature = signing_key.sign(&message);
let public_key = signing_key.verifying_key().to_bytes();
SignatureEntry::new(signer, public_key, signature)
}
pub fn verify_manifest_signature(
manifest: &ArtifactManifest,
signature: &SignatureEntry,
registry: &crate::key_registry::KeyRegistry,
at_version: u64,
) -> Result<()> {
let signer = AuthorId::new(signature.author_id);
let epoch = registry.active_epoch_at(signer, at_version).ok_or(
crate::AionError::SignatureVerificationFailed {
version: at_version,
author: signer,
},
)?;
if signature.public_key != epoch.public_key {
return Err(crate::AionError::SignatureVerificationFailed {
version: at_version,
author: signer,
});
}
let message = canonical_manifest_signature_message(manifest, signer);
let verifying_key = VerifyingKey::from_bytes(&signature.public_key)?;
verifying_key.verify(&message, &signature.signature)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(deprecated)] mod tests {
use super::*;
#[test]
fn should_build_and_verify_single_artifact() {
let bytes = b"payload bytes";
let mut b = ArtifactManifestBuilder::new();
let _h = b.add("payload.bin", bytes);
let m = b.build();
assert_eq!(m.entries().len(), 1);
assert!(m.verify_artifact("payload.bin", bytes).is_ok());
}
#[test]
fn should_reject_size_mismatch() {
let mut b = ArtifactManifestBuilder::new();
let _ = b.add("x", &[1, 2, 3]);
let m = b.build();
assert!(m.verify_artifact("x", &[1, 2, 3, 4]).is_err());
}
#[test]
fn should_reject_hash_mismatch() {
let mut b = ArtifactManifestBuilder::new();
let _ = b.add("x", &[1, 2, 3]);
let m = b.build();
assert!(m.verify_artifact("x", &[3, 2, 1]).is_err());
}
#[test]
fn should_reject_unknown_name() {
let mut b = ArtifactManifestBuilder::new();
let _ = b.add("x", &[1, 2, 3]);
let m = b.build();
assert!(m.verify_artifact("y", &[1, 2, 3]).is_err());
}
#[test]
fn should_handle_empty_artifact() {
let mut b = ArtifactManifestBuilder::new();
let _ = b.add("empty", &[]);
let m = b.build();
assert!(m.verify_artifact("empty", &[]).is_ok());
}
use crate::key_registry::KeyRegistry;
fn reg_pinning(author: AuthorId, key: &SigningKey) -> KeyRegistry {
let mut reg = KeyRegistry::new();
let master = SigningKey::generate();
reg.register_author(author, master.verifying_key(), key.verifying_key(), 0)
.unwrap_or_else(|_| std::process::abort());
reg
}
#[test]
fn should_sign_and_verify_manifest() {
let mut b = ArtifactManifestBuilder::new();
let _ = b.add("a", b"alpha");
let _ = b.add("b", b"beta");
let m = b.build();
let signer = AuthorId::new(42);
let key = SigningKey::generate();
let sig = sign_manifest(&m, signer, &key);
let reg = reg_pinning(signer, &key);
assert!(verify_manifest_signature(&m, &sig, ®, 1).is_ok());
}
#[test]
fn should_reject_signature_for_different_manifest() {
let key = SigningKey::generate();
let signer = AuthorId::new(7);
let mut b1 = ArtifactManifestBuilder::new();
let _ = b1.add("a", b"alpha");
let m1 = b1.build();
let mut b2 = ArtifactManifestBuilder::new();
let _ = b2.add("a", b"alpha-different");
let m2 = b2.build();
let sig = sign_manifest(&m1, signer, &key);
let reg = reg_pinning(signer, &key);
assert!(verify_manifest_signature(&m2, &sig, ®, 1).is_err());
}
mod reserved_validation {
use super::*;
fn one_entry_canonical_bytes() -> Vec<u8> {
let mut b = ArtifactManifestBuilder::new();
let _ = b.add("a", &[1, 2, 3]);
b.build().canonical_bytes()
}
#[allow(clippy::arithmetic_side_effects)] const fn first_entry_offset() -> usize {
MANIFEST_DOMAIN.len() + 8
}
#[test]
#[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
fn rejects_nonzero_reserved1() {
let mut bytes = one_entry_canonical_bytes();
let target = first_entry_offset() + 14;
bytes[target] = 0x01;
let result = ArtifactManifest::from_canonical_bytes(&bytes);
assert!(
result.is_err(),
"non-zero reserved1 must be rejected, got Ok"
);
}
#[test]
#[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
fn rejects_nonzero_reserved2() {
let mut bytes = one_entry_canonical_bytes();
let target = first_entry_offset() + 100;
bytes[target] = 0x42;
let result = ArtifactManifest::from_canonical_bytes(&bytes);
assert!(
result.is_err(),
"non-zero reserved2 must be rejected, got Ok"
);
}
#[test]
fn well_formed_input_still_round_trips() {
let bytes = one_entry_canonical_bytes();
let manifest = ArtifactManifest::from_canonical_bytes(&bytes).unwrap();
assert_eq!(manifest.canonical_bytes(), bytes);
}
}
mod properties {
use super::*;
use hegel::generators as gs;
fn draw_artifacts(tc: &hegel::TestCase) -> Vec<(String, Vec<u8>)> {
let n = tc.draw(gs::integers::<usize>().min_value(1).max_value(6));
let mut out: Vec<(String, Vec<u8>)> = Vec::with_capacity(n);
let mut counter: u64 = 0;
while out.len() < n {
let bytes = tc.draw(gs::binary().max_size(512));
let name = format!("artifact_{counter}");
counter = counter.saturating_add(1);
out.push((name, bytes));
}
out
}
fn build_manifest(pairs: &[(String, Vec<u8>)]) -> ArtifactManifest {
let mut b = ArtifactManifestBuilder::new();
for (name, bytes) in pairs {
let _ = b.add(name, bytes);
}
b.build()
}
#[hegel::test]
fn prop_manifest_build_verify_roundtrip(tc: hegel::TestCase) {
let pairs = draw_artifacts(&tc);
let manifest = build_manifest(&pairs);
for (name, bytes) in &pairs {
manifest
.verify_artifact(name, bytes)
.unwrap_or_else(|_| std::process::abort());
}
}
#[hegel::test]
fn prop_manifest_byte_flip_rejects(tc: hegel::TestCase) {
let pairs = draw_artifacts(&tc);
let manifest = build_manifest(&pairs);
let candidate = pairs.iter().find(|(_, b)| !b.is_empty());
if let Some((name, bytes)) = candidate {
let mut tampered = bytes.clone();
let max_idx = tampered.len().saturating_sub(1);
let idx = tc.draw(gs::integers::<usize>().max_value(max_idx));
if let Some(b) = tampered.get_mut(idx) {
*b ^= 0x01;
}
assert!(manifest.verify_artifact(name, &tampered).is_err());
}
}
#[hegel::test]
fn prop_manifest_size_mismatch_rejects(tc: hegel::TestCase) {
let pairs = draw_artifacts(&tc);
let manifest = build_manifest(&pairs);
for (name, bytes) in &pairs {
let mut truncated = bytes.clone();
let extra = tc.draw(gs::integers::<u8>().min_value(1).max_value(16));
truncated.extend(std::iter::repeat(0u8).take(usize::from(extra)));
assert!(manifest.verify_artifact(name, &truncated).is_err());
}
}
#[hegel::test]
fn prop_manifest_sign_verify_roundtrip(tc: hegel::TestCase) {
let pairs = draw_artifacts(&tc);
let manifest = build_manifest(&pairs);
let signer = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
let key = SigningKey::generate();
let sig = sign_manifest(&manifest, signer, &key);
let reg = reg_pinning(signer, &key);
assert!(verify_manifest_signature(&manifest, &sig, ®, 1).is_ok());
}
#[hegel::test]
fn prop_manifest_signature_rebinds_after_mutation(tc: hegel::TestCase) {
let pairs = draw_artifacts(&tc);
let m1 = build_manifest(&pairs);
let extra_bytes = tc.draw(gs::binary().min_size(1).max_size(32));
let mut b2 = ArtifactManifestBuilder::new();
for (name, bytes) in &pairs {
let _ = b2.add(name, bytes);
}
let _ = b2.add("__tamper__", &extra_bytes);
let m2 = b2.build();
let signer = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
let key = SigningKey::generate();
let sig = sign_manifest(&m1, signer, &key);
let reg = reg_pinning(signer, &key);
assert!(verify_manifest_signature(&m2, &sig, ®, 1).is_err());
}
#[hegel::test]
fn prop_manifest_signature_rejects_wrong_signer(tc: hegel::TestCase) {
let pairs = draw_artifacts(&tc);
let m = build_manifest(&pairs);
let real_signer =
AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(u64::MAX / 2)));
let fake_signer = AuthorId::new(real_signer.as_u64().saturating_add(1));
let key = SigningKey::generate();
let mut sig = sign_manifest(&m, real_signer, &key);
sig.author_id = fake_signer.as_u64();
let reg = reg_pinning(real_signer, &key);
assert!(verify_manifest_signature(&m, &sig, ®, 1).is_err());
}
#[hegel::test]
fn prop_manifest_signature_domain_is_separated(tc: hegel::TestCase) {
let pairs = draw_artifacts(&tc);
let m = build_manifest(&pairs);
let signer =
AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(u64::MAX / 2)));
let key = SigningKey::generate();
let raw_signature = key.sign(m.manifest_id());
let entry = SignatureEntry::new(signer, key.verifying_key().to_bytes(), raw_signature);
let reg = reg_pinning(signer, &key);
assert!(verify_manifest_signature(&m, &entry, ®, 1).is_err());
}
#[hegel::test]
fn prop_manifest_registry_verify_accepts_active_epoch(tc: hegel::TestCase) {
use crate::key_registry::{sign_rotation_record, KeyRegistry};
let pairs = draw_artifacts(&tc);
let m = build_manifest(&pairs);
let signer =
AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 32)));
let master = SigningKey::generate();
let op = SigningKey::generate();
let mut reg = KeyRegistry::new();
reg.register_author(signer, master.verifying_key(), op.verifying_key(), 0)
.unwrap_or_else(|_| std::process::abort());
let sig = sign_manifest(&m, signer, &op);
let at = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 20));
assert!(verify_manifest_signature(&m, &sig, ®, at).is_ok());
let _ = sign_rotation_record; }
#[hegel::test]
fn prop_manifest_registry_verify_rejects_rotated_out_key(tc: hegel::TestCase) {
use crate::key_registry::{sign_rotation_record, KeyRegistry};
let pairs = draw_artifacts(&tc);
let m = build_manifest(&pairs);
let signer =
AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 32)));
let master = SigningKey::generate();
let op0 = SigningKey::generate();
let op1 = SigningKey::generate();
let mut reg = KeyRegistry::new();
reg.register_author(signer, master.verifying_key(), op0.verifying_key(), 0)
.unwrap_or_else(|_| std::process::abort());
let effective = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 20));
let rotation = sign_rotation_record(
signer,
0,
1,
op1.verifying_key().to_bytes(),
effective,
&master,
);
reg.apply_rotation(&rotation)
.unwrap_or_else(|_| std::process::abort());
let sig = sign_manifest(&m, signer, &op0);
let v_after = effective.saturating_add(1);
assert!(verify_manifest_signature(&m, &sig, ®, v_after).is_err());
}
#[hegel::test]
fn prop_manifest_registry_verify_rejects_pubkey_substitution(tc: hegel::TestCase) {
use crate::key_registry::KeyRegistry;
let pairs = draw_artifacts(&tc);
let m = build_manifest(&pairs);
let signer =
AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 32)));
let master = SigningKey::generate();
let op = SigningKey::generate();
let mut reg = KeyRegistry::new();
reg.register_author(signer, master.verifying_key(), op.verifying_key(), 0)
.unwrap_or_else(|_| std::process::abort());
let attacker = SigningKey::generate();
let sig = sign_manifest(&m, signer, &attacker);
let at = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 20));
assert!(verify_manifest_signature(&m, &sig, ®, at).is_err());
}
#[hegel::test]
fn prop_canonical_round_trip_byte_identical(tc: hegel::TestCase) {
let pairs = draw_artifacts(&tc);
let m = build_manifest(&pairs);
let bytes = m.canonical_bytes();
let reparsed = ArtifactManifest::from_canonical_bytes(&bytes)
.unwrap_or_else(|_| std::process::abort());
if reparsed.canonical_bytes() != bytes {
std::process::abort();
}
if reparsed.manifest_id() != m.manifest_id() {
std::process::abort();
}
}
}
}