1use 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
57pub const IN_TOTO_STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1";
59
60pub const SLSA_V1_PREDICATE_TYPE: &str = "https://slsa.dev/provenance/v1";
62
63pub const IN_TOTO_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json";
65
66pub const AION_DEFAULT_BUILD_TYPE: &str = "https://aion-context.dev/buildtypes/generic/v1";
68
69pub const BLAKE3_DIGEST_KEY: &str = "blake3-256";
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct Subject {
75 pub name: String,
77 pub digest: BTreeMap<String, String>,
79}
80
81#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
84pub struct ResourceDescriptor {
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub name: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub uri: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub digest: Option<BTreeMap<String, String>>,
94 #[serde(rename = "mediaType", skip_serializing_if = "Option::is_none")]
96 pub media_type: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct Builder {
102 pub id: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub struct BuildDefinition {
109 #[serde(rename = "buildType")]
111 pub build_type: String,
112 #[serde(rename = "externalParameters")]
114 pub external_parameters: serde_json::Value,
115 #[serde(
117 rename = "internalParameters",
118 default,
119 skip_serializing_if = "Option::is_none"
120 )]
121 pub internal_parameters: Option<serde_json::Value>,
122 #[serde(rename = "resolvedDependencies", default)]
124 pub resolved_dependencies: Vec<ResourceDescriptor>,
125}
126
127#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
129pub struct BuildMetadata {
130 #[serde(rename = "invocationId", skip_serializing_if = "Option::is_none")]
132 pub invocation_id: Option<String>,
133 #[serde(rename = "startedOn", skip_serializing_if = "Option::is_none")]
135 pub started_on: Option<String>,
136 #[serde(rename = "finishedOn", skip_serializing_if = "Option::is_none")]
138 pub finished_on: Option<String>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct RunDetails {
144 pub builder: Builder,
146 #[serde(default, skip_serializing_if = "is_default_metadata")]
148 pub metadata: BuildMetadata,
149 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
160pub struct SlsaProvenancePredicate {
161 #[serde(rename = "buildDefinition")]
163 pub build_definition: BuildDefinition,
164 #[serde(rename = "runDetails")]
166 pub run_details: RunDetails,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct InTotoStatement {
172 #[serde(rename = "_type")]
174 pub type_uri: String,
175 pub subject: Vec<Subject>,
177 #[serde(rename = "predicateType")]
179 pub predicate_type: String,
180 pub predicate: SlsaProvenancePredicate,
182}
183
184impl InTotoStatement {
185 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 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 pub fn to_jcs_bytes(&self) -> Result<Vec<u8>> {
216 crate::jcs::to_jcs_bytes(self)
217 }
218
219 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#[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 #[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 pub fn build_type(&mut self, uri: impl Into<String>) -> &mut Self {
263 self.build_type = uri.into();
264 self
265 }
266
267 pub fn external_parameters(&mut self, params: serde_json::Value) -> &mut Self {
269 self.external_parameters = params;
270 self
271 }
272
273 pub fn internal_parameters(&mut self, params: serde_json::Value) -> &mut Self {
275 self.internal_parameters = Some(params);
276 self
277 }
278
279 pub fn add_resolved_dependency(&mut self, descriptor: ResourceDescriptor) -> &mut Self {
281 self.resolved_dependencies.push(descriptor);
282 self
283 }
284
285 pub fn add_byproduct(&mut self, descriptor: ResourceDescriptor) -> &mut Self {
287 self.byproducts.push(descriptor);
288 self
289 }
290
291 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 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 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 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 pub fn add_all_subjects_from_manifest(
334 &mut self,
335 manifest: &ArtifactManifest,
336 ) -> Result<&mut Self> {
337 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 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
391pub 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
413pub 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 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, ®, 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, ®, 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 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, ®, 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 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}