Skip to main content

aion_context/
aibom.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! AI Bill of Materials (AIBOM) — RFC-0029.
3//!
4//! [`AiBom`] captures the ingredients of a trained model: the
5//! artifact reference, frameworks, datasets, licenses,
6//! hyperparameters, safety attestations, export controls, and
7//! external references (model card, paper, changelog). Serializes
8//! to byte-stable JSON and rides over DSSE (RFC-0023) as
9//! `application/vnd.aion.aibom.v1+json`.
10//!
11//! Phase A: aion-native schema. Phase B: bi-directional
12//! translation to/from SPDX 3.0 AI profile and `CycloneDX` 1.6 ML.
13//!
14//! # Example
15//!
16//! ```
17//! use aion_context::aibom::{AiBom, ModelRef, FrameworkRef};
18//! use aion_context::crypto::SigningKey;
19//! use aion_context::types::AuthorId;
20//!
21//! let model = ModelRef {
22//!     name: "acme-7b-chat".into(),
23//!     version: "0.3.1".into(),
24//!     hash_algorithm: "BLAKE3-256".into(),
25//!     hash: [0xAB; 32],
26//!     size: 1_000,
27//!     format: "safetensors".into(),
28//! };
29//! let mut b = AiBom::builder(model, 42);
30//! b.add_framework(FrameworkRef {
31//!     name: "pytorch".into(),
32//!     version: "2.3.1".into(),
33//!     cpe: None,
34//! });
35//! let aibom = b.build();
36//!
37//! let signer = AuthorId::new(1001);
38//! let key = SigningKey::generate();
39//! let env = aion_context::aibom::wrap_aibom_dsse(&aibom, signer, &key).unwrap();
40//! let back = aion_context::aibom::unwrap_aibom_dsse(&env).unwrap();
41//! assert_eq!(back, aibom);
42//! ```
43
44use std::collections::BTreeMap;
45
46use serde::{Deserialize, Serialize};
47
48use crate::crypto::SigningKey;
49use crate::dsse::{self, DsseEnvelope};
50use crate::types::AuthorId;
51use crate::{AionError, Result};
52
53/// DSSE `payloadType` for aion AIBOM envelopes.
54pub const AIBOM_PAYLOAD_TYPE: &str = "application/vnd.aion.aibom.v1+json";
55
56/// Value carried in `schema_version` on every emitted AIBOM.
57pub const AIBOM_SCHEMA_VERSION: &str = "aion.aibom.v1";
58
59/// Reference to the model artifact the AIBOM describes.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct ModelRef {
62    /// Model name (site-local or upstream identifier).
63    pub name: String,
64    /// Model version string.
65    pub version: String,
66    /// Hash algorithm used for `hash`. Usually `"BLAKE3-256"`.
67    pub hash_algorithm: String,
68    /// 32-byte content hash, emitted as lowercase hex.
69    #[serde(with = "hex_bytes32")]
70    pub hash: [u8; 32],
71    /// Size of the artifact in bytes.
72    pub size: u64,
73    /// Serialization format — `safetensors`, `gguf`, `onnx`, …
74    pub format: String,
75}
76
77/// A framework required to run or train the model.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct FrameworkRef {
80    /// Framework name: `pytorch`, `tensorflow`, `jax`, …
81    pub name: String,
82    /// Framework version.
83    pub version: String,
84    /// Optional CPE 2.3 URI for CVE correlation.
85    pub cpe: Option<String>,
86}
87
88/// A dataset used in training, fine-tuning, or evaluation.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct DatasetRef {
91    /// Dataset name.
92    pub name: String,
93    /// Hash algorithm used for `hash`, if any.
94    pub hash_algorithm: Option<String>,
95    /// Optional 32-byte content hash.
96    #[serde(with = "hex_bytes32_opt")]
97    pub hash: Option<[u8; 32]>,
98    /// Optional size in bytes.
99    pub size: Option<u64>,
100    /// Optional URI — pointer to the dataset location.
101    pub uri: Option<String>,
102    /// Optional SPDX license identifier for the dataset.
103    pub license_spdx_id: Option<String>,
104}
105
106/// Scope a license applies to within the AIBOM.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108pub enum LicenseScope {
109    /// Just the trained weights.
110    Weights,
111    /// Source code only.
112    SourceCode,
113    /// Training data only.
114    TrainingData,
115    /// Documentation / model card.
116    Documentation,
117    /// The whole release (weights + code + data + docs).
118    Combined,
119}
120
121/// A license that applies to some part of the release.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct License {
124    /// SPDX license ID (`Apache-2.0`, `LLAMA3-COMMUNITY`, …).
125    pub spdx_id: String,
126    /// What the license covers.
127    pub scope: LicenseScope,
128    /// Optional URI to the full license text.
129    pub text_uri: Option<String>,
130}
131
132/// A safety / red-team / evaluation attestation.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct SafetyAttestation {
135    /// Attestation name or identifier.
136    pub name: String,
137    /// Result string — site-specific. `"PASS"` / `"REVIEW"` etc.
138    pub result: String,
139    /// Optional hash algorithm for `report_hash`.
140    pub report_hash_algorithm: Option<String>,
141    /// Optional 32-byte content hash of the attestation report.
142    #[serde(with = "hex_bytes32_opt")]
143    pub report_hash: Option<[u8; 32]>,
144    /// Optional URI for the report.
145    pub report_uri: Option<String>,
146}
147
148/// An export-control classification under some regime.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct ExportControl {
151    /// Regime name — `"US-ECCN"`, `"EU-dual-use"`, …
152    pub regime: String,
153    /// Classification within the regime — `"EAR99"`,
154    /// `"5D002.c.1"`, …
155    pub classification: String,
156    /// Optional human-readable notes.
157    pub notes: Option<String>,
158}
159
160/// An external reference (model card, paper, changelog, …).
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct ExternalReference {
163    /// Kind: `"model_card"`, `"paper"`, `"changelog"`, …
164    pub kind: String,
165    /// URI.
166    pub uri: String,
167}
168
169/// The full AIBOM record.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct AiBom {
172    /// Schema-version discriminator. Always
173    /// [`AIBOM_SCHEMA_VERSION`] for Phase A.
174    pub schema_version: String,
175    /// The model this AIBOM describes.
176    pub model: ModelRef,
177    /// Frameworks required at train/infer time.
178    pub frameworks: Vec<FrameworkRef>,
179    /// Datasets used in training/tuning.
180    pub datasets: Vec<DatasetRef>,
181    /// Licenses that apply to the release.
182    pub licenses: Vec<License>,
183    /// Hyperparameters — opaque JSON values keyed by string.
184    pub hyperparameters: BTreeMap<String, serde_json::Value>,
185    /// Safety / red-team attestations.
186    pub safety_attestations: Vec<SafetyAttestation>,
187    /// Export-control classifications.
188    pub export_controls: Vec<ExportControl>,
189    /// External references (model card, paper, …).
190    pub references: Vec<ExternalReference>,
191    /// aion version number at AIBOM creation time — orders
192    /// the AIBOM alongside other aion artifacts per
193    /// `.claude/rules/distributed.md`.
194    pub created_at_version: u64,
195}
196
197impl AiBom {
198    /// Start building a new AIBOM for `model` at
199    /// `created_at_version`.
200    #[must_use]
201    pub const fn builder(model: ModelRef, created_at_version: u64) -> AiBomBuilder {
202        AiBomBuilder {
203            model,
204            frameworks: Vec::new(),
205            datasets: Vec::new(),
206            licenses: Vec::new(),
207            hyperparameters: BTreeMap::new(),
208            safety_attestations: Vec::new(),
209            export_controls: Vec::new(),
210            references: Vec::new(),
211            created_at_version,
212        }
213    }
214
215    /// Serialize to pretty-printed JSON. For byte-stable output
216    /// use [`Self::canonical_bytes`].
217    ///
218    /// # Errors
219    ///
220    /// Propagates `serde_json` errors.
221    pub fn to_json(&self) -> Result<String> {
222        serde_json::to_string(self).map_err(|e| AionError::InvalidFormat {
223            reason: format!("AIBOM JSON serialize failed: {e}"),
224        })
225    }
226
227    /// Parse from JSON.
228    ///
229    /// # Errors
230    ///
231    /// Returns `Err` for malformed JSON or schema mismatch.
232    pub fn from_json(s: &str) -> Result<Self> {
233        serde_json::from_str(s).map_err(|e| AionError::InvalidFormat {
234            reason: format!("AIBOM JSON parse failed: {e}"),
235        })
236    }
237
238    /// Canonical serialized bytes — stable across runs because
239    /// all user-keyed maps use `BTreeMap` and struct fields are
240    /// emitted in declaration order.
241    ///
242    /// # Errors
243    ///
244    /// Propagates `serde_json` errors.
245    pub fn canonical_bytes(&self) -> Result<Vec<u8>> {
246        serde_json::to_vec(self).map_err(|e| AionError::InvalidFormat {
247            reason: format!("AIBOM canonical bytes failed: {e}"),
248        })
249    }
250
251    /// RFC 8785 (JCS) canonical bytes — use when cross-implementation
252    /// byte stability matters (Phase B of RFC-0031). Opt-in;
253    /// `canonical_bytes()` remains the signature-stable form for
254    /// historical DSSE envelopes.
255    ///
256    /// # Errors
257    ///
258    /// Propagates serialization errors from [`crate::jcs`].
259    pub fn to_jcs_bytes(&self) -> Result<Vec<u8>> {
260        crate::jcs::to_jcs_bytes(self)
261    }
262}
263
264/// Fluent builder for an [`AiBom`].
265#[derive(Debug)]
266pub struct AiBomBuilder {
267    model: ModelRef,
268    frameworks: Vec<FrameworkRef>,
269    datasets: Vec<DatasetRef>,
270    licenses: Vec<License>,
271    hyperparameters: BTreeMap<String, serde_json::Value>,
272    safety_attestations: Vec<SafetyAttestation>,
273    export_controls: Vec<ExportControl>,
274    references: Vec<ExternalReference>,
275    created_at_version: u64,
276}
277
278impl AiBomBuilder {
279    /// Append a framework reference.
280    pub fn add_framework(&mut self, f: FrameworkRef) -> &mut Self {
281        self.frameworks.push(f);
282        self
283    }
284
285    /// Append a dataset reference.
286    pub fn add_dataset(&mut self, d: DatasetRef) -> &mut Self {
287        self.datasets.push(d);
288        self
289    }
290
291    /// Append a license.
292    pub fn add_license(&mut self, l: License) -> &mut Self {
293        self.licenses.push(l);
294        self
295    }
296
297    /// Set a hyperparameter; overwrites any prior value for the
298    /// same key.
299    pub fn hyperparameter(&mut self, k: impl Into<String>, v: serde_json::Value) -> &mut Self {
300        self.hyperparameters.insert(k.into(), v);
301        self
302    }
303
304    /// Append a safety attestation.
305    pub fn add_safety_attestation(&mut self, s: SafetyAttestation) -> &mut Self {
306        self.safety_attestations.push(s);
307        self
308    }
309
310    /// Append an export-control entry.
311    pub fn add_export_control(&mut self, e: ExportControl) -> &mut Self {
312        self.export_controls.push(e);
313        self
314    }
315
316    /// Append an external reference.
317    pub fn add_reference(&mut self, r: ExternalReference) -> &mut Self {
318        self.references.push(r);
319        self
320    }
321
322    /// Finalize.
323    #[must_use]
324    pub fn build(self) -> AiBom {
325        AiBom {
326            schema_version: AIBOM_SCHEMA_VERSION.to_string(),
327            model: self.model,
328            frameworks: self.frameworks,
329            datasets: self.datasets,
330            licenses: self.licenses,
331            hyperparameters: self.hyperparameters,
332            safety_attestations: self.safety_attestations,
333            export_controls: self.export_controls,
334            references: self.references,
335            created_at_version: self.created_at_version,
336        }
337    }
338}
339
340/// Wrap `aibom` in a DSSE envelope signed by `signer`.
341///
342/// # Errors
343///
344/// Propagates canonical-bytes serialization errors.
345pub fn wrap_aibom_dsse(aibom: &AiBom, signer: AuthorId, key: &SigningKey) -> Result<DsseEnvelope> {
346    let payload = aibom.canonical_bytes()?;
347    Ok(dsse::sign_envelope(
348        &payload,
349        AIBOM_PAYLOAD_TYPE,
350        signer,
351        key,
352    ))
353}
354
355/// Pull an [`AiBom`] out of a DSSE envelope. The caller is
356/// responsible for verifying the envelope's signature(s) via
357/// [`crate::dsse::verify_envelope`] before trusting the result.
358///
359/// # Errors
360///
361/// Returns `Err` if the envelope's `payloadType` is not
362/// [`AIBOM_PAYLOAD_TYPE`] or if the payload fails to parse.
363pub fn unwrap_aibom_dsse(envelope: &DsseEnvelope) -> Result<AiBom> {
364    if envelope.payload_type != AIBOM_PAYLOAD_TYPE {
365        return Err(AionError::InvalidFormat {
366            reason: format!(
367                "envelope payloadType is '{}', expected '{}'",
368                envelope.payload_type, AIBOM_PAYLOAD_TYPE
369            ),
370        });
371    }
372    let payload_str =
373        std::str::from_utf8(&envelope.payload).map_err(|e| AionError::InvalidFormat {
374            reason: format!("AIBOM DSSE payload is not valid UTF-8: {e}"),
375        })?;
376    AiBom::from_json(payload_str)
377}
378
379/// Serde adapter for 32-byte hashes → lowercase hex and back.
380mod hex_bytes32 {
381    use serde::{Deserialize, Deserializer, Serializer};
382
383    pub fn serialize<S: Serializer>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error> {
384        serializer.serialize_str(&hex::encode(bytes))
385    }
386
387    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<[u8; 32], D::Error> {
388        let s = String::deserialize(deserializer)?;
389        let v = hex::decode(&s).map_err(serde::de::Error::custom)?;
390        if v.len() != 32 {
391            return Err(serde::de::Error::custom(format!(
392                "hash hex length is {} (expected 64 chars = 32 bytes)",
393                v.len()
394            )));
395        }
396        let mut out = [0u8; 32];
397        out.copy_from_slice(&v);
398        Ok(out)
399    }
400}
401
402/// Serde adapter for `Option<[u8; 32]>` → hex / null.
403mod hex_bytes32_opt {
404    use serde::{Deserialize, Deserializer, Serializer};
405
406    pub fn serialize<S: Serializer>(
407        bytes: &Option<[u8; 32]>,
408        serializer: S,
409    ) -> Result<S::Ok, S::Error> {
410        match bytes {
411            Some(b) => serializer.serialize_str(&hex::encode(b)),
412            None => serializer.serialize_none(),
413        }
414    }
415
416    pub fn deserialize<'de, D: Deserializer<'de>>(
417        deserializer: D,
418    ) -> Result<Option<[u8; 32]>, D::Error> {
419        let maybe: Option<String> = Option::deserialize(deserializer)?;
420        match maybe {
421            None => Ok(None),
422            Some(s) => {
423                let v = hex::decode(&s).map_err(serde::de::Error::custom)?;
424                if v.len() != 32 {
425                    return Err(serde::de::Error::custom(format!(
426                        "hash hex length is {} (expected 64 chars = 32 bytes)",
427                        v.len()
428                    )));
429                }
430                let mut out = [0u8; 32];
431                out.copy_from_slice(&v);
432                Ok(Some(out))
433            }
434        }
435    }
436}
437
438#[cfg(test)]
439#[allow(clippy::unwrap_used)]
440mod tests {
441    use super::*;
442    use crate::dsse::verify_envelope;
443    use crate::key_registry::KeyRegistry;
444    use serde_json::json;
445
446    /// Pin `signer` with `key` as the active op pubkey at epoch 0.
447    fn reg_pinning(signer: AuthorId, key: &SigningKey) -> KeyRegistry {
448        let mut reg = KeyRegistry::new();
449        let master = SigningKey::generate();
450        reg.register_author(signer, master.verifying_key(), key.verifying_key(), 0)
451            .unwrap();
452        reg
453    }
454
455    fn sample_model() -> ModelRef {
456        ModelRef {
457            name: "acme-7b-chat".to_string(),
458            version: "0.3.1".to_string(),
459            hash_algorithm: "BLAKE3-256".to_string(),
460            hash: [0xABu8; 32],
461            size: 1_000,
462            format: "safetensors".to_string(),
463        }
464    }
465
466    fn sample_aibom() -> AiBom {
467        let mut b = AiBom::builder(sample_model(), 42);
468        b.add_framework(FrameworkRef {
469            name: "pytorch".to_string(),
470            version: "2.3.1".to_string(),
471            cpe: None,
472        });
473        b.add_dataset(DatasetRef {
474            name: "c4-en-v2".to_string(),
475            hash_algorithm: Some("BLAKE3-256".to_string()),
476            hash: Some([0xCDu8; 32]),
477            size: None,
478            uri: Some("s3://acme-datasets/c4-en-v2/".to_string()),
479            license_spdx_id: Some("ODC-By-1.0".to_string()),
480        });
481        b.add_license(License {
482            spdx_id: "Apache-2.0".to_string(),
483            scope: LicenseScope::Weights,
484            text_uri: None,
485        });
486        b.hyperparameter("context_length", json!(8192));
487        b.add_export_control(ExportControl {
488            regime: "US-ECCN".to_string(),
489            classification: "EAR99".to_string(),
490            notes: None,
491        });
492        b.build()
493    }
494
495    #[test]
496    fn builds_with_schema_version() {
497        let aibom = sample_aibom();
498        assert_eq!(aibom.schema_version, AIBOM_SCHEMA_VERSION);
499    }
500
501    #[test]
502    fn json_round_trip_preserves_fields() {
503        let aibom = sample_aibom();
504        let json = aibom.to_json().unwrap();
505        let parsed = AiBom::from_json(&json).unwrap();
506        assert_eq!(parsed, aibom);
507    }
508
509    #[test]
510    fn canonical_bytes_are_deterministic() {
511        let aibom = sample_aibom();
512        let a = aibom.canonical_bytes().unwrap();
513        let b = aibom.canonical_bytes().unwrap();
514        assert_eq!(a, b);
515    }
516
517    #[test]
518    fn dsse_wrap_and_verify_round_trip() {
519        let aibom = sample_aibom();
520        let signer = AuthorId::new(1001);
521        let key = SigningKey::generate();
522        let env = wrap_aibom_dsse(&aibom, signer, &key).unwrap();
523        assert_eq!(env.payload_type, AIBOM_PAYLOAD_TYPE);
524        let reg = reg_pinning(signer, &key);
525        let verified = verify_envelope(&env, &reg, 1).unwrap();
526        assert_eq!(verified.len(), 1);
527        let back = unwrap_aibom_dsse(&env).unwrap();
528        assert_eq!(back, aibom);
529    }
530
531    #[test]
532    fn unwrap_rejects_wrong_payload_type() {
533        let key = SigningKey::generate();
534        let env = dsse::sign_envelope(b"not aibom", "text/plain", AuthorId::new(1), &key);
535        assert!(unwrap_aibom_dsse(&env).is_err());
536    }
537
538    #[test]
539    fn hash_field_survives_hex_round_trip() {
540        let aibom = sample_aibom();
541        let json = aibom.to_json().unwrap();
542        let parsed = AiBom::from_json(&json).unwrap();
543        assert_eq!(parsed.model.hash, [0xABu8; 32]);
544    }
545
546    mod properties {
547        use super::*;
548        use hegel::generators as gs;
549
550        fn draw_model(tc: &hegel::TestCase) -> ModelRef {
551            let hash_v = tc.draw(gs::binary().min_size(32).max_size(32));
552            let mut hash = [0u8; 32];
553            hash.copy_from_slice(&hash_v);
554            ModelRef {
555                name: tc.draw(gs::text().max_size(32)),
556                version: tc.draw(gs::text().max_size(16)),
557                hash_algorithm: "BLAKE3-256".to_string(),
558                hash,
559                size: tc.draw(gs::integers::<u64>()),
560                format: tc.draw(gs::text().max_size(16)),
561            }
562        }
563
564        fn draw_aibom(tc: &hegel::TestCase) -> AiBom {
565            let mut b = AiBom::builder(draw_model(tc), tc.draw(gs::integers::<u64>()));
566            let n_frameworks = tc.draw(gs::integers::<usize>().max_value(3));
567            for _ in 0..n_frameworks {
568                b.add_framework(FrameworkRef {
569                    name: tc.draw(gs::text().max_size(16)),
570                    version: tc.draw(gs::text().max_size(8)),
571                    cpe: None,
572                });
573            }
574            let n_datasets = tc.draw(gs::integers::<usize>().max_value(3));
575            for _ in 0..n_datasets {
576                b.add_dataset(DatasetRef {
577                    name: tc.draw(gs::text().max_size(16)),
578                    hash_algorithm: None,
579                    hash: None,
580                    size: None,
581                    uri: None,
582                    license_spdx_id: None,
583                });
584            }
585            b.build()
586        }
587
588        #[hegel::test]
589        fn prop_aibom_json_roundtrip(tc: hegel::TestCase) {
590            let aibom = draw_aibom(&tc);
591            let json = aibom.to_json().unwrap_or_else(|_| std::process::abort());
592            let parsed = AiBom::from_json(&json).unwrap_or_else(|_| std::process::abort());
593            assert_eq!(parsed, aibom);
594        }
595
596        #[hegel::test]
597        fn prop_aibom_canonical_bytes_deterministic(tc: hegel::TestCase) {
598            let aibom = draw_aibom(&tc);
599            let a = aibom
600                .canonical_bytes()
601                .unwrap_or_else(|_| std::process::abort());
602            let b = aibom
603                .canonical_bytes()
604                .unwrap_or_else(|_| std::process::abort());
605            assert_eq!(a, b);
606        }
607
608        #[hegel::test]
609        fn prop_aibom_model_hash_survives_json(tc: hegel::TestCase) {
610            let aibom = draw_aibom(&tc);
611            let expected = aibom.model.hash;
612            let json = aibom.to_json().unwrap_or_else(|_| std::process::abort());
613            let parsed = AiBom::from_json(&json).unwrap_or_else(|_| std::process::abort());
614            assert_eq!(parsed.model.hash, expected);
615        }
616
617        #[hegel::test]
618        fn prop_aibom_dsse_roundtrip(tc: hegel::TestCase) {
619            let aibom = draw_aibom(&tc);
620            let signer = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
621            let key = SigningKey::generate();
622            let env =
623                wrap_aibom_dsse(&aibom, signer, &key).unwrap_or_else(|_| std::process::abort());
624            let reg = reg_pinning(signer, &key);
625            let verified = verify_envelope(&env, &reg, 1).unwrap_or_else(|_| std::process::abort());
626            assert_eq!(verified.len(), 1);
627            let back = unwrap_aibom_dsse(&env).unwrap_or_else(|_| std::process::abort());
628            assert_eq!(back, aibom);
629        }
630
631        #[hegel::test]
632        fn prop_aibom_tampered_json_rejects(tc: hegel::TestCase) {
633            let aibom = draw_aibom(&tc);
634            let signer = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
635            let key = SigningKey::generate();
636            let mut env =
637                wrap_aibom_dsse(&aibom, signer, &key).unwrap_or_else(|_| std::process::abort());
638            let max_idx = env.payload.len().saturating_sub(1);
639            let idx = tc.draw(gs::integers::<usize>().max_value(max_idx));
640            if let Some(b) = env.payload.get_mut(idx) {
641                *b ^= 0x01;
642            }
643            let reg = reg_pinning(signer, &key);
644            assert!(verify_envelope(&env, &reg, 1).is_err());
645        }
646
647        #[hegel::test]
648        fn prop_aibom_multi_signer_envelope(tc: hegel::TestCase) {
649            let aibom = draw_aibom(&tc);
650            let s1 = (
651                AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 20))),
652                SigningKey::generate(),
653            );
654            let s2 = (
655                AuthorId::new(s1.0.as_u64().saturating_add(1)),
656                SigningKey::generate(),
657            );
658            let mut env =
659                wrap_aibom_dsse(&aibom, s1.0, &s1.1).unwrap_or_else(|_| std::process::abort());
660            dsse::add_signature(&mut env, s2.0, &s2.1);
661            let mut reg = KeyRegistry::new();
662            for (signer, key) in [(s1.0, &s1.1), (s2.0, &s2.1)] {
663                let master = SigningKey::generate();
664                reg.register_author(signer, master.verifying_key(), key.verifying_key(), 0)
665                    .unwrap_or_else(|_| std::process::abort());
666            }
667            let verified = verify_envelope(&env, &reg, 1).unwrap_or_else(|_| std::process::abort());
668            assert_eq!(verified.len(), 2);
669        }
670
671        #[hegel::test]
672        fn prop_aibom_payload_type_is_aion_aibom(tc: hegel::TestCase) {
673            let aibom = draw_aibom(&tc);
674            let signer = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
675            let key = SigningKey::generate();
676            let env =
677                wrap_aibom_dsse(&aibom, signer, &key).unwrap_or_else(|_| std::process::abort());
678            assert_eq!(env.payload_type, AIBOM_PAYLOAD_TYPE);
679        }
680
681        #[hegel::test]
682        fn prop_aibom_to_jcs_bytes_matches_helper(tc: hegel::TestCase) {
683            let aibom = draw_aibom(&tc);
684            let from_method = aibom
685                .to_jcs_bytes()
686                .unwrap_or_else(|_| std::process::abort());
687            let from_helper =
688                crate::jcs::to_jcs_bytes(&aibom).unwrap_or_else(|_| std::process::abort());
689            assert_eq!(from_method, from_helper);
690        }
691    }
692}