Skip to main content

aion_context/
manifest.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! External artifact manifest — RFC-0022.
3//!
4//! `.aion` files attest to governance context (policy, approvals, audit).
5//! Large artifacts the governance refers to — pretrained model weights,
6//! datasets, firmware images — are too large to embed. This module lets
7//! an `.aion` file bind to external binary blobs by their BLAKE3 hash,
8//! carrying `(name, size, hash)` triples in a signed manifest.
9//!
10//! Phase A (this module) provides:
11//!
12//! - [`ArtifactEntry`]: 128-byte `#[repr(C)]` descriptor — name offset,
13//!   size, hash algorithm, 32-byte BLAKE3 hash.
14//! - [`ArtifactManifestBuilder`]: accumulates entries, computes hashes.
15//! - [`ArtifactManifest`]: built manifest with canonical serialization.
16//! - [`sign_manifest`] / [`verify_manifest_signature`]: signing path
17//!   bound to the RFC-0021 attestation domain.
18//!
19//! Phase B (future RFC) will embed the manifest in the on-disk
20//! `.aion` file format and add CLI / SLSA / AIBOM integration.
21//!
22//! # Example
23//!
24//! ```
25//! use aion_context::manifest::{ArtifactManifestBuilder, sign_manifest, verify_manifest_signature};
26//! use aion_context::crypto::SigningKey;
27//! use aion_context::key_registry::KeyRegistry;
28//! use aion_context::types::AuthorId;
29//!
30//! let mut builder = ArtifactManifestBuilder::new();
31//! let weights: Vec<u8> = vec![0xAA; 128];
32//! let _handle = builder.add("model.bin", &weights);
33//! let manifest = builder.build();
34//!
35//! manifest.verify_artifact("model.bin", &weights).unwrap();
36//!
37//! let signer = AuthorId::new(1001);
38//! let master = SigningKey::generate();
39//! let key = SigningKey::generate();
40//! let mut registry = KeyRegistry::new();
41//! registry.register_author(signer, master.verifying_key(), key.verifying_key(), 0).unwrap();
42//!
43//! let sig = sign_manifest(&manifest, signer, &key);
44//! assert!(verify_manifest_signature(&manifest, &sig, &registry, 1).is_ok());
45//! ```
46
47use zerocopy::AsBytes;
48
49use crate::crypto::{hash, SigningKey, VerifyingKey};
50use crate::serializer::SignatureEntry;
51use crate::types::AuthorId;
52use crate::{AionError, Result};
53
54/// Size of a serialized [`ArtifactEntry`] in bytes.
55pub const ARTIFACT_ENTRY_SIZE: usize = 128;
56
57/// Inner domain separator for canonical manifest bytes (RFC-0022).
58///
59/// Distinct from the attestation domain so a manifest signature cannot
60/// collide with a version attestation even though both route through
61/// `canonical_attestation_message`.
62const MANIFEST_DOMAIN: &[u8] = b"AION_V2_MANIFEST_V1";
63
64/// Hash algorithms recognized by an [`ArtifactEntry`].
65///
66/// Stored as a `u16` on disk so new algorithms can be added without a
67/// struct layout change.
68#[repr(u16)]
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum HashAlgorithm {
71    /// BLAKE3-256, 32-byte digest.
72    Blake3_256 = 1,
73}
74
75impl HashAlgorithm {
76    /// Convert a raw `u16` back to a known algorithm.
77    ///
78    /// # Errors
79    ///
80    /// Returns `AionError::InvalidFormat` for unrecognized discriminants.
81    pub fn from_u16(value: u16) -> Result<Self> {
82        match value {
83            1 => Ok(Self::Blake3_256),
84            other => Err(AionError::InvalidFormat {
85                reason: format!("Unknown manifest hash algorithm: {other}"),
86            }),
87        }
88    }
89}
90
91/// Fixed-size descriptor for a single external artifact — 128 bytes.
92///
93/// Field layout is stable and `#[repr(C)]` for zero-copy serialization.
94#[repr(C)]
95#[derive(Debug, Clone, Copy, AsBytes)]
96pub struct ArtifactEntry {
97    /// Offset of the artifact's name in the manifest's name table.
98    pub name_offset: u64,
99    /// Length of the artifact's name in bytes (UTF-8, no null).
100    pub name_length: u32,
101    /// Hash algorithm discriminant — see [`HashAlgorithm`].
102    pub hash_algorithm: u16,
103    /// Reserved; must be zero.
104    pub reserved1: [u8; 2],
105    /// Size of the external artifact in bytes.
106    pub size: u64,
107    /// 32-byte hash of the full artifact content.
108    pub hash: [u8; 32],
109    /// Reserved; must be zero. Space for future Merkle root, chunk
110    /// size, or per-entry signature scope (Phase B).
111    pub reserved2: [u8; 72],
112}
113
114const _: () = assert!(std::mem::size_of::<ArtifactEntry>() == ARTIFACT_ENTRY_SIZE);
115
116impl ArtifactEntry {
117    /// Build a new entry. The caller supplies offsets derived from the
118    /// manifest's name table; prefer [`ArtifactManifestBuilder::add`].
119    #[must_use]
120    pub const fn new(name_offset: u64, name_length: u32, size: u64, hash: [u8; 32]) -> Self {
121        Self {
122            name_offset,
123            name_length,
124            hash_algorithm: HashAlgorithm::Blake3_256 as u16,
125            reserved1: [0; 2],
126            size,
127            hash,
128            reserved2: [0; 72],
129        }
130    }
131}
132
133/// Handle returned by [`ArtifactManifestBuilder::add`] — opaque for now.
134///
135/// Phase B may extend this with chunk offsets or Merkle-proof positions.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct ArtifactHandle {
138    index: usize,
139}
140
141impl ArtifactHandle {
142    /// 0-based position of the artifact in the manifest's entry list.
143    #[must_use]
144    pub const fn index(self) -> usize {
145        self.index
146    }
147}
148
149/// Accumulator for building an [`ArtifactManifest`].
150#[derive(Debug, Default)]
151pub struct ArtifactManifestBuilder {
152    entries: Vec<ArtifactEntry>,
153    name_table: Vec<u8>,
154}
155
156impl ArtifactManifestBuilder {
157    /// Construct an empty builder.
158    #[must_use]
159    #[allow(clippy::missing_const_for_fn)] // Vec::new() not const in MSRV 1.70
160    pub fn new() -> Self {
161        Self {
162            entries: Vec::new(),
163            name_table: Vec::new(),
164        }
165    }
166
167    /// Register an artifact by `name` and compute its BLAKE3 hash over
168    /// `bytes`. Returns a handle; the manifest records the hash, not
169    /// the bytes themselves.
170    #[must_use = "the returned ArtifactHandle is the only way to refer to this artifact by index later"]
171    #[allow(clippy::cast_possible_truncation)] // Name lengths capped by u32::MAX in practice
172    pub fn add(&mut self, name: &str, bytes: &[u8]) -> ArtifactHandle {
173        let name_offset = self.name_table.len() as u64;
174        let name_length = name.len() as u32;
175        self.name_table.extend_from_slice(name.as_bytes());
176        self.name_table.push(0);
177
178        let digest = hash(bytes);
179        let entry = ArtifactEntry::new(name_offset, name_length, bytes.len() as u64, digest);
180        let index = self.entries.len();
181        self.entries.push(entry);
182        ArtifactHandle { index }
183    }
184
185    /// Finalize the manifest. Computes the manifest-level hash over
186    /// the canonical bytes.
187    #[must_use]
188    pub fn build(self) -> ArtifactManifest {
189        let canonical = canonical_manifest_bytes(&self.entries, &self.name_table);
190        let manifest_id = hash(&canonical);
191        ArtifactManifest {
192            manifest_id,
193            entries: self.entries,
194            name_table: self.name_table,
195        }
196    }
197}
198
199/// A built, attestable artifact manifest.
200#[derive(Debug, Clone)]
201pub struct ArtifactManifest {
202    /// BLAKE3 hash of the canonical manifest bytes.
203    manifest_id: [u8; 32],
204    entries: Vec<ArtifactEntry>,
205    name_table: Vec<u8>,
206}
207
208impl ArtifactManifest {
209    /// Manifest-level identity hash.
210    #[must_use]
211    pub const fn manifest_id(&self) -> &[u8; 32] {
212        &self.manifest_id
213    }
214
215    /// Entry list (stable order).
216    #[must_use]
217    pub fn entries(&self) -> &[ArtifactEntry] {
218        &self.entries
219    }
220
221    /// Raw name-table bytes (null-terminated UTF-8).
222    #[must_use]
223    pub fn name_table(&self) -> &[u8] {
224        &self.name_table
225    }
226
227    /// Canonical serialized form used for signing and hashing.
228    #[must_use]
229    pub fn canonical_bytes(&self) -> Vec<u8> {
230        canonical_manifest_bytes(&self.entries, &self.name_table)
231    }
232
233    /// Parse a manifest from its canonical form. Inverse of
234    /// [`Self::canonical_bytes`].
235    ///
236    /// Layout: `MANIFEST_DOMAIN (19 B) || entry_count_le (8 B) ||
237    /// entries (N × 128 B) || name_table (rest)`.
238    ///
239    /// The resulting `manifest_id` is the BLAKE3 of the same
240    /// canonical bytes, so `from_canonical_bytes(bytes).manifest_id()`
241    /// matches `m.manifest_id()` for any `bytes = m.canonical_bytes()`.
242    ///
243    /// # Errors
244    ///
245    /// Returns `AionError::InvalidFormat` if the magic prefix is
246    /// wrong, the entry count exceeds the remaining bytes, or any
247    /// entry slice is not exactly [`ARTIFACT_ENTRY_SIZE`] bytes.
248    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self> {
249        let manifest_id = crate::crypto::hash(bytes);
250        let body = bytes
251            .strip_prefix(MANIFEST_DOMAIN)
252            .ok_or_else(|| AionError::InvalidFormat {
253                reason: "manifest canonical bytes missing domain prefix".to_string(),
254            })?;
255        if body.len() < 8 {
256            return Err(AionError::InvalidFormat {
257                reason: "manifest canonical bytes truncated before entry count".to_string(),
258            });
259        }
260        let (count_bytes, rest) = body.split_at(8);
261        let mut count_arr = [0u8; 8];
262        count_arr.copy_from_slice(count_bytes);
263        let entry_count = u64::from_le_bytes(count_arr) as usize;
264
265        let entries_len = entry_count
266            .checked_mul(ARTIFACT_ENTRY_SIZE)
267            .ok_or_else(|| AionError::InvalidFormat {
268                reason: "manifest entry count overflows usize".to_string(),
269            })?;
270        if rest.len() < entries_len {
271            return Err(AionError::InvalidFormat {
272                reason: format!(
273                    "manifest entries truncated: need {} bytes, have {}",
274                    entries_len,
275                    rest.len()
276                ),
277            });
278        }
279        let (entries_slice, name_table_slice) = rest.split_at(entries_len);
280        let mut entries = Vec::with_capacity(entry_count);
281        for i in 0..entry_count {
282            let start =
283                i.checked_mul(ARTIFACT_ENTRY_SIZE)
284                    .ok_or_else(|| AionError::InvalidFormat {
285                        reason: "entry index overflow".to_string(),
286                    })?;
287            let end =
288                start
289                    .checked_add(ARTIFACT_ENTRY_SIZE)
290                    .ok_or_else(|| AionError::InvalidFormat {
291                        reason: "entry end overflow".to_string(),
292                    })?;
293            let slice = entries_slice
294                .get(start..end)
295                .ok_or_else(|| AionError::InvalidFormat {
296                    reason: "entry slice out of bounds".to_string(),
297                })?;
298            entries.push(parse_artifact_entry(slice)?);
299        }
300
301        Ok(Self {
302            manifest_id,
303            entries,
304            name_table: name_table_slice.to_vec(),
305        })
306    }
307
308    /// Look up the name string for an entry.
309    ///
310    /// # Errors
311    ///
312    /// Returns `AionError::InvalidFormat` if `name_offset` + `name_length`
313    /// fall outside the name table or the slice is not valid UTF-8.
314    pub fn name_of(&self, entry: &ArtifactEntry) -> Result<&str> {
315        slice_name(&self.name_table, entry.name_offset, entry.name_length)
316    }
317
318    /// Re-hash `bytes` and check it matches the recorded size and hash
319    /// for `name`. First-match wins if duplicate names were added.
320    ///
321    /// # Errors
322    ///
323    /// Returns `AionError::InvalidFormat` if no entry matches the name,
324    /// the byte length disagrees with the recorded size, or the BLAKE3
325    /// digest differs.
326    pub fn verify_artifact(&self, name: &str, bytes: &[u8]) -> Result<()> {
327        for entry in &self.entries {
328            let candidate = self.name_of(entry)?;
329            if candidate != name {
330                continue;
331            }
332            if bytes.len() as u64 != entry.size {
333                return Err(AionError::InvalidFormat {
334                    reason: format!(
335                        "artifact '{name}': size mismatch (expected {}, got {})",
336                        entry.size,
337                        bytes.len()
338                    ),
339                });
340            }
341            let digest = hash(bytes);
342            if digest != entry.hash {
343                return Err(AionError::InvalidFormat {
344                    reason: format!("artifact '{name}': hash mismatch"),
345                });
346            }
347            return Ok(());
348        }
349        Err(AionError::InvalidFormat {
350            reason: format!("artifact '{name}' not found in manifest"),
351        })
352    }
353}
354
355/// Extract a name slice from a name table.
356fn slice_name(table: &[u8], offset: u64, length: u32) -> Result<&str> {
357    let start = usize::try_from(offset).map_err(|_| AionError::InvalidFormat {
358        reason: "manifest name_offset exceeds usize".to_string(),
359    })?;
360    let len = length as usize;
361    let end = start
362        .checked_add(len)
363        .ok_or_else(|| AionError::InvalidFormat {
364            reason: "manifest name_offset + name_length overflows".to_string(),
365        })?;
366    let slice = table
367        .get(start..end)
368        .ok_or_else(|| AionError::InvalidFormat {
369            reason: "manifest name slice out of bounds".to_string(),
370        })?;
371    std::str::from_utf8(slice).map_err(|e| AionError::InvalidFormat {
372        reason: format!("manifest name is not valid UTF-8: {e}"),
373    })
374}
375
376/// Parse one 128-byte canonical `ArtifactEntry` slice back into a
377/// struct. Inverse of `entry.as_bytes()`; preserves the on-disk
378/// `hash_algorithm` and reserved fields.
379fn parse_artifact_entry(slice: &[u8]) -> Result<ArtifactEntry> {
380    if slice.len() != ARTIFACT_ENTRY_SIZE {
381        return Err(AionError::InvalidFormat {
382            reason: format!(
383                "artifact entry slice must be {ARTIFACT_ENTRY_SIZE} bytes, got {}",
384                slice.len()
385            ),
386        });
387    }
388    let read_u64 = |from: usize| -> Result<u64> {
389        let end = from
390            .checked_add(8)
391            .ok_or_else(|| AionError::InvalidFormat {
392                reason: "u64 offset overflow".to_string(),
393            })?;
394        let bytes = slice
395            .get(from..end)
396            .ok_or_else(|| AionError::InvalidFormat {
397                reason: "u64 read out of bounds".to_string(),
398            })?;
399        let mut a = [0u8; 8];
400        a.copy_from_slice(bytes);
401        Ok(u64::from_le_bytes(a))
402    };
403    let name_offset = read_u64(0)?;
404    let size = read_u64(16)?;
405    let name_length = {
406        let bytes = slice.get(8..12).ok_or_else(|| AionError::InvalidFormat {
407            reason: "name_length out of bounds".to_string(),
408        })?;
409        let mut a = [0u8; 4];
410        a.copy_from_slice(bytes);
411        u32::from_le_bytes(a)
412    };
413    let hash_algorithm = {
414        let bytes = slice.get(12..14).ok_or_else(|| AionError::InvalidFormat {
415            reason: "hash_algorithm out of bounds".to_string(),
416        })?;
417        let mut a = [0u8; 2];
418        a.copy_from_slice(bytes);
419        u16::from_le_bytes(a)
420    };
421    let mut hash = [0u8; 32];
422    let hash_bytes = slice.get(24..56).ok_or_else(|| AionError::InvalidFormat {
423        reason: "hash bytes out of bounds".to_string(),
424    })?;
425    hash.copy_from_slice(hash_bytes);
426
427    // Reserved fields must be zero — issue #40. Silently accepting
428    // non-zero reserved bits would let producers ship manifests
429    // whose canonical_bytes() round-trip changes manifest_id, since
430    // the parser would re-emit them as zero. Strict validation
431    // matches the format spec and keeps manifest_id stable.
432    let reserved1 = slice.get(14..16).ok_or_else(|| AionError::InvalidFormat {
433        reason: "reserved1 out of bounds".to_string(),
434    })?;
435    if reserved1.iter().any(|b| *b != 0) {
436        return Err(AionError::InvalidFormat {
437            reason: "ArtifactEntry reserved1 must be all zero".to_string(),
438        });
439    }
440    let reserved2 = slice.get(56..128).ok_or_else(|| AionError::InvalidFormat {
441        reason: "reserved2 out of bounds".to_string(),
442    })?;
443    if reserved2.iter().any(|b| *b != 0) {
444        return Err(AionError::InvalidFormat {
445            reason: "ArtifactEntry reserved2 must be all zero".to_string(),
446        });
447    }
448
449    Ok(ArtifactEntry {
450        name_offset,
451        name_length,
452        hash_algorithm,
453        reserved1: [0u8; 2],
454        size,
455        hash,
456        reserved2: [0u8; 72],
457    })
458}
459
460/// Serialize the manifest to its canonical on-wire form:
461/// `MANIFEST_DOMAIN || entry_count_le || entries || name_table`.
462fn canonical_manifest_bytes(entries: &[ArtifactEntry], name_table: &[u8]) -> Vec<u8> {
463    let entries_len = entries
464        .len()
465        .checked_mul(ARTIFACT_ENTRY_SIZE)
466        .unwrap_or_else(|| std::process::abort());
467    let capacity = MANIFEST_DOMAIN
468        .len()
469        .saturating_add(8)
470        .saturating_add(entries_len)
471        .saturating_add(name_table.len());
472    let mut out = Vec::with_capacity(capacity);
473    out.extend_from_slice(MANIFEST_DOMAIN);
474    out.extend_from_slice(&(entries.len() as u64).to_le_bytes());
475    for entry in entries {
476        out.extend_from_slice(entry.as_bytes());
477    }
478    out.extend_from_slice(name_table);
479    out
480}
481
482/// Domain separator for manifest-identity signatures.
483///
484/// Dedicated to manifest signing under RFC-0033 C7, so the bytes
485/// signed are never confused with — nor replayable as — a
486/// multi-party attestation produced by `signature_chain` under
487/// `ATTESTATION_DOMAIN`. The trailing NUL forbids any other
488/// aion domain from being constructed by appending bytes.
489pub const MANIFEST_SIGNATURE_DOMAIN: &[u8] = b"AION_V2_MANIFEST_SIG_V1\0";
490
491/// Build the canonical bytes signed by [`sign_manifest`] and
492/// verified by [`verify_manifest_signature`]:
493/// `MANIFEST_SIGNATURE_DOMAIN || manifest_id(32 B) || signer_le(8 B)`.
494#[must_use]
495pub fn canonical_manifest_signature_message(
496    manifest: &ArtifactManifest,
497    signer: AuthorId,
498) -> Vec<u8> {
499    let capacity = MANIFEST_SIGNATURE_DOMAIN
500        .len()
501        .saturating_add(32)
502        .saturating_add(8);
503    let mut msg = Vec::with_capacity(capacity);
504    msg.extend_from_slice(MANIFEST_SIGNATURE_DOMAIN);
505    msg.extend_from_slice(manifest.manifest_id());
506    msg.extend_from_slice(&signer.as_u64().to_le_bytes());
507    msg
508}
509
510/// Sign a manifest as `signer` using `signing_key`. The returned
511/// [`SignatureEntry`] binds to the manifest-id under the dedicated
512/// manifest-signature domain ([`MANIFEST_SIGNATURE_DOMAIN`]).
513#[must_use]
514pub fn sign_manifest(
515    manifest: &ArtifactManifest,
516    signer: AuthorId,
517    signing_key: &SigningKey,
518) -> SignatureEntry {
519    let message = canonical_manifest_signature_message(manifest, signer);
520    let signature = signing_key.sign(&message);
521    let public_key = signing_key.verifying_key().to_bytes();
522    SignatureEntry::new(signer, public_key, signature)
523}
524
525/// Verify a manifest signature against a pinned
526/// [`KeyRegistry`](crate::key_registry::KeyRegistry) — RFC-0022 / RFC-0034.
527///
528/// Cross-checks `signature.public_key` against the active epoch
529/// for `(signature.author_id, at_version)` in `registry` before
530/// running the Ed25519 verify. Rejects signatures made by keys
531/// that have been rotated out or revoked as of `at_version`, and
532/// signatures whose embedded public key does not match the
533/// registered active epoch (closing the `public_key`-substitution
534/// gap).
535///
536/// # Errors
537///
538/// Returns `AionError::SignatureVerificationFailed { version: at_version, author }`
539/// if the registry has no active epoch for the signer at
540/// `at_version`, if the signature's embedded public key does not
541/// match that epoch, or if the underlying Ed25519 verification
542/// fails.
543pub fn verify_manifest_signature(
544    manifest: &ArtifactManifest,
545    signature: &SignatureEntry,
546    registry: &crate::key_registry::KeyRegistry,
547    at_version: u64,
548) -> Result<()> {
549    let signer = AuthorId::new(signature.author_id);
550    let epoch = registry.active_epoch_at(signer, at_version).ok_or(
551        crate::AionError::SignatureVerificationFailed {
552            version: at_version,
553            author: signer,
554        },
555    )?;
556    if signature.public_key != epoch.public_key {
557        return Err(crate::AionError::SignatureVerificationFailed {
558            version: at_version,
559            author: signer,
560        });
561    }
562    let message = canonical_manifest_signature_message(manifest, signer);
563    let verifying_key = VerifyingKey::from_bytes(&signature.public_key)?;
564    verifying_key.verify(&message, &signature.signature)
565}
566
567#[cfg(test)]
568#[allow(clippy::unwrap_used)]
569#[allow(deprecated)] // RFC-0034 Phase D: tests exercise the deprecated raw-key verify_manifest_signature contract
570mod tests {
571    use super::*;
572
573    #[test]
574    fn should_build_and_verify_single_artifact() {
575        let bytes = b"payload bytes";
576        let mut b = ArtifactManifestBuilder::new();
577        let _h = b.add("payload.bin", bytes);
578        let m = b.build();
579        assert_eq!(m.entries().len(), 1);
580        assert!(m.verify_artifact("payload.bin", bytes).is_ok());
581    }
582
583    #[test]
584    fn should_reject_size_mismatch() {
585        let mut b = ArtifactManifestBuilder::new();
586        let _ = b.add("x", &[1, 2, 3]);
587        let m = b.build();
588        assert!(m.verify_artifact("x", &[1, 2, 3, 4]).is_err());
589    }
590
591    #[test]
592    fn should_reject_hash_mismatch() {
593        let mut b = ArtifactManifestBuilder::new();
594        let _ = b.add("x", &[1, 2, 3]);
595        let m = b.build();
596        assert!(m.verify_artifact("x", &[3, 2, 1]).is_err());
597    }
598
599    #[test]
600    fn should_reject_unknown_name() {
601        let mut b = ArtifactManifestBuilder::new();
602        let _ = b.add("x", &[1, 2, 3]);
603        let m = b.build();
604        assert!(m.verify_artifact("y", &[1, 2, 3]).is_err());
605    }
606
607    #[test]
608    fn should_handle_empty_artifact() {
609        let mut b = ArtifactManifestBuilder::new();
610        let _ = b.add("empty", &[]);
611        let m = b.build();
612        assert!(m.verify_artifact("empty", &[]).is_ok());
613    }
614
615    use crate::key_registry::KeyRegistry;
616
617    /// Minimal test fixture: pin `key` as the active op key for `author` at epoch 0.
618    fn reg_pinning(author: AuthorId, key: &SigningKey) -> KeyRegistry {
619        let mut reg = KeyRegistry::new();
620        let master = SigningKey::generate();
621        reg.register_author(author, master.verifying_key(), key.verifying_key(), 0)
622            .unwrap_or_else(|_| std::process::abort());
623        reg
624    }
625
626    #[test]
627    fn should_sign_and_verify_manifest() {
628        let mut b = ArtifactManifestBuilder::new();
629        let _ = b.add("a", b"alpha");
630        let _ = b.add("b", b"beta");
631        let m = b.build();
632        let signer = AuthorId::new(42);
633        let key = SigningKey::generate();
634        let sig = sign_manifest(&m, signer, &key);
635        let reg = reg_pinning(signer, &key);
636        assert!(verify_manifest_signature(&m, &sig, &reg, 1).is_ok());
637    }
638
639    #[test]
640    fn should_reject_signature_for_different_manifest() {
641        let key = SigningKey::generate();
642        let signer = AuthorId::new(7);
643
644        let mut b1 = ArtifactManifestBuilder::new();
645        let _ = b1.add("a", b"alpha");
646        let m1 = b1.build();
647
648        let mut b2 = ArtifactManifestBuilder::new();
649        let _ = b2.add("a", b"alpha-different");
650        let m2 = b2.build();
651
652        let sig = sign_manifest(&m1, signer, &key);
653        let reg = reg_pinning(signer, &key);
654        assert!(verify_manifest_signature(&m2, &sig, &reg, 1).is_err());
655    }
656
657    /// Issue #40 — reserved fields must validate as zero. Catches
658    /// the round-trip identity gap surfaced by the fuzz harness in
659    /// PR #39: silently accepting non-zero reserved bits would let
660    /// `canonical_bytes() ↔ from_canonical_bytes()` produce drifting
661    /// `manifest_id` values for the same logical content.
662    mod reserved_validation {
663        use super::*;
664
665        /// Build a one-entry canonical manifest, return its bytes
666        /// alongside the entry's offset so the test can flip a
667        /// reserved bit at a known location.
668        fn one_entry_canonical_bytes() -> Vec<u8> {
669            let mut b = ArtifactManifestBuilder::new();
670            let _ = b.add("a", &[1, 2, 3]);
671            b.build().canonical_bytes()
672        }
673
674        /// Locate the start of the first `ArtifactEntry` in a canonical
675        /// manifest: 19 bytes domain prefix + 8 bytes count.
676        #[allow(clippy::arithmetic_side_effects)] // domain length + 8 is bounded
677        const fn first_entry_offset() -> usize {
678            MANIFEST_DOMAIN.len() + 8
679        }
680
681        #[test]
682        #[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
683        fn rejects_nonzero_reserved1() {
684            let mut bytes = one_entry_canonical_bytes();
685            // reserved1 occupies entry-relative bytes 14..16.
686            let target = first_entry_offset() + 14;
687            bytes[target] = 0x01;
688            let result = ArtifactManifest::from_canonical_bytes(&bytes);
689            assert!(
690                result.is_err(),
691                "non-zero reserved1 must be rejected, got Ok"
692            );
693        }
694
695        #[test]
696        #[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
697        fn rejects_nonzero_reserved2() {
698            let mut bytes = one_entry_canonical_bytes();
699            // reserved2 occupies entry-relative bytes 56..128. Pick
700            // offset 100 — squarely inside reserved2.
701            let target = first_entry_offset() + 100;
702            bytes[target] = 0x42;
703            let result = ArtifactManifest::from_canonical_bytes(&bytes);
704            assert!(
705                result.is_err(),
706                "non-zero reserved2 must be rejected, got Ok"
707            );
708        }
709
710        #[test]
711        fn well_formed_input_still_round_trips() {
712            let bytes = one_entry_canonical_bytes();
713            let manifest = ArtifactManifest::from_canonical_bytes(&bytes).unwrap();
714            assert_eq!(manifest.canonical_bytes(), bytes);
715        }
716    }
717
718    mod properties {
719        use super::*;
720        use hegel::generators as gs;
721
722        /// Draw a list of (name, bytes) pairs with distinct names so
723        /// `verify_artifact` can unambiguously look each entry up.
724        fn draw_artifacts(tc: &hegel::TestCase) -> Vec<(String, Vec<u8>)> {
725            let n = tc.draw(gs::integers::<usize>().min_value(1).max_value(6));
726            let mut out: Vec<(String, Vec<u8>)> = Vec::with_capacity(n);
727            let mut counter: u64 = 0;
728            while out.len() < n {
729                let bytes = tc.draw(gs::binary().max_size(512));
730                // Synthetic distinct names: "a_0", "a_1", ...
731                let name = format!("artifact_{counter}");
732                counter = counter.saturating_add(1);
733                out.push((name, bytes));
734            }
735            out
736        }
737
738        fn build_manifest(pairs: &[(String, Vec<u8>)]) -> ArtifactManifest {
739            let mut b = ArtifactManifestBuilder::new();
740            for (name, bytes) in pairs {
741                let _ = b.add(name, bytes);
742            }
743            b.build()
744        }
745
746        #[hegel::test]
747        fn prop_manifest_build_verify_roundtrip(tc: hegel::TestCase) {
748            let pairs = draw_artifacts(&tc);
749            let manifest = build_manifest(&pairs);
750            for (name, bytes) in &pairs {
751                manifest
752                    .verify_artifact(name, bytes)
753                    .unwrap_or_else(|_| std::process::abort());
754            }
755        }
756
757        #[hegel::test]
758        fn prop_manifest_byte_flip_rejects(tc: hegel::TestCase) {
759            let pairs = draw_artifacts(&tc);
760            let manifest = build_manifest(&pairs);
761            // Pick an entry with at least one byte to tamper.
762            let candidate = pairs.iter().find(|(_, b)| !b.is_empty());
763            if let Some((name, bytes)) = candidate {
764                let mut tampered = bytes.clone();
765                let max_idx = tampered.len().saturating_sub(1);
766                let idx = tc.draw(gs::integers::<usize>().max_value(max_idx));
767                if let Some(b) = tampered.get_mut(idx) {
768                    *b ^= 0x01;
769                }
770                assert!(manifest.verify_artifact(name, &tampered).is_err());
771            }
772        }
773
774        #[hegel::test]
775        fn prop_manifest_size_mismatch_rejects(tc: hegel::TestCase) {
776            let pairs = draw_artifacts(&tc);
777            let manifest = build_manifest(&pairs);
778            for (name, bytes) in &pairs {
779                let mut truncated = bytes.clone();
780                let extra = tc.draw(gs::integers::<u8>().min_value(1).max_value(16));
781                truncated.extend(std::iter::repeat(0u8).take(usize::from(extra)));
782                assert!(manifest.verify_artifact(name, &truncated).is_err());
783            }
784        }
785
786        #[hegel::test]
787        fn prop_manifest_sign_verify_roundtrip(tc: hegel::TestCase) {
788            let pairs = draw_artifacts(&tc);
789            let manifest = build_manifest(&pairs);
790            let signer = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
791            let key = SigningKey::generate();
792            let sig = sign_manifest(&manifest, signer, &key);
793            let reg = reg_pinning(signer, &key);
794            assert!(verify_manifest_signature(&manifest, &sig, &reg, 1).is_ok());
795        }
796
797        #[hegel::test]
798        fn prop_manifest_signature_rebinds_after_mutation(tc: hegel::TestCase) {
799            // A signature made for manifest M1 must not verify for any
800            // manifest M2 that differs in entry content or name.
801            let pairs = draw_artifacts(&tc);
802            let m1 = build_manifest(&pairs);
803            // Build m2 with one extra entry -> different manifest_id.
804            let extra_bytes = tc.draw(gs::binary().min_size(1).max_size(32));
805            let mut b2 = ArtifactManifestBuilder::new();
806            for (name, bytes) in &pairs {
807                let _ = b2.add(name, bytes);
808            }
809            let _ = b2.add("__tamper__", &extra_bytes);
810            let m2 = b2.build();
811            let signer = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
812            let key = SigningKey::generate();
813            let sig = sign_manifest(&m1, signer, &key);
814            let reg = reg_pinning(signer, &key);
815            assert!(verify_manifest_signature(&m2, &sig, &reg, 1).is_err());
816        }
817
818        #[hegel::test]
819        fn prop_manifest_signature_rejects_wrong_signer(tc: hegel::TestCase) {
820            let pairs = draw_artifacts(&tc);
821            let m = build_manifest(&pairs);
822            let real_signer =
823                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(u64::MAX / 2)));
824            let fake_signer = AuthorId::new(real_signer.as_u64().saturating_add(1));
825            let key = SigningKey::generate();
826            let mut sig = sign_manifest(&m, real_signer, &key);
827            sig.author_id = fake_signer.as_u64();
828            // Pin the real_signer; tamper claims fake_signer; not in registry → reject.
829            let reg = reg_pinning(real_signer, &key);
830            assert!(verify_manifest_signature(&m, &sig, &reg, 1).is_err());
831        }
832
833        #[hegel::test]
834        fn prop_manifest_signature_domain_is_separated(tc: hegel::TestCase) {
835            // RFC-0033 C7: manifest signing uses MANIFEST_SIGNATURE_DOMAIN,
836            // which must differ from any other aion signing domain. A
837            // raw Ed25519 signature produced directly over the
838            // manifest_id — i.e. without MANIFEST_SIGNATURE_DOMAIN —
839            // must not verify as a manifest signature.
840            let pairs = draw_artifacts(&tc);
841            let m = build_manifest(&pairs);
842            let signer =
843                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(u64::MAX / 2)));
844            let key = SigningKey::generate();
845            let raw_signature = key.sign(m.manifest_id());
846            let entry = SignatureEntry::new(signer, key.verifying_key().to_bytes(), raw_signature);
847            let reg = reg_pinning(signer, &key);
848            assert!(verify_manifest_signature(&m, &entry, &reg, 1).is_err());
849        }
850
851        #[hegel::test]
852        fn prop_manifest_registry_verify_accepts_active_epoch(tc: hegel::TestCase) {
853            use crate::key_registry::{sign_rotation_record, KeyRegistry};
854            let pairs = draw_artifacts(&tc);
855            let m = build_manifest(&pairs);
856            let signer =
857                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 32)));
858            let master = SigningKey::generate();
859            let op = SigningKey::generate();
860            let mut reg = KeyRegistry::new();
861            reg.register_author(signer, master.verifying_key(), op.verifying_key(), 0)
862                .unwrap_or_else(|_| std::process::abort());
863            let sig = sign_manifest(&m, signer, &op);
864            let at = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 20));
865            assert!(verify_manifest_signature(&m, &sig, &reg, at).is_ok());
866            let _ = sign_rotation_record; // keep import live in all test configs
867        }
868
869        #[hegel::test]
870        fn prop_manifest_registry_verify_rejects_rotated_out_key(tc: hegel::TestCase) {
871            use crate::key_registry::{sign_rotation_record, KeyRegistry};
872            let pairs = draw_artifacts(&tc);
873            let m = build_manifest(&pairs);
874            let signer =
875                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 32)));
876            let master = SigningKey::generate();
877            let op0 = SigningKey::generate();
878            let op1 = SigningKey::generate();
879            let mut reg = KeyRegistry::new();
880            reg.register_author(signer, master.verifying_key(), op0.verifying_key(), 0)
881                .unwrap_or_else(|_| std::process::abort());
882            let effective = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 20));
883            let rotation = sign_rotation_record(
884                signer,
885                0,
886                1,
887                op1.verifying_key().to_bytes(),
888                effective,
889                &master,
890            );
891            reg.apply_rotation(&rotation)
892                .unwrap_or_else(|_| std::process::abort());
893            // Sign the manifest with the rotated-OUT op0 key.
894            let sig = sign_manifest(&m, signer, &op0);
895            let v_after = effective.saturating_add(1);
896            assert!(verify_manifest_signature(&m, &sig, &reg, v_after).is_err());
897        }
898
899        #[hegel::test]
900        fn prop_manifest_registry_verify_rejects_pubkey_substitution(tc: hegel::TestCase) {
901            use crate::key_registry::KeyRegistry;
902            let pairs = draw_artifacts(&tc);
903            let m = build_manifest(&pairs);
904            let signer =
905                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 32)));
906            let master = SigningKey::generate();
907            let op = SigningKey::generate();
908            let mut reg = KeyRegistry::new();
909            reg.register_author(signer, master.verifying_key(), op.verifying_key(), 0)
910                .unwrap_or_else(|_| std::process::abort());
911            // Attacker mints a valid-shaped key and signs under the target AuthorId.
912            let attacker = SigningKey::generate();
913            let sig = sign_manifest(&m, signer, &attacker);
914            let at = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 20));
915            // With only the registry-aware API remaining (Phase E), this
916            // must reject because the attacker's pubkey does not match the
917            // pinned active epoch.
918            assert!(verify_manifest_signature(&m, &sig, &reg, at).is_err());
919        }
920
921        /// Issue #40 — for any well-formed manifest produced by
922        /// the public builder, `canonical_bytes` ↔ `from_canonical_bytes`
923        /// is byte-identical (and `manifest_id` therefore stable).
924        #[hegel::test]
925        fn prop_canonical_round_trip_byte_identical(tc: hegel::TestCase) {
926            let pairs = draw_artifacts(&tc);
927            let m = build_manifest(&pairs);
928            let bytes = m.canonical_bytes();
929            let reparsed = ArtifactManifest::from_canonical_bytes(&bytes)
930                .unwrap_or_else(|_| std::process::abort());
931            if reparsed.canonical_bytes() != bytes {
932                std::process::abort();
933            }
934            if reparsed.manifest_id() != m.manifest_id() {
935                std::process::abort();
936            }
937        }
938    }
939}