1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::artifact::TrustedArtifact;
14use crate::bundle::TrustAnchorBundle;
15use crate::coverage::{
16 Acceptance, AcceptancePolicy, ClassificationLabel, ClassificationPolicy, CoverageReport,
17 HardCheckStatus, ReferenceClassificationPolicy,
18};
19use crate::error::{SignatifError, SignatifResult};
20use crate::graph::{SignatureVerifier, TrustGraph};
21use crate::registry::Registry;
22use crate::revocation::{DEFAULT_GRACE_PERIOD, RevocationView};
23use crate::scope::ScopeDimensions;
24
25#[derive(Debug, Clone, Default)]
27pub struct TransparencyInputs {
28 pub artifact_included: bool,
31 pub multi_log_quorum: bool,
33 pub time_anchored: bool,
35 pub time_attested_at: Option<DateTime<Utc>>,
39 pub downgrades: Vec<String>,
42}
43
44#[derive(Debug, Clone, Copy)]
48pub struct FreshnessWindow {
49 pub window: chrono::Duration,
51 pub grace: chrono::Duration,
53}
54
55impl Default for FreshnessWindow {
56 fn default() -> Self {
57 Self {
58 window: chrono::Duration::seconds(30 * 60),
59 grace: chrono::Duration::hours(24),
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct VerificationOutcome {
67 pub report: CoverageReport,
69 pub label: ClassificationLabel,
71 pub acceptance: Acceptance,
73}
74
75pub struct Pipeline<'a> {
77 pub bundle: &'a TrustAnchorBundle,
79 pub graph: &'a TrustGraph,
81 pub registry: &'a Registry,
83 pub verifier: &'a dyn SignatureVerifier,
85 pub revocation: &'a dyn RevocationView,
87 pub transparency: TransparencyInputs,
89 pub freshness: FreshnessWindow,
91 pub classification: &'a dyn ClassificationPolicy,
93 pub acceptance: &'a AcceptancePolicy,
95}
96
97impl<'a> Pipeline<'a> {
98 #[allow(clippy::too_many_arguments)]
100 pub fn new(
101 bundle: &'a TrustAnchorBundle,
102 graph: &'a TrustGraph,
103 registry: &'a Registry,
104 verifier: &'a dyn SignatureVerifier,
105 revocation: &'a dyn RevocationView,
106 transparency: TransparencyInputs,
107 acceptance: &'a AcceptancePolicy,
108 ) -> Self {
109 Self {
110 bundle,
111 graph,
112 registry,
113 verifier,
114 revocation,
115 transparency,
116 freshness: FreshnessWindow::default(),
117 classification: &ReferenceClassificationPolicy,
118 acceptance,
119 }
120 }
121
122 pub fn with_classification(mut self, policy: &'a dyn ClassificationPolicy) -> Self {
124 self.classification = policy;
125 self
126 }
127
128 pub fn with_freshness(mut self, window: FreshnessWindow) -> Self {
130 self.freshness = window;
131 self
132 }
133
134 pub fn run(
146 &self,
147 artifact: &TrustedArtifact,
148 now: DateTime<Utc>,
149 ) -> SignatifResult<VerificationOutcome> {
150 self.bundle
152 .verify(now, self.verifier)
153 .map_err(|e| SignatifError::HardCheck(format!("bundle_validity: {e}")))?;
154
155 let supported = crate::artifact::ArtifactVersion { major: 1, minor: 0 };
159 if !supported.accepts(&artifact.version) {
160 return Err(SignatifError::HardCheck(format!(
161 "format_version: artifact {} exceeds supported {}",
162 artifact.version, supported
163 )));
164 }
165 artifact
166 .verify_self(self.registry, self.verifier)
167 .map_err(|e| SignatifError::HardCheck(format!("signature_validity: {e}")))?;
168
169 let mut paths_found = 0usize;
173 let mut roots: Vec<String> = Vec::new();
174 for block in &artifact.co_signatures {
175 let node = self.graph.node(&block.signer_cert_ref).ok_or_else(|| {
176 SignatifError::HardCheck(format!(
177 "chain_integrity: unknown signer {}",
178 block.signer_cert_ref
179 ))
180 })?;
181 let paths = self
182 .graph
183 .find_paths(&node.id, self.bundle, self.verifier)
184 .map_err(|e| SignatifError::HardCheck(format!("chain_integrity: {e}")))?;
185 if paths.is_empty() {
186 return Err(SignatifError::HardCheck(format!(
187 "chain_integrity: no path from {} to an anchor",
188 node.id
189 )));
190 }
191 paths_found += paths.len();
192 for p in &paths {
193 if !roots.contains(&p.root.id) {
194 roots.push(p.root.id.clone());
195 }
196 }
197 }
198
199 for block in &artifact.co_signatures {
204 if let Some(node) = self.graph.node(&block.signer_cert_ref) {
205 let ctx = crate::conditions::ConditionContext::new(
206 &artifact.payload,
207 &artifact.artifact_id,
208 &block.signer_cert_ref,
209 block.dimension.as_str(),
210 &block.timestamp.to_rfc3339(),
211 );
212 crate::conditions::evaluate_all(&node.scope.conditions, &ctx)
213 .map_err(|e| SignatifError::HardCheck(format!("scope_conditions: {e}")))?;
214 }
215 }
216
217 for block in &artifact.co_signatures {
219 let status = self
220 .revocation
221 .authority_status(&block.signer_cert_ref, now);
222 match status {
223 crate::revocation::RevocationStatus::Revoked => {
224 return Err(SignatifError::HardCheck(format!(
225 "revocation: signer {} is revoked",
226 block.signer_cert_ref
227 )));
228 }
229 crate::revocation::RevocationStatus::GraceDowngrade => {
230 }
232 crate::revocation::RevocationStatus::Good => {}
233 }
234 }
235
236 let mut downgrades = self.transparency.downgrades.clone();
240 for block in &artifact.co_signatures {
241 if self.registry.algorithms.status(&block.algorithm)
242 == Some(crate::registry::Status::Deprecated)
243 {
244 downgrades.push(format!("deprecated_algorithm:{}", block.algorithm));
245 }
246 }
247 if !self.transparency.artifact_included {
248 downgrades.push("transparency_missing".into());
249 }
250 if !self.transparency.time_anchored {
251 downgrades.push("time_anchor_absent".into());
252 }
253 if self.revocation.max_crl_age(now) > DEFAULT_GRACE_PERIOD {
254 downgrades.push("crl_stale".into());
255 }
256 let freshness_source = self.transparency.time_attested_at.or_else(|| {
260 artifact
261 .co_signatures
262 .iter()
263 .filter(|b| b.dimension.as_str() == crate::registry::DimensionTag::TIME)
264 .map(|b| b.timestamp)
265 .max()
266 });
267 if let Some(newest) = freshness_source {
268 let age = now.signed_duration_since(newest);
269 if age > self.freshness.window + self.freshness.grace {
270 return Err(SignatifError::HardCheck(
271 "time_freshness: time attestation outside window and grace".into(),
272 ));
273 }
274 if age > self.freshness.window {
275 downgrades.push("time_attestation_stale".into());
276 }
277 }
278
279 let report = CoverageReport {
280 hard_checks: HardCheckStatus::Pass,
281 transparency_included: self.transparency.artifact_included,
282 time_anchored: self.transparency.time_anchored,
283 dimensions_verified: artifact
284 .dimensions_verified()
285 .iter()
286 .map(|d| d.as_str().to_string())
287 .collect(),
288 dimension_count: artifact.dimensions_verified().len(),
289 independent_roots: roots.len(),
290 multi_log_quorum: self.transparency.multi_log_quorum,
291 paths_found,
292 downgrades,
293 };
294 let label = self.classification.classify(&report);
295 let acceptance = self.acceptance.decide(&label);
296 Ok(VerificationOutcome {
297 report,
298 label,
299 acceptance,
300 })
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct SignerScopeSummary {
308 pub signer: String,
310 pub scope: ScopeDimensions,
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::artifact::ArtifactVersion;
318 use crate::bundle::AnchorRoot;
319 use crate::graph::{AuthorityKind, AuthorityNode, DelegationEdge, Quorum as GraphQuorum};
320 use crate::registry::DimensionTag;
321 use crate::registry::Registry;
322 use crate::revocation::NoRevocations;
323 use ed25519_dalek::{Signer, SigningKey};
324
325 fn generate_key() -> ed25519_dalek::SigningKey {
326 use rand_core::RngCore;
327 let mut seed = [0u8; 32];
328 rand_core::OsRng.fill_bytes(&mut seed);
329 ed25519_dalek::SigningKey::from_bytes(&seed)
330 }
331
332 struct Ed25519Verifier;
333
334 impl SignatureVerifier for Ed25519Verifier {
335 fn verify(&self, pk: &[u8], msg: &[u8], sig: &[u8]) -> bool {
336 use ed25519_dalek::Signature;
337 use ed25519_dalek::Verifier;
338 let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pk.try_into().unwrap()) else {
339 return false;
340 };
341 let Ok(signature) = Signature::from_slice(sig) else {
342 return false;
343 };
344 vk.verify(msg, &signature).is_ok()
345 }
346 }
347
348 struct Fixture {
349 graph: TrustGraph,
350 bundle: TrustAnchorBundle,
351 registry: Registry,
352 artifact: TrustedArtifact,
353 end_sk: SigningKey,
354 root_sk: SigningKey,
355 }
356
357 fn build() -> Fixture {
358 let registry = Registry::with_initial_values();
359 let root_sk = generate_key();
360 let root = AuthorityNode {
361 id: "root".into(),
362 kind: AuthorityKind::Root,
363 public_key: root_sk.verifying_key().as_bytes().to_vec(),
364 quorum: Some(GraphQuorum::new(2, 3).unwrap()),
365 scope: crate::scope::ScopeDimensions::unconstrained(),
366 };
367 let end_sk = generate_key();
368 let end = AuthorityNode {
369 id: "end".into(),
370 kind: AuthorityKind::EndCertificate,
371 public_key: end_sk.verifying_key().as_bytes().to_vec(),
372 quorum: None,
373 scope: crate::scope::ScopeDimensions::unconstrained(),
374 };
375 let mut graph = TrustGraph::new();
376 graph.add_node(root.clone());
377 graph.add_node(end.clone());
378 graph
379 .add_delegation(DelegationEdge {
380 parent: "root".into(),
381 child: "end".into(),
382 signature: root_sk
383 .sign(&end.binding_bytes().unwrap())
384 .to_bytes()
385 .to_vec(),
386 })
387 .unwrap();
388
389 let bundle = TrustAnchorBundle {
390 bundle_version: "2026.08".into(),
391 valid_from: Utc::now() - chrono::Duration::hours(1),
392 valid_until: Utc::now() + chrono::Duration::days(30),
393 roots: vec![AnchorRoot {
394 name: "root".into(),
395 aggregate_key: root.public_key.clone(),
396 fingerprint: "00".into(),
397 quorum: root.quorum,
398 }],
399 transparency_logs: vec![],
400 bundle_signature: vec![],
401 update_log: None,
402 };
403 let mut bundle = bundle;
406 let mut signed = bundle.clone();
407 signed.bundle_signature = Vec::new();
408 let msg = crate::jcs::canonicalize(&serde_json::to_value(&signed).unwrap()).unwrap();
409 bundle.bundle_signature = root_sk.sign(msg.as_bytes()).to_bytes().to_vec();
410
411 let mut artifact = TrustedArtifact::new(
412 ArtifactVersion { major: 1, minor: 0 },
413 "art-1",
414 serde_json::json!({"v": 1}),
415 None,
416 )
417 .unwrap();
418 artifact
419 .sign(
420 DimensionTag::data(),
421 "Ed25519",
422 "end",
423 end_sk.verifying_key().as_bytes().to_vec(),
424 "root",
425 &|m| end_sk.sign(m).to_bytes().to_vec(),
426 ®istry,
427 )
428 .unwrap();
429
430 Fixture {
431 graph,
432 bundle,
433 registry,
434 artifact,
435 end_sk,
436 root_sk,
437 }
438 }
439
440 #[test]
441 fn full_pipeline_rejects_without_transparency_then_ladders_up() {
442 let f = build();
443 static NO_REVOCATIONS: NoRevocations = NoRevocations;
444 let accept_all =
445 AcceptancePolicy::accept(&["unverified", "basic", "verified", "attested", "certified"]);
446
447 let pipe = Pipeline::new(
448 &f.bundle,
449 &f.graph,
450 &f.registry,
451 &Ed25519Verifier,
452 &NO_REVOCATIONS,
453 TransparencyInputs::default(),
454 &accept_all,
455 );
456 let out = pipe.run(&f.artifact, Utc::now()).unwrap();
457 assert_eq!(out.label.0, "unverified");
458 assert_eq!(out.acceptance, Acceptance::Accept);
459
460 let with_transparency = pipe_with(&f, true, false, &accept_all);
461 let out = with_transparency.run(&f.artifact, Utc::now()).unwrap();
462 assert_eq!(out.label.0, "basic");
463
464 let with_time = pipe_with(&f, true, true, &accept_all);
465 let out = with_time.run(&f.artifact, Utc::now()).unwrap();
466 assert_eq!(out.label.0, "verified");
467 }
468
469 fn pipe_with<'p>(
470 f: &'p Fixture,
471 transparency: bool,
472 time: bool,
473 acceptance: &'p AcceptancePolicy,
474 ) -> Pipeline<'p> {
475 static NO_REVOCATIONS: NoRevocations = NoRevocations;
476 Pipeline::new(
477 &f.bundle,
478 &f.graph,
479 &f.registry,
480 &Ed25519Verifier,
481 &NO_REVOCATIONS,
482 TransparencyInputs {
483 artifact_included: transparency,
484 time_anchored: time,
485 time_attested_at: None,
486 multi_log_quorum: false,
487 downgrades: vec![],
488 },
489 acceptance,
490 )
491 }
492
493 #[test]
494 fn tampered_artifact_short_circuits_to_hard_failure() {
495 let f = build();
496 let mut broken = f.artifact.clone();
497 broken.payload["v"] = serde_json::json!(2);
498 static NO_REVOCATIONS: NoRevocations = NoRevocations;
499 let accept_all = AcceptancePolicy::accept(&["verified"]);
500 let pipe = Pipeline::new(
501 &f.bundle,
502 &f.graph,
503 &f.registry,
504 &Ed25519Verifier,
505 &NO_REVOCATIONS,
506 TransparencyInputs::default(),
507 &accept_all,
508 );
509 let err = pipe.run(&broken, Utc::now()).unwrap_err();
510 assert!(err.to_string().contains("signature_validity"));
511 }
512
513 #[test]
514 fn scope_conditions_hard_fail_when_unmet() {
515 let f = build();
516 static NO_REVOCATIONS: NoRevocations = NoRevocations;
517 let accept_all = AcceptancePolicy::accept(&["verified"]);
518 let mut end = f.graph.node("end").unwrap().clone();
523 end.scope.conditions = vec![serde_json::json!({
524 ">=": [ {"var": "payload.v"}, 5 ]
525 })];
526 let root_node = f.graph.node("root").unwrap().clone();
527 use ed25519_dalek::Signer as _;
528 let mut graph = TrustGraph::new();
529 graph.add_node(root_node);
530 graph.add_node(end.clone());
531 graph
532 .add_delegation(DelegationEdge {
533 parent: "root".into(),
534 child: "end".into(),
535 signature: f
536 .root_sk
537 .sign(&end.binding_bytes().unwrap())
538 .to_bytes()
539 .to_vec(),
540 })
541 .unwrap();
542 let pipe = Pipeline::new(
543 &f.bundle,
544 &graph,
545 &f.registry,
546 &Ed25519Verifier,
547 &NO_REVOCATIONS,
548 TransparencyInputs::default(),
549 &accept_all,
550 );
551 let err = pipe.run(&f.artifact, Utc::now()).unwrap_err();
555 assert!(err.to_string().contains("scope_conditions"), "got {err}");
556 }
557
558 #[test]
559 fn deprecated_algorithm_downgrades_and_caps_label() {
560 let f = build();
561 static NO_REVOCATIONS: NoRevocations = NoRevocations;
562 let mut agile = f.registry.clone();
563 agile
564 .algorithms
565 .set_status("Ed25519", crate::registry::Status::Deprecated)
566 .unwrap();
567 let accept_all =
568 AcceptancePolicy::accept(&["unverified", "basic", "verified", "attested", "certified"]);
569 let pipe = Pipeline::new(
574 &f.bundle,
575 &f.graph,
576 &agile,
577 &Ed25519Verifier,
578 &NO_REVOCATIONS,
579 TransparencyInputs {
580 artifact_included: true,
581 time_anchored: true,
582 time_attested_at: None,
583 multi_log_quorum: false,
584 downgrades: vec![],
585 },
586 &accept_all,
587 );
588 let out = pipe.run(&f.artifact, Utc::now()).unwrap();
589 assert!(
590 out.report
591 .downgrades
592 .iter()
593 .any(|d| d == "deprecated_algorithm:Ed25519"),
594 "downgrades: {:?}",
595 out.report.downgrades
596 );
597
598 let person = ed25519_dalek::SigningKey::from_bytes(&{
600 use rand_core::RngCore as _;
601 let mut seed = [0u8; 32];
602 rand_core::OsRng.fill_bytes(&mut seed);
603 seed
604 });
605 use ed25519_dalek::Signer as _;
606 let mut rich = f.artifact.clone();
607 let input = rich.cosign_input(&DimensionTag::person());
608 rich.co_signatures.push(crate::artifact::CoSignatureBlock {
609 dimension: DimensionTag::person(),
610 algorithm: "Ed25519".into(),
611 signer_cert_ref: "end".into(),
612 signer_pubkey: person.verifying_key().as_bytes().to_vec(),
613 chain_ref: "root".into(),
614 signature: person.sign(&input).to_bytes().to_vec(),
615 timestamp: Utc::now(),
616 });
617 let out = pipe.run(&rich, Utc::now()).unwrap();
618 assert_eq!(out.label.0, "verified", "deprecated caps attested");
619
620 let mut retired = agile.clone();
622 retired
623 .algorithms
624 .set_status("Ed25519", crate::registry::Status::Retired)
625 .unwrap();
626 let pipe = Pipeline::new(
627 &f.bundle,
628 &f.graph,
629 &retired,
630 &Ed25519Verifier,
631 &NO_REVOCATIONS,
632 TransparencyInputs::default(),
633 &accept_all,
634 );
635 assert!(pipe.run(&f.artifact, Utc::now()).is_err());
636 }
637
638 #[test]
639 fn stale_time_attestation_beyond_grace_is_hard_failure() {
640 let f = build();
641 static NO_REVOCATIONS: NoRevocations = NoRevocations;
642 let accept_all = AcceptancePolicy::accept(&["verified"]);
643 let pipe = Pipeline::new(
644 &f.bundle,
645 &f.graph,
646 &f.registry,
647 &Ed25519Verifier,
648 &NO_REVOCATIONS,
649 TransparencyInputs {
650 artifact_included: true,
651 time_anchored: true,
652 time_attested_at: None,
653 multi_log_quorum: false,
654 downgrades: vec![],
655 },
656 &accept_all,
657 )
658 .with_freshness(FreshnessWindow {
659 window: chrono::Duration::seconds(60),
660 grace: chrono::Duration::seconds(60),
661 });
662 let mut old = f.artifact.clone();
665 let input = old.cosign_input(&DimensionTag::time());
666 use ed25519_dalek::Signer as _;
667 old.co_signatures.push(crate::artifact::CoSignatureBlock {
668 dimension: DimensionTag::time(),
669 algorithm: "Ed25519".into(),
670 signer_cert_ref: "end".into(),
671 signer_pubkey: f.end_sk.verifying_key().as_bytes().to_vec(),
672 chain_ref: "root".into(),
673 signature: f.end_sk.sign(&input).to_bytes().to_vec(),
674 timestamp: Utc::now() - chrono::Duration::hours(2),
675 });
676 let err = pipe.run(&old, Utc::now()).unwrap_err();
677 assert!(err.to_string().contains("time_freshness"), "got {err}");
678 }
679}