Skip to main content

aion_context/
release.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Release orchestration — RFC-0032.
3//!
4//! One call site that composes the Phase-A primitives into a
5//! complete signed model release: manifest (RFC-0022), AIBOM
6//! (RFC-0029), SLSA v1.1 statement (RFC-0024), three DSSE
7//! envelopes (RFC-0023), three transparency-log entries
8//! (RFC-0025), an OCI primary manifest, and two OCI attestation
9//! referrers (RFC-0030).
10//!
11//! Nothing here is a new primitive. Every byte the builder emits
12//! is produced by code that already has Hegel property tests in
13//! its home module. What this module asserts is the **integration**
14//! contract: if `ReleaseBuilder::seal` returned `Ok`, then
15//! `SignedRelease::verify` with the matching key is `Ok`; any
16//! tampering of any component breaks `verify`.
17//!
18//! # Example
19//!
20//! ```
21//! use aion_context::aibom::{FrameworkRef, License, LicenseScope};
22//! use aion_context::crypto::SigningKey;
23//! use aion_context::key_registry::KeyRegistry;
24//! use aion_context::release::ReleaseBuilder;
25//! use aion_context::transparency_log::TransparencyLog;
26//! use aion_context::types::AuthorId;
27//!
28//! let mut log = TransparencyLog::new();
29//! let signer = AuthorId::new(50_001);
30//! let master = SigningKey::generate();
31//! let key = SigningKey::generate();
32//! let mut registry = KeyRegistry::new();
33//! registry
34//!     .register_author(signer, master.verifying_key(), key.verifying_key(), 0)
35//!     .unwrap();
36//!
37//! let mut b = ReleaseBuilder::new("acme-7b-chat", "0.3.1", "safetensors");
38//! b.primary_artifact("model.safetensors", vec![0xAA; 128])
39//!     .add_framework(FrameworkRef {
40//!         name: "pytorch".into(),
41//!         version: "2.3.1".into(),
42//!         cpe: None,
43//!     })
44//!     .add_license(License {
45//!         spdx_id: "Apache-2.0".into(),
46//!         scope: LicenseScope::Weights,
47//!         text_uri: None,
48//!     })
49//!     .builder_id("https://example.com/ci/run/1")
50//!     .current_aion_version(1);
51//! let signed = b.seal(signer, &key, &mut log).unwrap();
52//! signed.verify(&registry, 1).unwrap();
53//! ```
54
55use std::collections::BTreeMap;
56
57use crate::aibom::{
58    AiBom, DatasetRef, ExportControl, ExternalReference, FrameworkRef, License, ModelRef,
59    SafetyAttestation,
60};
61use crate::crypto::SigningKey;
62use crate::dsse::{self, DsseEnvelope, AION_MANIFEST_TYPE};
63use crate::key_registry::KeyRegistry;
64use crate::manifest::{
65    sign_manifest, verify_manifest_signature, ArtifactEntry, ArtifactManifest,
66    ArtifactManifestBuilder,
67};
68use crate::oci::{
69    build_aion_manifest, build_attestation_manifest, AionConfig, OciArtifactManifest,
70    AION_CONFIG_MEDIA_TYPE,
71};
72use crate::serializer::SignatureEntry;
73use crate::slsa::{
74    wrap_statement_dsse, InTotoStatement, SlsaStatementBuilder, IN_TOTO_PAYLOAD_TYPE,
75};
76use crate::transparency_log::{LogEntryKind, TransparencyLog};
77use crate::types::AuthorId;
78use crate::{AionError, Result};
79
80/// Transparency-log position returned from [`TransparencyLog::append`].
81///
82/// `#[non_exhaustive]` because future phases may attach inclusion
83/// proofs, operator STHs, or Rekor log indices per log entry.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[non_exhaustive]
86pub struct LogSeq {
87    /// Which kind of record was logged.
88    pub kind: LogEntryKind,
89    /// 0-indexed position in the log.
90    pub seq: u64,
91}
92
93/// Builder that collects everything needed for a signed release.
94#[derive(Debug)]
95pub struct ReleaseBuilder {
96    model_name: String,
97    model_version: String,
98    model_format: String,
99    primary_artifact: Option<(String, Vec<u8>)>,
100    auxiliary_artifacts: Vec<(String, Vec<u8>)>,
101    frameworks: Vec<FrameworkRef>,
102    datasets: Vec<DatasetRef>,
103    licenses: Vec<License>,
104    hyperparameters: BTreeMap<String, serde_json::Value>,
105    safety_attestations: Vec<SafetyAttestation>,
106    export_controls: Vec<ExportControl>,
107    references: Vec<ExternalReference>,
108    builder_id: String,
109    external_parameters: serde_json::Value,
110    current_aion_version: u64,
111}
112
113impl ReleaseBuilder {
114    /// Start a new builder for `(name, version, format)`.
115    #[must_use]
116    pub fn new(
117        name: impl Into<String>,
118        version: impl Into<String>,
119        format: impl Into<String>,
120    ) -> Self {
121        Self {
122            model_name: name.into(),
123            model_version: version.into(),
124            model_format: format.into(),
125            primary_artifact: None,
126            auxiliary_artifacts: Vec::new(),
127            frameworks: Vec::new(),
128            datasets: Vec::new(),
129            licenses: Vec::new(),
130            hyperparameters: BTreeMap::new(),
131            safety_attestations: Vec::new(),
132            export_controls: Vec::new(),
133            references: Vec::new(),
134            builder_id: String::new(),
135            external_parameters: serde_json::json!({}),
136            current_aion_version: 0,
137        }
138    }
139
140    /// Register the primary artifact (the model weights).
141    pub fn primary_artifact(&mut self, name: impl Into<String>, bytes: Vec<u8>) -> &mut Self {
142        self.primary_artifact = Some((name.into(), bytes));
143        self
144    }
145
146    /// Register an auxiliary artifact (tokenizer, config, sidecar).
147    pub fn add_auxiliary(&mut self, name: impl Into<String>, bytes: Vec<u8>) -> &mut Self {
148        self.auxiliary_artifacts.push((name.into(), bytes));
149        self
150    }
151
152    /// AIBOM framework entry.
153    pub fn add_framework(&mut self, f: FrameworkRef) -> &mut Self {
154        self.frameworks.push(f);
155        self
156    }
157
158    /// AIBOM dataset entry.
159    pub fn add_dataset(&mut self, d: DatasetRef) -> &mut Self {
160        self.datasets.push(d);
161        self
162    }
163
164    /// AIBOM license entry.
165    pub fn add_license(&mut self, l: License) -> &mut Self {
166        self.licenses.push(l);
167        self
168    }
169
170    /// AIBOM hyperparameter (arbitrary JSON value).
171    pub fn hyperparameter(&mut self, k: impl Into<String>, v: serde_json::Value) -> &mut Self {
172        self.hyperparameters.insert(k.into(), v);
173        self
174    }
175
176    /// AIBOM safety attestation entry.
177    pub fn add_safety_attestation(&mut self, s: SafetyAttestation) -> &mut Self {
178        self.safety_attestations.push(s);
179        self
180    }
181
182    /// AIBOM export-control entry.
183    pub fn add_export_control(&mut self, e: ExportControl) -> &mut Self {
184        self.export_controls.push(e);
185        self
186    }
187
188    /// AIBOM external reference (model card, paper).
189    pub fn add_reference(&mut self, r: ExternalReference) -> &mut Self {
190        self.references.push(r);
191        self
192    }
193
194    /// SLSA `builder.id` — the CI / build-system URI. Required.
195    pub fn builder_id(&mut self, id: impl Into<String>) -> &mut Self {
196        self.builder_id = id.into();
197        self
198    }
199
200    /// SLSA `externalParameters` blob.
201    pub fn external_parameters(&mut self, v: serde_json::Value) -> &mut Self {
202        self.external_parameters = v;
203        self
204    }
205
206    /// aion version number at seal time.
207    pub fn current_aion_version(&mut self, v: u64) -> &mut Self {
208        self.current_aion_version = v;
209        self
210    }
211
212    /// Seal the release: produce every signed artifact, append the
213    /// three log entries, and build the OCI manifest graph.
214    ///
215    /// # Errors
216    ///
217    /// Returns `Err` if the primary artifact is missing, the
218    /// `builder_id` is empty, or any downstream module rejects the
219    /// inputs (e.g. SLSA's non-empty-subject requirement).
220    pub fn seal(
221        self,
222        signer: AuthorId,
223        signing_key: &SigningKey,
224        log: &mut TransparencyLog,
225    ) -> Result<SignedRelease> {
226        let core = self.build_core()?;
227        let manifest_signature = sign_manifest(&core.manifest, signer, signing_key);
228        let manifest_dsse = dsse::wrap_manifest(&core.manifest, signer, signing_key);
229        let aibom_dsse = crate::aibom::wrap_aibom_dsse(&core.aibom, signer, signing_key)?;
230        let slsa_dsse = wrap_statement_dsse(&core.slsa_statement, signer, signing_key)?;
231        let log_entries = append_release_log_entries(
232            log,
233            &manifest_signature.signature,
234            &aibom_dsse,
235            &slsa_dsse,
236            core.current_aion_version,
237        )?;
238        let (oci_primary, oci_aibom_referrer, oci_slsa_referrer) = build_oci_graph(
239            &core.manifest,
240            &core.model_ref.name,
241            core.current_aion_version,
242            &aibom_dsse,
243            &slsa_dsse,
244        )?;
245        Ok(SignedRelease {
246            signer,
247            model_ref: core.model_ref,
248            manifest: core.manifest,
249            manifest_signature,
250            manifest_dsse,
251            aibom: core.aibom,
252            aibom_dsse,
253            slsa_statement: core.slsa_statement,
254            slsa_dsse,
255            oci_primary,
256            oci_aibom_referrer,
257            oci_slsa_referrer,
258            log_entries,
259        })
260    }
261
262    /// Validate preconditions and assemble the unsigned core
263    /// artifacts (manifest, `model_ref`, AIBOM, SLSA statement).
264    /// Called once by [`Self::seal`]; consumes the builder.
265    fn build_core(self) -> Result<SealedCore> {
266        let Self {
267            model_name,
268            model_version,
269            model_format,
270            primary_artifact,
271            auxiliary_artifacts,
272            frameworks,
273            datasets,
274            licenses,
275            hyperparameters,
276            safety_attestations,
277            export_controls,
278            references,
279            builder_id,
280            external_parameters,
281            current_aion_version,
282        } = self;
283        let (primary_name, primary_bytes) =
284            primary_artifact.ok_or_else(|| AionError::InvalidFormat {
285                reason: "ReleaseBuilder requires a primary_artifact".to_string(),
286            })?;
287        if builder_id.is_empty() {
288            return Err(AionError::InvalidFormat {
289                reason: "ReleaseBuilder requires a non-empty builder_id".to_string(),
290            });
291        }
292        let manifest =
293            construct_artifact_manifest(&primary_name, &primary_bytes, &auxiliary_artifacts);
294        let model_ref =
295            model_ref_from_manifest(&manifest, model_name, model_version, model_format)?;
296        let aibom = assemble_aibom(
297            model_ref.clone(),
298            current_aion_version,
299            AibomFields {
300                frameworks,
301                datasets,
302                licenses,
303                hyperparameters,
304                safety_attestations,
305                export_controls,
306                references,
307            },
308        );
309        let slsa_statement = assemble_slsa_statement(&manifest, builder_id, external_parameters)?;
310        Ok(SealedCore {
311            manifest,
312            model_ref,
313            aibom,
314            slsa_statement,
315            current_aion_version,
316        })
317    }
318}
319
320/// Unsigned core artifacts produced by [`ReleaseBuilder::build_core`].
321/// Not part of the public API.
322struct SealedCore {
323    manifest: ArtifactManifest,
324    model_ref: ModelRef,
325    aibom: AiBom,
326    slsa_statement: InTotoStatement,
327    current_aion_version: u64,
328}
329
330/// Bundle of AIBOM-shaped fields transported from `ReleaseBuilder`
331/// into [`assemble_aibom`]. Keeps the helper's argument list
332/// readable without forcing more parameter structs into the
333/// public API.
334struct AibomFields {
335    frameworks: Vec<FrameworkRef>,
336    datasets: Vec<DatasetRef>,
337    licenses: Vec<License>,
338    hyperparameters: BTreeMap<String, serde_json::Value>,
339    safety_attestations: Vec<SafetyAttestation>,
340    export_controls: Vec<ExportControl>,
341    references: Vec<ExternalReference>,
342}
343
344/// Step 2: build the artifact manifest — primary first, then
345/// auxiliaries in insertion order.
346fn construct_artifact_manifest(
347    primary_name: &str,
348    primary_bytes: &[u8],
349    auxiliaries: &[(String, Vec<u8>)],
350) -> ArtifactManifest {
351    let mut mb = ArtifactManifestBuilder::new();
352    let _ = mb.add(primary_name, primary_bytes);
353    for (name, bytes) in auxiliaries {
354        let _ = mb.add(name, bytes);
355    }
356    mb.build()
357}
358
359/// Step 3: derive a [`ModelRef`] from the manifest's primary entry.
360fn model_ref_from_manifest(
361    manifest: &ArtifactManifest,
362    name: String,
363    version: String,
364    format: String,
365) -> Result<ModelRef> {
366    let primary = manifest
367        .entries()
368        .first()
369        .ok_or_else(|| AionError::InvalidFormat {
370            reason: "manifest is unexpectedly empty".to_string(),
371        })?;
372    Ok(ModelRef {
373        name,
374        version,
375        hash_algorithm: "BLAKE3-256".to_string(),
376        hash: primary.hash,
377        size: primary.size,
378        format,
379    })
380}
381
382/// Step 4: assemble an [`AiBom`] from the collected builder fields.
383fn assemble_aibom(model_ref: ModelRef, current_aion_version: u64, fields: AibomFields) -> AiBom {
384    let mut ab = AiBom::builder(model_ref, current_aion_version);
385    for f in fields.frameworks {
386        ab.add_framework(f);
387    }
388    for d in fields.datasets {
389        ab.add_dataset(d);
390    }
391    for l in fields.licenses {
392        ab.add_license(l);
393    }
394    for (k, v) in fields.hyperparameters {
395        ab.hyperparameter(k, v);
396    }
397    for s in fields.safety_attestations {
398        ab.add_safety_attestation(s);
399    }
400    for e in fields.export_controls {
401        ab.add_export_control(e);
402    }
403    for r in fields.references {
404        ab.add_reference(r);
405    }
406    ab.build()
407}
408
409/// Step 5: assemble the SLSA v1.1 Statement.
410fn assemble_slsa_statement(
411    manifest: &ArtifactManifest,
412    builder_id: String,
413    external_parameters: serde_json::Value,
414) -> Result<InTotoStatement> {
415    let mut sb = SlsaStatementBuilder::new(builder_id);
416    sb.add_all_subjects_from_manifest(manifest)?;
417    sb.external_parameters(external_parameters);
418    sb.build()
419}
420
421/// Step 8: append the three release attestations to the
422/// transparency log in kind order.
423fn append_release_log_entries(
424    log: &mut TransparencyLog,
425    manifest_sig_bytes: &[u8; 64],
426    aibom_dsse: &DsseEnvelope,
427    slsa_dsse: &DsseEnvelope,
428    current_aion_version: u64,
429) -> Result<Vec<LogSeq>> {
430    let seq_manifest = log.append(
431        LogEntryKind::ManifestSignature,
432        manifest_sig_bytes,
433        current_aion_version,
434    )?;
435    let seq_aibom = log.append(
436        LogEntryKind::DsseEnvelope,
437        aibom_dsse.to_json()?.as_bytes(),
438        current_aion_version,
439    )?;
440    let seq_slsa = log.append(
441        LogEntryKind::SlsaStatement,
442        slsa_dsse.to_json()?.as_bytes(),
443        current_aion_version,
444    )?;
445    Ok(vec![
446        LogSeq {
447            kind: LogEntryKind::ManifestSignature,
448            seq: seq_manifest,
449        },
450        LogSeq {
451            kind: LogEntryKind::DsseEnvelope,
452            seq: seq_aibom,
453        },
454        LogSeq {
455            kind: LogEntryKind::SlsaStatement,
456            seq: seq_slsa,
457        },
458    ])
459}
460
461/// Steps 9–10: build the OCI primary manifest plus one referrer
462/// per DSSE envelope.
463///
464/// Phase-B stub: the primary's layer payload is the artifact
465/// manifest's canonical bytes. Phase C of RFC-0022 replaces this
466/// with the real `.aion` v3 on-disk bytes.
467fn build_oci_graph(
468    manifest: &ArtifactManifest,
469    model_name: &str,
470    current_aion_version: u64,
471    aibom_dsse: &DsseEnvelope,
472    slsa_dsse: &DsseEnvelope,
473) -> Result<(
474    OciArtifactManifest,
475    OciArtifactManifest,
476    OciArtifactManifest,
477)> {
478    // Touch the unused re-exports so `cargo clippy` doesn't flag them;
479    // both are kept in the `use` list for downstream consumers.
480    let _ = AION_MANIFEST_TYPE;
481    let _ = AION_CONFIG_MEDIA_TYPE;
482    let oci_config = AionConfig {
483        schema_version: "aion.oci.config.v1".to_string(),
484        format_version: 2,
485        file_id: 0,
486        created_at_version: current_aion_version,
487        created_at: "release-orchestration-phase-b".to_string(),
488    };
489    let oci_layer_payload = manifest.canonical_bytes();
490    let oci_primary = build_aion_manifest(&oci_layer_payload, model_name, &oci_config)?;
491    let oci_aibom_referrer = build_attestation_manifest(
492        aibom_dsse.to_json()?.as_bytes(),
493        crate::aibom::AIBOM_PAYLOAD_TYPE,
494        &oci_primary,
495    )?;
496    let oci_slsa_referrer = build_attestation_manifest(
497        slsa_dsse.to_json()?.as_bytes(),
498        IN_TOTO_PAYLOAD_TYPE,
499        &oci_primary,
500    )?;
501    Ok((oci_primary, oci_aibom_referrer, oci_slsa_referrer))
502}
503
504/// Everything produced by [`ReleaseBuilder::seal`].
505///
506/// `#[non_exhaustive]` so Phase C additions (countersignatures,
507/// hybrid-sig variants, inclusion proofs, Rekor bundle, Sigstore
508/// certificate chain) can land without breaking downstream
509/// pattern matches or struct-literal constructions.
510#[derive(Debug, Clone)]
511#[non_exhaustive]
512pub struct SignedRelease {
513    /// Author that sealed this release. `verify` uses this to
514    /// require every DSSE signature's `keyid` to match
515    /// [`crate::dsse::keyid_for`] of the same signer.
516    pub signer: AuthorId,
517    /// AIBOM-flavoured reference to the primary artifact.
518    pub model_ref: ModelRef,
519    /// Artifact manifest (primary + auxiliaries).
520    pub manifest: ArtifactManifest,
521    /// RFC-0022 signature over `manifest`.
522    pub manifest_signature: SignatureEntry,
523    /// DSSE envelope for the manifest signature.
524    pub manifest_dsse: DsseEnvelope,
525    /// AIBOM record.
526    pub aibom: AiBom,
527    /// DSSE envelope wrapping `aibom`.
528    pub aibom_dsse: DsseEnvelope,
529    /// in-toto Statement carrying the SLSA v1.1 provenance.
530    pub slsa_statement: InTotoStatement,
531    /// DSSE envelope wrapping `slsa_statement`.
532    pub slsa_dsse: DsseEnvelope,
533    /// OCI Image Manifest v1.1 for the primary artifact.
534    pub oci_primary: OciArtifactManifest,
535    /// OCI referrer manifest for `aibom_dsse`.
536    pub oci_aibom_referrer: OciArtifactManifest,
537    /// OCI referrer manifest for `slsa_dsse`.
538    pub oci_slsa_referrer: OciArtifactManifest,
539    /// Log positions for the three appended entries.
540    pub log_entries: Vec<LogSeq>,
541}
542
543/// Named-field input bag for [`SignedRelease::from_components`].
544///
545/// Replaces an earlier 13-positional-argument signature: 9 of those
546/// arguments were structurally-similar (3 × [`DsseEnvelope`], 3 ×
547/// [`OciArtifactManifest`]) and the compiler could not distinguish
548/// a transposition from intent. With this struct, every field is
549/// named at the call site and Rust catches missing or duplicated
550/// fields at compile time.
551///
552/// **Not** marked `#[non_exhaustive]` — the entire point of this
553/// struct is to be constructible via struct-literal from outside
554/// the crate. New fields added to [`SignedRelease`] must also land
555/// here, and external consumers must add them at every call site;
556/// that is the intended ergonomic.
557///
558/// Audit-pass WARN finding (2026-04-25); landed in the same
559/// 0.2.0 breaking window as the rest of the registry-aware
560/// rollout.
561#[derive(Debug, Clone)]
562pub struct SignedReleaseComponents {
563    /// Author that sealed the release.
564    pub signer: AuthorId,
565    /// AIBOM-flavoured reference to the primary artifact.
566    pub model_ref: ModelRef,
567    /// Artifact manifest (primary + auxiliaries).
568    pub manifest: ArtifactManifest,
569    /// RFC-0022 signature over `manifest`.
570    pub manifest_signature: SignatureEntry,
571    /// DSSE envelope wrapping the manifest signature.
572    pub manifest_dsse: DsseEnvelope,
573    /// AIBOM record.
574    pub aibom: AiBom,
575    /// DSSE envelope wrapping the AIBOM.
576    pub aibom_dsse: DsseEnvelope,
577    /// in-toto Statement carrying the SLSA v1.1 provenance.
578    pub slsa_statement: InTotoStatement,
579    /// DSSE envelope wrapping the SLSA statement.
580    pub slsa_dsse: DsseEnvelope,
581    /// OCI Image Manifest v1.1 for the primary artifact.
582    pub oci_primary: OciArtifactManifest,
583    /// OCI referrer manifest for the AIBOM DSSE envelope.
584    pub oci_aibom_referrer: OciArtifactManifest,
585    /// OCI referrer manifest for the SLSA DSSE envelope.
586    pub oci_slsa_referrer: OciArtifactManifest,
587    /// Transparency-log positions for the three appended entries,
588    /// in seal order: manifest signature, DSSE envelope, SLSA
589    /// statement.
590    pub log_entries: Vec<(LogEntryKind, u64)>,
591}
592
593impl SignedRelease {
594    /// Reconstruct a [`SignedRelease`] from its component parts
595    /// (issue #28). Complement to [`ReleaseBuilder::seal`] — the
596    /// cold-storage audit path: a verifier that loaded the
597    /// per-component artifacts from disk can reassemble the
598    /// aggregate and call [`Self::verify`].
599    ///
600    /// Takes a [`SignedReleaseComponents`] struct so every input is
601    /// named at the call site; the compiler catches missing or
602    /// duplicated fields at compile time. See the
603    /// `SignedReleaseComponents` doc for the rationale and the
604    /// audit-pass WARN that motivated the change.
605    #[must_use]
606    pub fn from_components(parts: SignedReleaseComponents) -> Self {
607        let log_seqs = parts
608            .log_entries
609            .into_iter()
610            .map(|(kind, seq)| LogSeq { kind, seq })
611            .collect();
612        Self {
613            signer: parts.signer,
614            model_ref: parts.model_ref,
615            manifest: parts.manifest,
616            manifest_signature: parts.manifest_signature,
617            manifest_dsse: parts.manifest_dsse,
618            aibom: parts.aibom,
619            aibom_dsse: parts.aibom_dsse,
620            slsa_statement: parts.slsa_statement,
621            slsa_dsse: parts.slsa_dsse,
622            oci_primary: parts.oci_primary,
623            oci_aibom_referrer: parts.oci_aibom_referrer,
624            oci_slsa_referrer: parts.oci_slsa_referrer,
625            log_entries: log_seqs,
626        }
627    }
628
629    /// Verify every component of the release against `verifying_key`.
630    ///
631    /// # Errors
632    ///
633    /// Returns `Err` if any signature fails to verify, any OCI
634    /// digest is inconsistent, or the AIBOM / SLSA linkages to the
635    /// manifest are broken. Resolves every signing key from
636    /// `registry` at `at_version`.
637    pub fn verify(&self, registry: &KeyRegistry, at_version: u64) -> Result<()> {
638        verify_manifest_signature(
639            &self.manifest,
640            &self.manifest_signature,
641            registry,
642            at_version,
643        )?;
644        let _ = dsse::verify_envelope(&self.manifest_dsse, registry, at_version)?;
645        let _ = dsse::verify_envelope(&self.aibom_dsse, registry, at_version)?;
646        let _ = dsse::verify_envelope(&self.slsa_dsse, registry, at_version)?;
647        self.verify_aibom_manifest_linkage()?;
648        verify_slsa_subjects_against_manifest(&self.slsa_statement, &self.manifest)?;
649        self.verify_oci_linkage()?;
650        self.verify_log_entry_kinds()?;
651        Ok(())
652    }
653
654    /// AIBOM model hash + size must match the primary manifest
655    /// entry (first entry by seal invariant).
656    fn verify_aibom_manifest_linkage(&self) -> Result<()> {
657        let primary = self
658            .manifest
659            .entries()
660            .first()
661            .ok_or_else(|| AionError::InvalidFormat {
662                reason: "manifest has no primary entry".to_string(),
663            })?;
664        if primary.hash != self.aibom.model.hash {
665            return Err(AionError::InvalidFormat {
666                reason: "AIBOM model hash does not match manifest primary entry".to_string(),
667            });
668        }
669        if primary.size != self.aibom.model.size {
670            return Err(AionError::InvalidFormat {
671                reason: "AIBOM model size does not match manifest primary entry".to_string(),
672            });
673        }
674        Ok(())
675    }
676
677    /// Both OCI referrers must point at the primary manifest's
678    /// SHA-256 digest via their `subject` descriptor.
679    fn verify_oci_linkage(&self) -> Result<()> {
680        let primary_digest = self.oci_primary.digest()?;
681        check_referrer_subject(&self.oci_aibom_referrer, &primary_digest, "AIBOM")?;
682        check_referrer_subject(&self.oci_slsa_referrer, &primary_digest, "SLSA")
683    }
684
685    /// Log entries are exactly three, in the kind order produced
686    /// by [`append_release_log_entries`].
687    fn verify_log_entry_kinds(&self) -> Result<()> {
688        let expected = [
689            LogEntryKind::ManifestSignature,
690            LogEntryKind::DsseEnvelope,
691            LogEntryKind::SlsaStatement,
692        ];
693        if self.log_entries.len() != expected.len() {
694            return Err(AionError::InvalidFormat {
695                reason: format!(
696                    "expected {} log entries, got {}",
697                    expected.len(),
698                    self.log_entries.len()
699                ),
700            });
701        }
702        for (entry, want) in self.log_entries.iter().zip(expected.iter()) {
703            if entry.kind != *want {
704                return Err(AionError::InvalidFormat {
705                    reason: format!(
706                        "log entry kind mismatch: got {:?}, expected {want:?}",
707                        entry.kind
708                    ),
709                });
710            }
711        }
712        Ok(())
713    }
714}
715
716/// Assert that `referrer`'s `subject.digest` equals
717/// `expected_digest`. `label` is injected into the error message
718/// so the caller can tell which referrer failed.
719fn check_referrer_subject(
720    referrer: &OciArtifactManifest,
721    expected_digest: &str,
722    label: &str,
723) -> Result<()> {
724    let subject = referrer
725        .subject
726        .as_ref()
727        .ok_or_else(|| AionError::InvalidFormat {
728            reason: format!("{label} OCI referrer missing subject"),
729        })?;
730    if subject.digest != expected_digest {
731        return Err(AionError::InvalidFormat {
732            reason: format!("{label} OCI referrer subject.digest != primary digest"),
733        });
734    }
735    Ok(())
736}
737
738/// Check that every `InTotoStatement` subject's BLAKE3-256 digest
739/// corresponds to some `ArtifactEntry` in the manifest.
740fn verify_slsa_subjects_against_manifest(
741    statement: &InTotoStatement,
742    manifest: &ArtifactManifest,
743) -> Result<()> {
744    for subject in &statement.subject {
745        let want = subject
746            .digest
747            .get("blake3-256")
748            .ok_or_else(|| AionError::InvalidFormat {
749                reason: format!("SLSA subject '{}' missing blake3-256 digest", subject.name),
750            })?;
751        let matched = manifest.entries().iter().any(|entry: &ArtifactEntry| {
752            let observed = hex::encode(entry.hash);
753            observed == *want
754        });
755        if !matched {
756            return Err(AionError::InvalidFormat {
757                reason: format!(
758                    "SLSA subject digest for '{}' not found in manifest",
759                    subject.name
760                ),
761            });
762        }
763    }
764    Ok(())
765}
766
767#[cfg(test)]
768#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
769mod tests {
770    use super::*;
771
772    /// Build a registry pinning `signer` with `key` as the active op at epoch 0.
773    fn reg_pinning(signer: AuthorId, key: &SigningKey) -> KeyRegistry {
774        let mut reg = KeyRegistry::new();
775        let master = SigningKey::generate();
776        reg.register_author(signer, master.verifying_key(), key.verifying_key(), 0)
777            .unwrap();
778        reg
779    }
780
781    fn sample_builder() -> ReleaseBuilder {
782        let mut b = ReleaseBuilder::new("acme-7b-chat", "0.3.1", "safetensors");
783        b.primary_artifact("model.safetensors", vec![0xAAu8; 256])
784            .add_auxiliary("tokenizer.json", b"{}".to_vec())
785            .add_framework(FrameworkRef {
786                name: "pytorch".into(),
787                version: "2.3.1".into(),
788                cpe: None,
789            })
790            .add_license(License {
791                spdx_id: "Apache-2.0".into(),
792                scope: crate::aibom::LicenseScope::Weights,
793                text_uri: None,
794            })
795            .builder_id("https://example.com/ci/run/1")
796            .current_aion_version(1);
797        b
798    }
799
800    #[test]
801    fn seal_requires_primary_artifact() {
802        let mut log = TransparencyLog::new();
803        let key = SigningKey::generate();
804        let mut b = ReleaseBuilder::new("m", "1", "safetensors");
805        b.builder_id("https://ci/1");
806        assert!(b.seal(AuthorId::new(1), &key, &mut log).is_err());
807    }
808
809    #[test]
810    fn seal_requires_builder_id() {
811        let mut log = TransparencyLog::new();
812        let key = SigningKey::generate();
813        let mut b = ReleaseBuilder::new("m", "1", "safetensors");
814        b.primary_artifact("x", vec![0u8; 32]);
815        assert!(b.seal(AuthorId::new(1), &key, &mut log).is_err());
816    }
817
818    #[test]
819    fn seal_and_verify_round_trip() {
820        let mut log = TransparencyLog::new();
821        let key = SigningKey::generate();
822        let signed = sample_builder()
823            .seal(AuthorId::new(50_001), &key, &mut log)
824            .unwrap();
825        signed
826            .verify(&reg_pinning(AuthorId::new(50_001), &key), 1)
827            .unwrap();
828    }
829
830    #[test]
831    fn log_has_three_entries_in_kind_order() {
832        let mut log = TransparencyLog::new();
833        let key = SigningKey::generate();
834        let signed = sample_builder()
835            .seal(AuthorId::new(50_001), &key, &mut log)
836            .unwrap();
837        assert_eq!(signed.log_entries.len(), 3);
838        assert_eq!(signed.log_entries[0].kind, LogEntryKind::ManifestSignature);
839        assert_eq!(signed.log_entries[1].kind, LogEntryKind::DsseEnvelope);
840        assert_eq!(signed.log_entries[2].kind, LogEntryKind::SlsaStatement);
841    }
842
843    #[test]
844    fn oci_referrers_link_to_primary() {
845        let mut log = TransparencyLog::new();
846        let key = SigningKey::generate();
847        let signed = sample_builder()
848            .seal(AuthorId::new(50_001), &key, &mut log)
849            .unwrap();
850        let primary_digest = signed.oci_primary.digest().unwrap();
851        assert_eq!(
852            signed.oci_aibom_referrer.subject.as_ref().unwrap().digest,
853            primary_digest
854        );
855        assert_eq!(
856            signed.oci_slsa_referrer.subject.as_ref().unwrap().digest,
857            primary_digest
858        );
859    }
860
861    #[test]
862    fn aibom_model_hash_equals_manifest_primary() {
863        let mut log = TransparencyLog::new();
864        let key = SigningKey::generate();
865        let signed = sample_builder()
866            .seal(AuthorId::new(50_001), &key, &mut log)
867            .unwrap();
868        assert_eq!(
869            signed.aibom.model.hash,
870            signed.manifest.entries().first().unwrap().hash
871        );
872    }
873
874    #[test]
875    fn tampered_aibom_envelope_rejects() {
876        let mut log = TransparencyLog::new();
877        let key = SigningKey::generate();
878        let mut signed = sample_builder()
879            .seal(AuthorId::new(50_001), &key, &mut log)
880            .unwrap();
881        signed.aibom_dsse.payload[0] ^= 0x01;
882        assert!(signed
883            .verify(&reg_pinning(AuthorId::new(50_001), &key), 1)
884            .is_err());
885    }
886
887    #[test]
888    fn wrong_key_rejects() {
889        let mut log = TransparencyLog::new();
890        let key = SigningKey::generate();
891        let other = SigningKey::generate();
892        let signed = sample_builder()
893            .seal(AuthorId::new(50_001), &key, &mut log)
894            .unwrap();
895        // Pin the WRONG key for the author — registry check rejects.
896        assert!(signed
897            .verify(&reg_pinning(AuthorId::new(50_001), &other), 1)
898            .is_err());
899    }
900
901    mod properties {
902        use super::*;
903        use hegel::generators as gs;
904
905        fn build_and_seal(
906            tc: &hegel::TestCase,
907            log: &mut TransparencyLog,
908            signer: AuthorId,
909            key: &SigningKey,
910        ) -> SignedRelease {
911            let primary_bytes = tc.draw(gs::binary().min_size(1).max_size(1024));
912            let n_aux = tc.draw(gs::integers::<usize>().max_value(3));
913            let mut b = ReleaseBuilder::new("model", "0.1.0", "safetensors");
914            b.primary_artifact("model.bin", primary_bytes)
915                .builder_id("https://ci/run/42")
916                .current_aion_version(tc.draw(gs::integers::<u64>().max_value(1 << 40)));
917            for i in 0..n_aux {
918                let bytes = tc.draw(gs::binary().max_size(256));
919                b.add_auxiliary(format!("aux_{i}"), bytes);
920            }
921            b.seal(signer, key, log)
922                .unwrap_or_else(|_| std::process::abort())
923        }
924
925        #[hegel::test]
926        fn prop_release_seal_verify_roundtrip(tc: hegel::TestCase) {
927            let mut log = TransparencyLog::new();
928            let key = SigningKey::generate();
929            let signer = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
930            let signed = build_and_seal(&tc, &mut log, signer, &key);
931            let reg = reg_pinning(signer, &key);
932            signed
933                .verify(&reg, 1)
934                .unwrap_or_else(|_| std::process::abort());
935        }
936
937        #[hegel::test]
938        fn prop_release_tampered_manifest_detected(tc: hegel::TestCase) {
939            let mut log = TransparencyLog::new();
940            let key = SigningKey::generate();
941            let mut signed = build_and_seal(
942                &tc,
943                &mut log,
944                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1))),
945                &key,
946            );
947            let idx = tc.draw(
948                gs::integers::<usize>()
949                    .max_value(signed.manifest_dsse.payload.len().saturating_sub(1)),
950            );
951            if let Some(b) = signed.manifest_dsse.payload.get_mut(idx) {
952                *b ^= 0x01;
953            }
954            assert!(signed
955                .verify(&reg_pinning(AuthorId::new(50_001), &key), 1)
956                .is_err());
957        }
958
959        #[hegel::test]
960        fn prop_release_oci_referrers_link_to_primary(tc: hegel::TestCase) {
961            let mut log = TransparencyLog::new();
962            let key = SigningKey::generate();
963            let signed = build_and_seal(
964                &tc,
965                &mut log,
966                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1))),
967                &key,
968            );
969            let primary_digest = signed
970                .oci_primary
971                .digest()
972                .unwrap_or_else(|_| std::process::abort());
973            let aibom_subject = signed
974                .oci_aibom_referrer
975                .subject
976                .as_ref()
977                .unwrap_or_else(|| std::process::abort());
978            let slsa_subject = signed
979                .oci_slsa_referrer
980                .subject
981                .as_ref()
982                .unwrap_or_else(|| std::process::abort());
983            assert_eq!(aibom_subject.digest, primary_digest);
984            assert_eq!(slsa_subject.digest, primary_digest);
985        }
986
987        #[hegel::test]
988        fn prop_release_aibom_model_ref_matches_manifest(tc: hegel::TestCase) {
989            let mut log = TransparencyLog::new();
990            let key = SigningKey::generate();
991            let signed = build_and_seal(
992                &tc,
993                &mut log,
994                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1))),
995                &key,
996            );
997            let primary = signed
998                .manifest
999                .entries()
1000                .first()
1001                .unwrap_or_else(|| std::process::abort());
1002            assert_eq!(signed.aibom.model.hash, primary.hash);
1003            assert_eq!(signed.aibom.model.size, primary.size);
1004        }
1005
1006        #[hegel::test]
1007        fn prop_release_log_has_expected_kinds(tc: hegel::TestCase) {
1008            let mut log = TransparencyLog::new();
1009            let key = SigningKey::generate();
1010            let signed = build_and_seal(
1011                &tc,
1012                &mut log,
1013                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1))),
1014                &key,
1015            );
1016            assert_eq!(signed.log_entries.len(), 3);
1017            assert_eq!(signed.log_entries[0].kind, LogEntryKind::ManifestSignature);
1018            assert_eq!(signed.log_entries[1].kind, LogEntryKind::DsseEnvelope);
1019            assert_eq!(signed.log_entries[2].kind, LogEntryKind::SlsaStatement);
1020        }
1021
1022        #[hegel::test]
1023        fn prop_release_registry_verify_accepts_pinned_release(tc: hegel::TestCase) {
1024            use crate::key_registry::KeyRegistry;
1025            let mut log = TransparencyLog::new();
1026            let master = SigningKey::generate();
1027            let op = SigningKey::generate();
1028            let signer =
1029                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 32)));
1030            let mut reg = KeyRegistry::new();
1031            reg.register_author(signer, master.verifying_key(), op.verifying_key(), 0)
1032                .unwrap_or_else(|_| std::process::abort());
1033            let signed = build_and_seal(&tc, &mut log, signer, &op);
1034            let at = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 20));
1035            signed
1036                .verify(&reg, at)
1037                .unwrap_or_else(|_| std::process::abort());
1038        }
1039
1040        #[hegel::test]
1041        fn prop_release_registry_verify_rejects_rotated_out_signer(tc: hegel::TestCase) {
1042            use crate::key_registry::{sign_rotation_record, KeyRegistry};
1043            let mut log = TransparencyLog::new();
1044            let master = SigningKey::generate();
1045            let op0 = SigningKey::generate();
1046            let op1 = SigningKey::generate();
1047            let signer =
1048                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 32)));
1049            let mut reg = KeyRegistry::new();
1050            reg.register_author(signer, master.verifying_key(), op0.verifying_key(), 0)
1051                .unwrap_or_else(|_| std::process::abort());
1052            let effective = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 20));
1053            let rotation = sign_rotation_record(
1054                signer,
1055                0,
1056                1,
1057                op1.verifying_key().to_bytes(),
1058                effective,
1059                &master,
1060            );
1061            reg.apply_rotation(&rotation)
1062                .unwrap_or_else(|_| std::process::abort());
1063            // Seal the release with the rotated-OUT op0 key.
1064            let signed = build_and_seal(&tc, &mut log, signer, &op0);
1065            let v_after = effective.saturating_add(1);
1066            assert!(signed.verify(&reg, v_after).is_err());
1067        }
1068    }
1069}