1use ed25519_dalek::{Signature, Verifier, VerifyingKey};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7use traverse_registry::{
8 ArtifactSignature, ArtifactSignatureScheme, ResolvedCapability, SourceKind,
9};
10use zeroize::Zeroizing;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct RuntimeIdentity {
14 pub subject_id: String,
15 #[serde(default)]
16 pub actor_id: Option<String>,
17 pub token_reference_hash: String,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum RuntimeSecurityMode {
22 Production,
23 Development,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RuntimeSecurityConfig {
28 pub mode: RuntimeSecurityMode,
29}
30
31impl RuntimeSecurityConfig {
32 #[must_use]
33 pub fn production() -> Self {
34 Self {
35 mode: RuntimeSecurityMode::Production,
36 }
37 }
38
39 #[must_use]
40 pub fn development() -> Self {
41 Self {
42 mode: RuntimeSecurityMode::Development,
43 }
44 }
45}
46
47impl Default for RuntimeSecurityConfig {
48 fn default() -> Self {
52 Self::production()
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct ArtifactVerificationRecord {
58 pub status: ArtifactVerificationStatus,
59 pub trust_level: ArtifactTrustLevel,
60 #[serde(default)]
61 pub scheme: Option<ArtifactVerificationScheme>,
62 #[serde(default)]
63 pub warning_code: Option<String>,
64 #[serde(default)]
65 pub error_code: Option<String>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum ArtifactVerificationStatus {
71 Verified,
72 Warning,
73 Rejected,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum ArtifactTrustLevel {
79 LocalDev,
80 PublishedGoverned,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum ArtifactVerificationScheme {
86 Ed25519,
87 Sigstore,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct RuntimeWarning {
92 pub code: String,
93 pub message: String,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum ArtifactVerificationFailure {
98 MissingChecksum(ArtifactVerificationRecord),
99 ChecksumMismatch(ArtifactVerificationRecord),
100 MissingSignature(ArtifactVerificationRecord),
101 SignatureVerificationFailed(ArtifactVerificationRecord),
102 SigstoreUnreachable(ArtifactVerificationRecord),
103}
104
105impl ArtifactVerificationFailure {
106 #[must_use]
107 pub fn code(&self) -> &'static str {
108 match self {
109 Self::MissingChecksum(_) => "missing_checksum",
110 Self::ChecksumMismatch(_) => "checksum_mismatch",
111 Self::MissingSignature(_) => "missing_signature",
112 Self::SignatureVerificationFailed(_) => "signature_verification_failed",
113 Self::SigstoreUnreachable(_) => "sigstore_unreachable",
114 }
115 }
116
117 #[must_use]
118 pub fn record(&self) -> &ArtifactVerificationRecord {
119 match self {
120 Self::MissingChecksum(record)
121 | Self::ChecksumMismatch(record)
122 | Self::MissingSignature(record)
123 | Self::SignatureVerificationFailed(record)
124 | Self::SigstoreUnreachable(record) => record,
125 }
126 }
127}
128
129#[must_use]
140pub fn derive_identity_from_jwt(token: &str) -> Option<RuntimeIdentity> {
141 let mut parts = token.split('.');
142 let header = parts.next();
143 let payload = parts.next();
144 let signature = parts.next();
145 if header.is_none() || payload.is_none() || signature.is_none() || parts.next().is_some() {
146 return None;
147 }
148 let payload = payload?;
149 let payload_bytes = Zeroizing::new(base64url_decode(payload).ok()?);
152 let value = serde_json::from_slice::<Value>(&payload_bytes).ok()?;
153 let subject_id = value
154 .get("sub")
155 .and_then(Value::as_str)
156 .filter(|sub| !sub.trim().is_empty())?
157 .to_string();
158 let actor_id = value
159 .get("act")
160 .and_then(|act| act.get("sub"))
161 .and_then(Value::as_str)
162 .filter(|actor| !actor.trim().is_empty())
163 .map(ToString::to_string);
164 Some(RuntimeIdentity {
165 subject_id,
166 actor_id,
167 token_reference_hash: sha256_hex(token.as_bytes()),
168 })
169}
170
171pub fn verify_artifact(
179 capability: &ResolvedCapability,
180 artifact_bytes: &[u8],
181 config: &RuntimeSecurityConfig,
182) -> Result<ArtifactVerificationRecord, ArtifactVerificationFailure> {
183 let trust_level = artifact_trust_level(capability);
184 let Some(binary) = capability.artifact.binary.as_ref() else {
185 return Ok(verified_local_record(trust_level));
186 };
187 let Some(signature) = binary.signature.as_ref() else {
188 if trust_level == ArtifactTrustLevel::LocalDev
189 && config.mode == RuntimeSecurityMode::Development
190 {
191 return Ok(ArtifactVerificationRecord {
192 status: ArtifactVerificationStatus::Warning,
193 trust_level,
194 scheme: None,
195 warning_code: Some("unsigned_local_dev_artifact".to_string()),
196 error_code: None,
197 });
198 }
199 let record = rejected_record(trust_level, None, "missing_signature");
200 return Err(ArtifactVerificationFailure::MissingSignature(record));
201 };
202
203 let verification = match signature.scheme {
204 ArtifactSignatureScheme::Ed25519 => verify_ed25519(signature, artifact_bytes, trust_level),
205 ArtifactSignatureScheme::Sigstore => verify_sigstore(signature, trust_level),
206 }?;
207 verify_checksum(capability, artifact_bytes, trust_level)?;
208 Ok(verification)
209}
210
211fn verify_checksum(
212 capability: &ResolvedCapability,
213 artifact_bytes: &[u8],
214 trust_level: ArtifactTrustLevel,
215) -> Result<(), ArtifactVerificationFailure> {
216 if trust_level != ArtifactTrustLevel::PublishedGoverned {
217 return Ok(());
218 }
219 let Some(expected) = capability.artifact.digests.binary_digest.as_deref() else {
220 return Err(ArtifactVerificationFailure::MissingChecksum(
221 rejected_record(trust_level, None, "missing_checksum"),
222 ));
223 };
224 let expected = expected.strip_prefix("sha256:").unwrap_or(expected);
225 let actual = sha256_hex(artifact_bytes);
226 if expected.eq_ignore_ascii_case(&actual) {
227 Ok(())
228 } else {
229 Err(ArtifactVerificationFailure::ChecksumMismatch(
230 rejected_record(trust_level, None, "checksum_mismatch"),
231 ))
232 }
233}
234
235fn artifact_trust_level(capability: &ResolvedCapability) -> ArtifactTrustLevel {
246 if capability.artifact.source.kind == SourceKind::Local {
247 return ArtifactTrustLevel::LocalDev;
248 }
249 if traverse_registry::is_governed_artifact_path(&capability.record.contract_path) {
250 ArtifactTrustLevel::PublishedGoverned
251 } else {
252 ArtifactTrustLevel::LocalDev
253 }
254}
255
256fn verify_ed25519(
257 signature: &ArtifactSignature,
258 artifact_bytes: &[u8],
259 trust_level: ArtifactTrustLevel,
260) -> Result<ArtifactVerificationRecord, ArtifactVerificationFailure> {
261 let Some(public_key_hex) = signature.public_key_hex.as_deref() else {
262 let record = rejected_record(
263 trust_level,
264 Some(ArtifactVerificationScheme::Ed25519),
265 "signature_verification_failed",
266 );
267 return Err(ArtifactVerificationFailure::SignatureVerificationFailed(
268 record,
269 ));
270 };
271 let Some(signature_hex) = signature.signature_hex.as_deref() else {
272 let record = rejected_record(
273 trust_level,
274 Some(ArtifactVerificationScheme::Ed25519),
275 "signature_verification_failed",
276 );
277 return Err(ArtifactVerificationFailure::SignatureVerificationFailed(
278 record,
279 ));
280 };
281 let Ok(public_key_bytes) = hex_decode(public_key_hex) else {
282 let record = rejected_record(
283 trust_level,
284 Some(ArtifactVerificationScheme::Ed25519),
285 "signature_verification_failed",
286 );
287 return Err(ArtifactVerificationFailure::SignatureVerificationFailed(
288 record,
289 ));
290 };
291 let Ok(signature_bytes) = hex_decode(signature_hex) else {
292 let record = rejected_record(
293 trust_level,
294 Some(ArtifactVerificationScheme::Ed25519),
295 "signature_verification_failed",
296 );
297 return Err(ArtifactVerificationFailure::SignatureVerificationFailed(
298 record,
299 ));
300 };
301 let Ok(public_key_array) = <[u8; 32]>::try_from(public_key_bytes.as_slice()) else {
302 let record = rejected_record(
303 trust_level,
304 Some(ArtifactVerificationScheme::Ed25519),
305 "signature_verification_failed",
306 );
307 return Err(ArtifactVerificationFailure::SignatureVerificationFailed(
308 record,
309 ));
310 };
311 let Ok(signature_array) = <[u8; 64]>::try_from(signature_bytes.as_slice()) else {
312 return Err(signature_verification_failed(trust_level));
313 };
314 let key = VerifyingKey::from_bytes(&public_key_array)
315 .map_err(|_| signature_verification_failed(trust_level))?;
316 let signature = Signature::from_bytes(&signature_array);
317 if key.verify(artifact_bytes, &signature).is_err() {
318 return Err(signature_verification_failed(trust_level));
319 }
320 Ok(ArtifactVerificationRecord {
321 status: ArtifactVerificationStatus::Verified,
322 trust_level,
323 scheme: Some(ArtifactVerificationScheme::Ed25519),
324 warning_code: None,
325 error_code: None,
326 })
327}
328
329fn signature_verification_failed(trust_level: ArtifactTrustLevel) -> ArtifactVerificationFailure {
330 ArtifactVerificationFailure::SignatureVerificationFailed(rejected_record(
331 trust_level,
332 Some(ArtifactVerificationScheme::Ed25519),
333 "signature_verification_failed",
334 ))
335}
336
337fn verify_sigstore(
338 _signature: &ArtifactSignature,
339 trust_level: ArtifactTrustLevel,
340) -> Result<ArtifactVerificationRecord, ArtifactVerificationFailure> {
341 let record = rejected_record(
342 trust_level,
343 Some(ArtifactVerificationScheme::Sigstore),
344 "sigstore_unreachable",
345 );
346 Err(ArtifactVerificationFailure::SigstoreUnreachable(record))
347}
348
349fn verified_local_record(trust_level: ArtifactTrustLevel) -> ArtifactVerificationRecord {
350 ArtifactVerificationRecord {
351 status: ArtifactVerificationStatus::Verified,
352 trust_level,
353 scheme: None,
354 warning_code: None,
355 error_code: None,
356 }
357}
358
359fn rejected_record(
360 trust_level: ArtifactTrustLevel,
361 scheme: Option<ArtifactVerificationScheme>,
362 error_code: &str,
363) -> ArtifactVerificationRecord {
364 ArtifactVerificationRecord {
365 status: ArtifactVerificationStatus::Rejected,
366 trust_level,
367 scheme,
368 warning_code: None,
369 error_code: Some(error_code.to_string()),
370 }
371}
372
373fn sha256_hex(bytes: &[u8]) -> String {
374 let digest = Sha256::digest(bytes);
375 let mut output = String::with_capacity(digest.len() * 2);
376 for byte in digest {
377 output.push(char::from(HEX_TABLE[(byte >> 4) as usize]));
378 output.push(char::from(HEX_TABLE[(byte & 0x0f) as usize]));
379 }
380 output
381}
382
383const HEX_TABLE: &[u8; 16] = b"0123456789abcdef";
384
385fn hex_decode(input: &str) -> Result<Vec<u8>, ()> {
386 if !input.len().is_multiple_of(2) {
387 return Err(());
388 }
389 let mut output = Vec::with_capacity(input.len() / 2);
390 for pair in input.as_bytes().chunks_exact(2) {
391 let high = hex_nibble(pair[0])?;
392 let low = hex_nibble(pair[1])?;
393 output.push((high << 4) | low);
394 }
395 Ok(output)
396}
397
398fn hex_nibble(byte: u8) -> Result<u8, ()> {
399 match byte {
400 b'0'..=b'9' => Ok(byte - b'0'),
401 b'a'..=b'f' => Ok(byte - b'a' + 10),
402 b'A'..=b'F' => Ok(byte - b'A' + 10),
403 _ => Err(()),
404 }
405}
406
407fn base64url_decode(input: &str) -> Result<Vec<u8>, ()> {
408 if input.contains('=') {
409 return Err(());
410 }
411 let mut sextets = Vec::with_capacity(input.len());
412 for ch in input.chars() {
413 let val = match ch {
414 'A'..='Z' => (ch as u8) - b'A',
415 'a'..='z' => (ch as u8) - b'a' + 26,
416 '0'..='9' => (ch as u8) - b'0' + 52,
417 '-' => 62,
418 '_' => 63,
419 _ => return Err(()),
420 };
421 sextets.push(val);
422 }
423 match sextets.len() % 4 {
424 0 | 2 | 3 => {}
425 _ => return Err(()),
426 }
427 let mut out = Vec::with_capacity((sextets.len() * 3) / 4);
428 let mut i = 0;
429 while i + 4 <= sextets.len() {
430 let n = (u32::from(sextets[i]) << 18)
431 | (u32::from(sextets[i + 1]) << 12)
432 | (u32::from(sextets[i + 2]) << 6)
433 | u32::from(sextets[i + 3]);
434 out.push(((n >> 16) & 0xff) as u8);
435 out.push(((n >> 8) & 0xff) as u8);
436 out.push((n & 0xff) as u8);
437 i += 4;
438 }
439 let rem = sextets.len() - i;
440 if rem == 2 {
441 let n = (u32::from(sextets[i]) << 18) | (u32::from(sextets[i + 1]) << 12);
442 out.push(((n >> 16) & 0xff) as u8);
443 } else if rem == 3 {
444 let n = (u32::from(sextets[i]) << 18)
445 | (u32::from(sextets[i + 1]) << 12)
446 | (u32::from(sextets[i + 2]) << 6);
447 out.push(((n >> 16) & 0xff) as u8);
448 out.push(((n >> 8) & 0xff) as u8);
449 }
450 Ok(out)
451}
452
453#[cfg(test)]
454#[allow(clippy::expect_used)]
455mod tests {
456 use super::*;
457 use ed25519_dalek::{Signer, SigningKey};
458 use serde_json::json;
459 use traverse_contracts::{
460 CapabilityContract, Entrypoint, EntrypointKind, Execution, ExecutionConstraints,
461 ExecutionTarget, FilesystemAccess, HostApiAccess, Lifecycle, NetworkAccess, Owner,
462 Provenance, ProvenanceSource, SchemaContainer, ServiceType,
463 };
464 use traverse_registry::{
465 ArtifactDigests, BinaryFormat, BinaryReference, CapabilityArtifactRecord,
466 CapabilityRegistryRecord, ComposabilityMetadata, CompositionKind, CompositionPattern,
467 DiscoveryIndexEntry, ImplementationKind, RegistrationEvidence, RegistrationResult,
468 RegistryProvenance, RegistryScope, SourceReference,
469 };
470
471 #[allow(clippy::too_many_lines)]
472 fn test_capability(
473 contract_path: &str,
474 source_kind: SourceKind,
475 binary: Option<BinaryReference>,
476 ) -> ResolvedCapability {
477 let owner = Owner {
478 team: "comments".to_string(),
479 contact: "comments@example.com".to_string(),
480 };
481 let contract = CapabilityContract {
482 kind: "capability_contract".to_string(),
483 schema_version: "1.0.0".to_string(),
484 id: "content.comments.create-comment-draft".to_string(),
485 namespace: "content.comments".to_string(),
486 name: "create-comment-draft".to_string(),
487 version: "1.0.0".to_string(),
488 lifecycle: Lifecycle::Active,
489 owner: owner.clone(),
490 summary: "Create a comment draft for a resource".to_string(),
491 description: "Creates a draft comment and returns the generated draft identifier."
492 .to_string(),
493 inputs: SchemaContainer {
494 schema: json!({"type": "object"}),
495 },
496 outputs: SchemaContainer {
497 schema: json!({"type": "object"}),
498 },
499 preconditions: Vec::new(),
500 postconditions: Vec::new(),
501 side_effects: vec![traverse_contracts::SideEffect {
502 kind: traverse_contracts::SideEffectKind::MemoryOnly,
503 description: "Produces a draft representation in memory.".to_string(),
504 }],
505 emits: Vec::new(),
506 consumes: Vec::new(),
507 permissions: Vec::new(),
508 execution: Execution {
509 binary_format: traverse_contracts::BinaryFormat::Wasm,
510 entrypoint: Entrypoint {
511 kind: EntrypointKind::WasiCommand,
512 command: "run".to_string(),
513 },
514 preferred_targets: vec![ExecutionTarget::Local],
515 constraints: ExecutionConstraints {
516 host_api_access: HostApiAccess::None,
517 network_access: NetworkAccess::Forbidden,
518 filesystem_access: FilesystemAccess::None,
519 },
520 },
521 policies: Vec::new(),
522 dependencies: Vec::new(),
523 provenance: Provenance {
524 source: ProvenanceSource::Greenfield,
525 author: "Enrico Piovesan".to_string(),
526 created_at: "2026-03-27T00:00:00Z".to_string(),
527 spec_ref: Some("030-security-identity-model".to_string()),
528 adr_refs: Vec::new(),
529 exception_refs: Vec::new(),
530 },
531 evidence: Vec::new(),
532 service_type: ServiceType::Stateless,
533 permitted_targets: vec![ExecutionTarget::Local],
534 event_trigger: None,
535 connector_requirements: Vec::new(),
536 state_schema: None,
537 use_cases: Vec::new(),
538 risk: traverse_contracts::default_risk_metadata(),
539 };
540 let record = CapabilityRegistryRecord {
541 scope: RegistryScope::Private,
542 id: contract.id.clone(),
543 version: contract.version.clone(),
544 lifecycle: Lifecycle::Active,
545 owner: owner.clone(),
546 contract_path: contract_path.to_string(),
547 contract_digest: "digest".to_string(),
548 implementation_kind: ImplementationKind::Executable,
549 artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
550 registered_at: "2026-03-27T00:00:00Z".to_string(),
551 provenance: RegistryProvenance {
552 source: "test".to_string(),
553 author: "Enrico Piovesan".to_string(),
554 created_at: "2026-03-27T00:00:00Z".to_string(),
555 },
556 evidence: RegistrationEvidence {
557 evidence_id: "evidence".to_string(),
558 artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
559 capability_id: contract.id.clone(),
560 capability_version: contract.version.clone(),
561 scope: RegistryScope::Private,
562 governing_spec: "030-security-identity-model".to_string(),
563 validator_version: "0.1.0".to_string(),
564 produced_at: "2026-03-27T00:00:00Z".to_string(),
565 result: RegistrationResult::Passed,
566 },
567 };
568 let artifact = CapabilityArtifactRecord {
569 artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
570 implementation_kind: ImplementationKind::Executable,
571 source: SourceReference {
572 kind: source_kind,
573 location: "https://github.com/traverse-framework/traverse".to_string(),
574 },
575 binary,
576 workflow_ref: None,
577 digests: ArtifactDigests {
578 source_digest: "src-digest".to_string(),
579 binary_digest: None,
580 },
581 provenance: RegistryProvenance {
582 source: "test".to_string(),
583 author: "Enrico Piovesan".to_string(),
584 created_at: "2026-03-27T00:00:00Z".to_string(),
585 },
586 };
587 let index_entry = DiscoveryIndexEntry {
588 scope: RegistryScope::Private,
589 id: contract.id.clone(),
590 version: contract.version.clone(),
591 lifecycle: Lifecycle::Active,
592 owner,
593 summary: "Create a comment draft for a resource".to_string(),
594 tags: Vec::new(),
595 permissions: Vec::new(),
596 emits: Vec::new(),
597 consumes: Vec::new(),
598 implementation_kind: ImplementationKind::Executable,
599 composability: ComposabilityMetadata {
600 kind: CompositionKind::Atomic,
601 patterns: vec![CompositionPattern::Sequential],
602 provides: Vec::new(),
603 requires: Vec::new(),
604 },
605 artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
606 registered_at: "2026-03-27T00:00:00Z".to_string(),
607 };
608 ResolvedCapability {
609 contract,
610 record,
611 artifact,
612 index_entry,
613 }
614 }
615
616 fn signed_binary(bytes: &[u8]) -> BinaryReference {
617 let signing_key = SigningKey::from_bytes(&[9_u8; 32]);
618 let signature = signing_key.sign(bytes);
619 BinaryReference {
620 format: BinaryFormat::Wasm,
621 location: "unused.wasm".to_string(),
622 signature: Some(ArtifactSignature {
623 scheme: ArtifactSignatureScheme::Ed25519,
624 public_key_hex: Some(hex_encode(signing_key.verifying_key().as_bytes())),
625 signature_hex: Some(hex_encode(&signature.to_bytes())),
626 sigstore_bundle_ref: None,
627 }),
628 }
629 }
630
631 fn unsigned_binary() -> BinaryReference {
632 BinaryReference {
633 format: BinaryFormat::Wasm,
634 location: "unused.wasm".to_string(),
635 signature: None,
636 }
637 }
638
639 fn hex_encode(bytes: &[u8]) -> String {
640 let mut out = String::with_capacity(bytes.len() * 2);
641 for byte in bytes {
642 out.push(char::from(HEX_TABLE[(byte >> 4) as usize]));
643 out.push(char::from(HEX_TABLE[(byte & 0x0f) as usize]));
644 }
645 out
646 }
647
648 #[test]
653 fn local_source_is_always_local_dev_regardless_of_contract_path() {
654 let capability = test_capability(
655 "contracts/approved/comment-draft.json",
656 SourceKind::Local,
657 None,
658 );
659 assert_eq!(
660 artifact_trust_level(&capability),
661 ArtifactTrustLevel::LocalDev
662 );
663 }
664
665 #[test]
666 fn contract_under_contracts_directory_is_published_governed() {
667 let capability = test_capability(
668 "contracts/approved/comment-draft.json",
669 SourceKind::Git,
670 None,
671 );
672 assert_eq!(
673 artifact_trust_level(&capability),
674 ArtifactTrustLevel::PublishedGoverned
675 );
676 }
677
678 #[test]
679 fn contract_outside_any_governed_path_is_local_dev() {
680 let capability = test_capability(
681 "workspaces/ws-test/registry/private/comment-draft@1.0.0/contract.json",
682 SourceKind::Git,
683 None,
684 );
685 assert_eq!(
686 artifact_trust_level(&capability),
687 ArtifactTrustLevel::LocalDev
688 );
689 }
690
691 #[test]
692 fn path_containing_specs_substring_outside_a_governed_prefix_is_not_governed() {
693 let capability = test_capability("my-app/specs/comment-draft.json", SourceKind::Git, None);
697 assert_eq!(
698 artifact_trust_level(&capability),
699 ArtifactTrustLevel::LocalDev
700 );
701 }
702
703 #[test]
704 fn approved_keyword_in_url_and_path_no_longer_spoofs_governed_trust() {
705 let mut capability = test_capability(
709 "workspaces/ws-test/approved/comment-draft.json",
710 SourceKind::Git,
711 None,
712 );
713 capability.artifact.source.location = "https://example.com/not-governed".to_string();
714 assert_eq!(
715 artifact_trust_level(&capability),
716 ArtifactTrustLevel::LocalDev
717 );
718 }
719
720 #[test]
725 fn published_governed_unsigned_artifact_is_rejected_even_in_development_mode() {
726 let capability = test_capability(
727 "contracts/approved/comment-draft.json",
728 SourceKind::Git,
729 Some(unsigned_binary()),
730 );
731 let result = verify_artifact(&capability, b"bytes", &RuntimeSecurityConfig::development());
732 assert!(matches!(
733 result,
734 Err(ArtifactVerificationFailure::MissingSignature(_))
735 ));
736 }
737
738 #[test]
739 fn published_governed_signed_artifact_with_matching_checksum_verifies() {
740 let bytes = b"wasm-bytes";
741 let mut capability = test_capability(
742 "contracts/approved/comment-draft.json",
743 SourceKind::Git,
744 Some(signed_binary(bytes)),
745 );
746 capability.artifact.digests.binary_digest = Some(format!("sha256:{}", sha256_hex(bytes)));
747 let result = verify_artifact(&capability, bytes, &RuntimeSecurityConfig::production());
748 let record = result.expect("signed governed artifact with matching checksum must verify");
749 assert_eq!(record.status, ArtifactVerificationStatus::Verified);
750 assert_eq!(record.trust_level, ArtifactTrustLevel::PublishedGoverned);
751 }
752
753 #[test]
754 fn local_dev_unsigned_artifact_warns_in_development_mode() {
755 let capability = test_capability(
756 "workspaces/ws-test/registry/private/comment-draft@1.0.0/contract.json",
757 SourceKind::Local,
758 Some(unsigned_binary()),
759 );
760 let result = verify_artifact(&capability, b"bytes", &RuntimeSecurityConfig::development());
761 let record =
762 result.expect("unsigned local artifact must be allowed-but-warned in dev mode");
763 assert_eq!(record.status, ArtifactVerificationStatus::Warning);
764 assert_eq!(record.trust_level, ArtifactTrustLevel::LocalDev);
765 assert_eq!(
766 record.warning_code.as_deref(),
767 Some("unsigned_local_dev_artifact")
768 );
769 }
770
771 #[test]
772 fn local_dev_unsigned_artifact_is_rejected_in_production_mode() {
773 let capability = test_capability(
774 "workspaces/ws-test/registry/private/comment-draft@1.0.0/contract.json",
775 SourceKind::Local,
776 Some(unsigned_binary()),
777 );
778 let result = verify_artifact(&capability, b"bytes", &RuntimeSecurityConfig::production());
779 assert!(matches!(
780 result,
781 Err(ArtifactVerificationFailure::MissingSignature(_))
782 ));
783 }
784}