Skip to main content

aion_context/
slsa.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! SLSA v1.1 provenance emitter — RFC-0024.
3//!
4//! Builds an in-toto Statement carrying a SLSA v1.1 provenance
5//! predicate and wraps it in a DSSE envelope ([`crate::dsse`]) so
6//! it can be consumed by `slsa-verifier`, cosign, Kyverno, and
7//! every other DSSE-aware supply-chain tool.
8//!
9//! This module does **not** claim any specific SLSA Build level —
10//! that's an organizational claim the caller asserts via the
11//! `builder.id` and build-type URIs. We emit valid provenance; the
12//! level is declared by the consumer's policy.
13//!
14//! # Example
15//!
16//! ```
17//! use aion_context::manifest::ArtifactManifestBuilder;
18//! use aion_context::slsa::SlsaStatementBuilder;
19//! use aion_context::dsse::verify_envelope;
20//! use aion_context::crypto::SigningKey;
21//! use aion_context::key_registry::KeyRegistry;
22//! use aion_context::types::AuthorId;
23//! use serde_json::json;
24//!
25//! let mut m = ArtifactManifestBuilder::new();
26//! let _ = m.add("model.bin", &vec![0xAA; 32]);
27//! let manifest = m.build();
28//!
29//! let mut b = SlsaStatementBuilder::new("https://example.com/ci/run/1");
30//! b.add_all_subjects_from_manifest(&manifest).unwrap();
31//! b.external_parameters(json!({"source": "git@example.com/org/repo"}));
32//! let statement = b.build().unwrap();
33//!
34//! let signer = AuthorId::new(42);
35//! let master = SigningKey::generate();
36//! let key = SigningKey::generate();
37//! let mut registry = KeyRegistry::new();
38//! registry
39//!     .register_author(signer, master.verifying_key(), key.verifying_key(), 0)
40//!     .unwrap();
41//!
42//! let env = aion_context::slsa::wrap_statement_dsse(&statement, signer, &key).unwrap();
43//! let verified = verify_envelope(&env, &registry, 1).unwrap();
44//! assert_eq!(verified.len(), 1);
45//! ```
46
47use std::collections::BTreeMap;
48
49use serde::{Deserialize, Serialize};
50
51use crate::crypto::SigningKey;
52use crate::dsse::{self, DsseEnvelope};
53use crate::manifest::{ArtifactEntry, ArtifactManifest};
54use crate::types::AuthorId;
55use crate::{AionError, Result};
56
57/// `_type` for in-toto Statements v1.
58pub const IN_TOTO_STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1";
59
60/// `predicateType` for SLSA v1.1 provenance predicates.
61pub const SLSA_V1_PREDICATE_TYPE: &str = "https://slsa.dev/provenance/v1";
62
63/// DSSE `payloadType` for in-toto Statements (any version).
64pub const IN_TOTO_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json";
65
66/// Default build-type URI for generic aion-produced provenance.
67pub const AION_DEFAULT_BUILD_TYPE: &str = "https://aion-context.dev/buildtypes/generic/v1";
68
69/// Digest algorithm label used in subjects and resource descriptors.
70pub const BLAKE3_DIGEST_KEY: &str = "blake3-256";
71
72/// An in-toto Subject: an artifact identified by name + digest map.
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct Subject {
75    /// Artifact name (file-path-like).
76    pub name: String,
77    /// Map of digest algorithm → lowercase hex digest.
78    pub digest: BTreeMap<String, String>,
79}
80
81/// in-toto v1 `ResourceDescriptor` — used for resolvedDependencies,
82/// byproducts, and related lists. All fields optional per spec.
83#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
84pub struct ResourceDescriptor {
85    /// Optional name.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub name: Option<String>,
88    /// Optional URI.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub uri: Option<String>,
91    /// Optional digest map.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub digest: Option<BTreeMap<String, String>>,
94    /// Optional media type.
95    #[serde(rename = "mediaType", skip_serializing_if = "Option::is_none")]
96    pub media_type: Option<String>,
97}
98
99/// SLSA v1.1 Builder identity.
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct Builder {
102    /// URI identifying the builder (e.g. CI workflow URL).
103    pub id: String,
104}
105
106/// SLSA v1.1 `BuildDefinition` block.
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub struct BuildDefinition {
109    /// URI for the build type schema.
110    #[serde(rename = "buildType")]
111    pub build_type: String,
112    /// Caller-provided parameters the build was invoked with.
113    #[serde(rename = "externalParameters")]
114    pub external_parameters: serde_json::Value,
115    /// Internal parameters (private to the builder).
116    #[serde(
117        rename = "internalParameters",
118        default,
119        skip_serializing_if = "Option::is_none"
120    )]
121    pub internal_parameters: Option<serde_json::Value>,
122    /// Resolved dependencies (source, tools, containers, ...).
123    #[serde(rename = "resolvedDependencies", default)]
124    pub resolved_dependencies: Vec<ResourceDescriptor>,
125}
126
127/// SLSA v1.1 metadata about the build invocation.
128#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
129pub struct BuildMetadata {
130    /// Invocation identifier (CI run id, etc.).
131    #[serde(rename = "invocationId", skip_serializing_if = "Option::is_none")]
132    pub invocation_id: Option<String>,
133    /// RFC 3339 start timestamp.
134    #[serde(rename = "startedOn", skip_serializing_if = "Option::is_none")]
135    pub started_on: Option<String>,
136    /// RFC 3339 finish timestamp.
137    #[serde(rename = "finishedOn", skip_serializing_if = "Option::is_none")]
138    pub finished_on: Option<String>,
139}
140
141/// SLSA v1.1 `RunDetails` block.
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct RunDetails {
144    /// Identity of the builder that produced this provenance.
145    pub builder: Builder,
146    /// Optional invocation metadata.
147    #[serde(default, skip_serializing_if = "is_default_metadata")]
148    pub metadata: BuildMetadata,
149    /// Optional byproducts (logs, intermediate artifacts).
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub byproducts: Vec<ResourceDescriptor>,
152}
153
154const fn is_default_metadata(m: &BuildMetadata) -> bool {
155    m.invocation_id.is_none() && m.started_on.is_none() && m.finished_on.is_none()
156}
157
158/// SLSA v1.1 provenance predicate.
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
160pub struct SlsaProvenancePredicate {
161    /// The build definition.
162    #[serde(rename = "buildDefinition")]
163    pub build_definition: BuildDefinition,
164    /// Details about the invocation.
165    #[serde(rename = "runDetails")]
166    pub run_details: RunDetails,
167}
168
169/// in-toto v1 Statement wrapping a SLSA provenance predicate.
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct InTotoStatement {
172    /// Always [`IN_TOTO_STATEMENT_TYPE`].
173    #[serde(rename = "_type")]
174    pub type_uri: String,
175    /// The artifacts this statement attests to.
176    pub subject: Vec<Subject>,
177    /// Predicate type URI.
178    #[serde(rename = "predicateType")]
179    pub predicate_type: String,
180    /// The predicate body.
181    pub predicate: SlsaProvenancePredicate,
182}
183
184impl InTotoStatement {
185    /// Serialise to JSON.
186    ///
187    /// # Errors
188    ///
189    /// Propagates `serde_json` errors.
190    pub fn to_json(&self) -> Result<String> {
191        serde_json::to_string(self).map_err(|e| AionError::InvalidFormat {
192            reason: format!("in-toto Statement JSON serialization failed: {e}"),
193        })
194    }
195
196    /// Canonical bytes used for DSSE PAE.
197    ///
198    /// # Errors
199    ///
200    /// Propagates `serde_json` errors.
201    pub fn canonical_bytes(&self) -> Result<Vec<u8>> {
202        serde_json::to_vec(self).map_err(|e| AionError::InvalidFormat {
203            reason: format!("in-toto Statement canonical serialization failed: {e}"),
204        })
205    }
206
207    /// RFC 8785 (JCS) canonical bytes — use when cross-implementation
208    /// byte stability matters (Phase B of RFC-0031). Opt-in;
209    /// [`Self::canonical_bytes`] remains the signature-stable form
210    /// for historical DSSE envelopes.
211    ///
212    /// # Errors
213    ///
214    /// Propagates serialization errors from [`crate::jcs`].
215    pub fn to_jcs_bytes(&self) -> Result<Vec<u8>> {
216        crate::jcs::to_jcs_bytes(self)
217    }
218
219    /// Parse from JSON.
220    ///
221    /// # Errors
222    ///
223    /// Returns `Err` for malformed JSON or schema mismatches.
224    pub fn from_json(s: &str) -> Result<Self> {
225        serde_json::from_str(s).map_err(|e| AionError::InvalidFormat {
226            reason: format!("in-toto Statement JSON parse failed: {e}"),
227        })
228    }
229}
230
231/// Fluent builder for [`InTotoStatement`].
232#[derive(Debug)]
233pub struct SlsaStatementBuilder {
234    subjects: Vec<Subject>,
235    build_type: String,
236    builder_id: String,
237    external_parameters: serde_json::Value,
238    internal_parameters: Option<serde_json::Value>,
239    resolved_dependencies: Vec<ResourceDescriptor>,
240    metadata: BuildMetadata,
241    byproducts: Vec<ResourceDescriptor>,
242}
243
244impl SlsaStatementBuilder {
245    /// Start a new builder. `builder_id` is the URI identifying the
246    /// build system and is mandatory per SLSA v1.1.
247    #[must_use]
248    pub fn new(builder_id: impl Into<String>) -> Self {
249        Self {
250            subjects: Vec::new(),
251            build_type: AION_DEFAULT_BUILD_TYPE.to_string(),
252            builder_id: builder_id.into(),
253            external_parameters: serde_json::json!({}),
254            internal_parameters: None,
255            resolved_dependencies: Vec::new(),
256            metadata: BuildMetadata::default(),
257            byproducts: Vec::new(),
258        }
259    }
260
261    /// Override the default buildType URI.
262    pub fn build_type(&mut self, uri: impl Into<String>) -> &mut Self {
263        self.build_type = uri.into();
264        self
265    }
266
267    /// Set the build's externalParameters.
268    pub fn external_parameters(&mut self, params: serde_json::Value) -> &mut Self {
269        self.external_parameters = params;
270        self
271    }
272
273    /// Set the build's internalParameters.
274    pub fn internal_parameters(&mut self, params: serde_json::Value) -> &mut Self {
275        self.internal_parameters = Some(params);
276        self
277    }
278
279    /// Append a resolved dependency.
280    pub fn add_resolved_dependency(&mut self, descriptor: ResourceDescriptor) -> &mut Self {
281        self.resolved_dependencies.push(descriptor);
282        self
283    }
284
285    /// Append a byproduct.
286    pub fn add_byproduct(&mut self, descriptor: ResourceDescriptor) -> &mut Self {
287        self.byproducts.push(descriptor);
288        self
289    }
290
291    /// Set the invocation id.
292    pub fn invocation_id(&mut self, id: impl Into<String>) -> &mut Self {
293        self.metadata.invocation_id = Some(id.into());
294        self
295    }
296
297    /// Set the start timestamp (RFC 3339).
298    pub fn started_on(&mut self, ts: impl Into<String>) -> &mut Self {
299        self.metadata.started_on = Some(ts.into());
300        self
301    }
302
303    /// Set the finish timestamp (RFC 3339).
304    pub fn finished_on(&mut self, ts: impl Into<String>) -> &mut Self {
305        self.metadata.finished_on = Some(ts.into());
306        self
307    }
308
309    /// Append a subject derived from one manifest entry.
310    ///
311    /// # Errors
312    ///
313    /// Returns `Err` if the entry's name cannot be decoded from the
314    /// manifest name table.
315    pub fn add_subject_from_entry(
316        &mut self,
317        manifest: &ArtifactManifest,
318        entry: &ArtifactEntry,
319    ) -> Result<&mut Self> {
320        let name = manifest.name_of(entry)?.to_string();
321        let mut digest = BTreeMap::new();
322        digest.insert(BLAKE3_DIGEST_KEY.to_string(), hex::encode(entry.hash));
323        self.subjects.push(Subject { name, digest });
324        Ok(self)
325    }
326
327    /// Append subjects for every entry in `manifest`.
328    ///
329    /// # Errors
330    ///
331    /// Propagates any name-table decoding error from
332    /// [`Self::add_subject_from_entry`].
333    pub fn add_all_subjects_from_manifest(
334        &mut self,
335        manifest: &ArtifactManifest,
336    ) -> Result<&mut Self> {
337        // Collect names first to avoid holding a borrow of `manifest`
338        // across the mutable self.subjects.push inside the loop.
339        let mut entries: Vec<(String, [u8; 32])> = Vec::with_capacity(manifest.entries().len());
340        for entry in manifest.entries() {
341            entries.push((manifest.name_of(entry)?.to_string(), entry.hash));
342        }
343        for (name, digest_bytes) in entries {
344            let mut digest = BTreeMap::new();
345            digest.insert(BLAKE3_DIGEST_KEY.to_string(), hex::encode(digest_bytes));
346            self.subjects.push(Subject { name, digest });
347        }
348        Ok(self)
349    }
350
351    /// Finalize into a validated [`InTotoStatement`].
352    ///
353    /// # Errors
354    ///
355    /// Returns `Err` if no subjects were registered or if `builder_id`
356    /// is empty — both are required by SLSA v1.1.
357    pub fn build(self) -> Result<InTotoStatement> {
358        if self.subjects.is_empty() {
359            return Err(AionError::InvalidFormat {
360                reason: "SLSA Statement must have at least one subject".to_string(),
361            });
362        }
363        if self.builder_id.is_empty() {
364            return Err(AionError::InvalidFormat {
365                reason: "SLSA Statement requires a non-empty builder.id".to_string(),
366            });
367        }
368        Ok(InTotoStatement {
369            type_uri: IN_TOTO_STATEMENT_TYPE.to_string(),
370            subject: self.subjects,
371            predicate_type: SLSA_V1_PREDICATE_TYPE.to_string(),
372            predicate: SlsaProvenancePredicate {
373                build_definition: BuildDefinition {
374                    build_type: self.build_type,
375                    external_parameters: self.external_parameters,
376                    internal_parameters: self.internal_parameters,
377                    resolved_dependencies: self.resolved_dependencies,
378                },
379                run_details: RunDetails {
380                    builder: Builder {
381                        id: self.builder_id,
382                    },
383                    metadata: self.metadata,
384                    byproducts: self.byproducts,
385                },
386            },
387        })
388    }
389}
390
391/// Wrap a statement in a DSSE envelope signed by `signer`.
392///
393/// The payload type is always [`IN_TOTO_PAYLOAD_TYPE`], matching
394/// what every DSSE-aware SLSA verifier expects.
395///
396/// # Errors
397///
398/// Propagates JSON serialization errors.
399pub fn wrap_statement_dsse(
400    statement: &InTotoStatement,
401    signer: AuthorId,
402    key: &SigningKey,
403) -> Result<DsseEnvelope> {
404    let payload = statement.canonical_bytes()?;
405    Ok(dsse::sign_envelope(
406        &payload,
407        IN_TOTO_PAYLOAD_TYPE,
408        signer,
409        key,
410    ))
411}
412
413/// Unwrap a DSSE envelope that is known to carry an in-toto Statement.
414///
415/// The caller is expected to separately verify the DSSE signature via
416/// [`crate::dsse::verify_envelope`] before trusting the statement
417/// contents.
418///
419/// # Errors
420///
421/// Returns `Err` if the envelope's `payloadType` is not
422/// [`IN_TOTO_PAYLOAD_TYPE`] or if the payload bytes fail to parse as
423/// an in-toto Statement.
424pub fn unwrap_statement_dsse(envelope: &DsseEnvelope) -> Result<InTotoStatement> {
425    if envelope.payload_type != IN_TOTO_PAYLOAD_TYPE {
426        return Err(AionError::InvalidFormat {
427            reason: format!(
428                "envelope payloadType is '{}', expected '{}'",
429                envelope.payload_type, IN_TOTO_PAYLOAD_TYPE
430            ),
431        });
432    }
433    let payload_str =
434        std::str::from_utf8(&envelope.payload).map_err(|e| AionError::InvalidFormat {
435            reason: format!("envelope payload is not valid UTF-8: {e}"),
436        })?;
437    InTotoStatement::from_json(payload_str)
438}
439
440#[cfg(test)]
441#[allow(clippy::unwrap_used)]
442mod tests {
443    use super::*;
444    use crate::dsse::verify_envelope;
445    use crate::key_registry::KeyRegistry;
446    use crate::manifest::ArtifactManifestBuilder;
447    use serde_json::json;
448
449    /// Pin `signer` with `key` as the active op pubkey at epoch 0.
450    fn reg_pinning(signer: AuthorId, key: &SigningKey) -> KeyRegistry {
451        let mut reg = KeyRegistry::new();
452        let master = SigningKey::generate();
453        reg.register_author(signer, master.verifying_key(), key.verifying_key(), 0)
454            .unwrap();
455        reg
456    }
457
458    fn build_sample_manifest() -> ArtifactManifest {
459        let mut m = ArtifactManifestBuilder::new();
460        let _ = m.add("model.bin", &[0xAAu8; 32]);
461        let _ = m.add("tokenizer.json", b"{}");
462        m.build()
463    }
464
465    #[test]
466    fn should_build_minimal_statement() {
467        let manifest = build_sample_manifest();
468        let mut b = SlsaStatementBuilder::new("https://example.com/ci/1");
469        b.add_all_subjects_from_manifest(&manifest).unwrap();
470        let statement = b.build().unwrap();
471        assert_eq!(statement.type_uri, IN_TOTO_STATEMENT_TYPE);
472        assert_eq!(statement.predicate_type, SLSA_V1_PREDICATE_TYPE);
473        assert_eq!(statement.subject.len(), 2);
474        assert_eq!(
475            statement.predicate.build_definition.build_type,
476            AION_DEFAULT_BUILD_TYPE
477        );
478    }
479
480    #[test]
481    fn should_reject_empty_subjects() {
482        let b = SlsaStatementBuilder::new("https://example.com/ci/1");
483        assert!(b.build().is_err());
484    }
485
486    #[test]
487    fn should_reject_empty_builder_id() {
488        let manifest = build_sample_manifest();
489        let mut b = SlsaStatementBuilder::new("");
490        b.add_all_subjects_from_manifest(&manifest).unwrap();
491        assert!(b.build().is_err());
492    }
493
494    #[test]
495    fn should_round_trip_through_json() {
496        let manifest = build_sample_manifest();
497        let mut b = SlsaStatementBuilder::new("https://example.com/ci/1");
498        b.add_all_subjects_from_manifest(&manifest).unwrap();
499        b.external_parameters(json!({"source": "git@example.com/org/repo"}));
500        b.invocation_id("run-42");
501        let statement = b.build().unwrap();
502        let json = statement.to_json().unwrap();
503        let parsed = InTotoStatement::from_json(&json).unwrap();
504        assert_eq!(parsed, statement);
505    }
506
507    #[test]
508    fn should_wrap_and_verify_via_dsse() {
509        let manifest = build_sample_manifest();
510        let mut b = SlsaStatementBuilder::new("https://example.com/ci/1");
511        b.add_all_subjects_from_manifest(&manifest).unwrap();
512        let statement = b.build().unwrap();
513        let signer = AuthorId::new(42);
514        let key = SigningKey::generate();
515        let env = wrap_statement_dsse(&statement, signer, &key).unwrap();
516        assert_eq!(env.payload_type, IN_TOTO_PAYLOAD_TYPE);
517        let reg = reg_pinning(signer, &key);
518        let verified = verify_envelope(&env, &reg, 1).unwrap();
519        assert_eq!(verified.len(), 1);
520        let back = unwrap_statement_dsse(&env).unwrap();
521        assert_eq!(back, statement);
522    }
523
524    #[test]
525    fn should_reject_unwrap_with_wrong_payload_type() {
526        let key = SigningKey::generate();
527        let signer = AuthorId::new(1);
528        let env = dsse::sign_envelope(b"not a statement", "text/plain", signer, &key);
529        assert!(unwrap_statement_dsse(&env).is_err());
530    }
531
532    #[test]
533    fn subject_digest_uses_blake3_label() {
534        let manifest = build_sample_manifest();
535        let entry = manifest.entries().first().unwrap();
536        let mut b = SlsaStatementBuilder::new("https://example.com/ci/1");
537        b.add_subject_from_entry(&manifest, entry).unwrap();
538        let statement = b.build().unwrap();
539        let subject = statement.subject.first().unwrap();
540        assert!(subject.digest.contains_key(BLAKE3_DIGEST_KEY));
541        assert_eq!(
542            subject.digest.get(BLAKE3_DIGEST_KEY).unwrap(),
543            &hex::encode(entry.hash)
544        );
545    }
546
547    mod properties {
548        use super::*;
549        use crate::crypto::VerifyingKey;
550        use hegel::generators as gs;
551
552        fn draw_manifest(tc: &hegel::TestCase) -> ArtifactManifest {
553            let n = tc.draw(gs::integers::<usize>().min_value(1).max_value(4));
554            let mut b = ArtifactManifestBuilder::new();
555            let mut counter: u64 = 0;
556            for _ in 0..n {
557                let bytes = tc.draw(gs::binary().max_size(256));
558                let name = format!("artifact_{counter}");
559                counter = counter.saturating_add(1);
560                let _ = b.add(&name, &bytes);
561            }
562            b.build()
563        }
564
565        #[hegel::test]
566        fn prop_slsa_dsse_roundtrip(tc: hegel::TestCase) {
567            let manifest = draw_manifest(&tc);
568            let mut builder = SlsaStatementBuilder::new("https://example.com/ci/1");
569            builder
570                .add_all_subjects_from_manifest(&manifest)
571                .unwrap_or_else(|_| std::process::abort());
572            let statement = builder.build().unwrap_or_else(|_| std::process::abort());
573            let signer = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
574            let key = SigningKey::generate();
575            let env = wrap_statement_dsse(&statement, signer, &key)
576                .unwrap_or_else(|_| std::process::abort());
577            let reg = reg_pinning(signer, &key);
578            let verified = verify_envelope(&env, &reg, 1).unwrap_or_else(|_| std::process::abort());
579            assert_eq!(verified.len(), 1);
580            let roundtripped =
581                unwrap_statement_dsse(&env).unwrap_or_else(|_| std::process::abort());
582            assert_eq!(roundtripped, statement);
583        }
584
585        #[hegel::test]
586        fn prop_slsa_manifest_binding_survives_json(tc: hegel::TestCase) {
587            let manifest = draw_manifest(&tc);
588            let mut builder = SlsaStatementBuilder::new("https://example.com/ci/1");
589            builder
590                .add_all_subjects_from_manifest(&manifest)
591                .unwrap_or_else(|_| std::process::abort());
592            let statement = builder.build().unwrap_or_else(|_| std::process::abort());
593            let json = statement
594                .to_json()
595                .unwrap_or_else(|_| std::process::abort());
596            let parsed =
597                InTotoStatement::from_json(&json).unwrap_or_else(|_| std::process::abort());
598            assert_eq!(parsed.subject.len(), manifest.entries().len());
599            for (subject, entry) in parsed.subject.iter().zip(manifest.entries().iter()) {
600                let expected = hex::encode(entry.hash);
601                let got = subject
602                    .digest
603                    .get(BLAKE3_DIGEST_KEY)
604                    .unwrap_or_else(|| std::process::abort());
605                assert_eq!(got, &expected);
606            }
607        }
608
609        #[hegel::test]
610        fn prop_slsa_tampered_subject_digest_rejects(tc: hegel::TestCase) {
611            let manifest = draw_manifest(&tc);
612            let mut builder = SlsaStatementBuilder::new("https://example.com/ci/1");
613            builder
614                .add_all_subjects_from_manifest(&manifest)
615                .unwrap_or_else(|_| std::process::abort());
616            let statement = builder.build().unwrap_or_else(|_| std::process::abort());
617            let signer = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
618            let key = SigningKey::generate();
619            let mut env = wrap_statement_dsse(&statement, signer, &key)
620                .unwrap_or_else(|_| std::process::abort());
621            // Flip a byte in the payload (the JSON body) → verification fails.
622            let max = env.payload.len().saturating_sub(1);
623            let idx = tc.draw(gs::integers::<usize>().max_value(max));
624            if let Some(b) = env.payload.get_mut(idx) {
625                *b ^= 0x01;
626            }
627            let reg = reg_pinning(signer, &key);
628            let result: Result<Vec<String>> = verify_envelope(&env, &reg, 1);
629            assert!(result.is_err());
630        }
631
632        #[hegel::test]
633        fn prop_slsa_envelope_payload_type_is_in_toto(tc: hegel::TestCase) {
634            let manifest = draw_manifest(&tc);
635            let mut builder = SlsaStatementBuilder::new("https://example.com/ci/1");
636            builder
637                .add_all_subjects_from_manifest(&manifest)
638                .unwrap_or_else(|_| std::process::abort());
639            let statement = builder.build().unwrap_or_else(|_| std::process::abort());
640            let signer = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
641            let key = SigningKey::generate();
642            let env = wrap_statement_dsse(&statement, signer, &key)
643                .unwrap_or_else(|_| std::process::abort());
644            assert_eq!(env.payload_type, IN_TOTO_PAYLOAD_TYPE);
645            // Suppress `unused` for the signer variable path and keep
646            // the VerifyingKey import live in test builds.
647            let _ = signer;
648            let _: fn() -> Option<VerifyingKey> = || None;
649        }
650
651        #[hegel::test]
652        fn prop_slsa_statement_to_jcs_bytes_matches_helper(tc: hegel::TestCase) {
653            let manifest = draw_manifest(&tc);
654            let mut builder = SlsaStatementBuilder::new("https://example.com/ci/1");
655            builder
656                .add_all_subjects_from_manifest(&manifest)
657                .unwrap_or_else(|_| std::process::abort());
658            let statement = builder.build().unwrap_or_else(|_| std::process::abort());
659            let from_method = statement
660                .to_jcs_bytes()
661                .unwrap_or_else(|_| std::process::abort());
662            let from_helper =
663                crate::jcs::to_jcs_bytes(&statement).unwrap_or_else(|_| std::process::abort());
664            assert_eq!(from_method, from_helper);
665        }
666    }
667}