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 };
538 let record = CapabilityRegistryRecord {
539 scope: RegistryScope::Private,
540 id: contract.id.clone(),
541 version: contract.version.clone(),
542 lifecycle: Lifecycle::Active,
543 owner: owner.clone(),
544 contract_path: contract_path.to_string(),
545 contract_digest: "digest".to_string(),
546 implementation_kind: ImplementationKind::Executable,
547 artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
548 registered_at: "2026-03-27T00:00:00Z".to_string(),
549 provenance: RegistryProvenance {
550 source: "test".to_string(),
551 author: "Enrico Piovesan".to_string(),
552 created_at: "2026-03-27T00:00:00Z".to_string(),
553 },
554 evidence: RegistrationEvidence {
555 evidence_id: "evidence".to_string(),
556 artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
557 capability_id: contract.id.clone(),
558 capability_version: contract.version.clone(),
559 scope: RegistryScope::Private,
560 governing_spec: "030-security-identity-model".to_string(),
561 validator_version: "0.1.0".to_string(),
562 produced_at: "2026-03-27T00:00:00Z".to_string(),
563 result: RegistrationResult::Passed,
564 },
565 };
566 let artifact = CapabilityArtifactRecord {
567 artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
568 implementation_kind: ImplementationKind::Executable,
569 source: SourceReference {
570 kind: source_kind,
571 location: "https://github.com/traverse-framework/traverse".to_string(),
572 },
573 binary,
574 workflow_ref: None,
575 digests: ArtifactDigests {
576 source_digest: "src-digest".to_string(),
577 binary_digest: None,
578 },
579 provenance: RegistryProvenance {
580 source: "test".to_string(),
581 author: "Enrico Piovesan".to_string(),
582 created_at: "2026-03-27T00:00:00Z".to_string(),
583 },
584 };
585 let index_entry = DiscoveryIndexEntry {
586 scope: RegistryScope::Private,
587 id: contract.id.clone(),
588 version: contract.version.clone(),
589 lifecycle: Lifecycle::Active,
590 owner,
591 summary: "Create a comment draft for a resource".to_string(),
592 tags: Vec::new(),
593 permissions: Vec::new(),
594 emits: Vec::new(),
595 consumes: Vec::new(),
596 implementation_kind: ImplementationKind::Executable,
597 composability: ComposabilityMetadata {
598 kind: CompositionKind::Atomic,
599 patterns: vec![CompositionPattern::Sequential],
600 provides: Vec::new(),
601 requires: Vec::new(),
602 },
603 artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
604 registered_at: "2026-03-27T00:00:00Z".to_string(),
605 };
606 ResolvedCapability {
607 contract,
608 record,
609 artifact,
610 index_entry,
611 }
612 }
613
614 fn signed_binary(bytes: &[u8]) -> BinaryReference {
615 let signing_key = SigningKey::from_bytes(&[9_u8; 32]);
616 let signature = signing_key.sign(bytes);
617 BinaryReference {
618 format: BinaryFormat::Wasm,
619 location: "unused.wasm".to_string(),
620 signature: Some(ArtifactSignature {
621 scheme: ArtifactSignatureScheme::Ed25519,
622 public_key_hex: Some(hex_encode(signing_key.verifying_key().as_bytes())),
623 signature_hex: Some(hex_encode(&signature.to_bytes())),
624 sigstore_bundle_ref: None,
625 }),
626 }
627 }
628
629 fn unsigned_binary() -> BinaryReference {
630 BinaryReference {
631 format: BinaryFormat::Wasm,
632 location: "unused.wasm".to_string(),
633 signature: None,
634 }
635 }
636
637 fn hex_encode(bytes: &[u8]) -> String {
638 let mut out = String::with_capacity(bytes.len() * 2);
639 for byte in bytes {
640 out.push(char::from(HEX_TABLE[(byte >> 4) as usize]));
641 out.push(char::from(HEX_TABLE[(byte & 0x0f) as usize]));
642 }
643 out
644 }
645
646 #[test]
651 fn local_source_is_always_local_dev_regardless_of_contract_path() {
652 let capability = test_capability(
653 "contracts/approved/comment-draft.json",
654 SourceKind::Local,
655 None,
656 );
657 assert_eq!(
658 artifact_trust_level(&capability),
659 ArtifactTrustLevel::LocalDev
660 );
661 }
662
663 #[test]
664 fn contract_under_contracts_directory_is_published_governed() {
665 let capability = test_capability(
666 "contracts/approved/comment-draft.json",
667 SourceKind::Git,
668 None,
669 );
670 assert_eq!(
671 artifact_trust_level(&capability),
672 ArtifactTrustLevel::PublishedGoverned
673 );
674 }
675
676 #[test]
677 fn contract_outside_any_governed_path_is_local_dev() {
678 let capability = test_capability(
679 "workspaces/ws-test/registry/private/comment-draft@1.0.0/contract.json",
680 SourceKind::Git,
681 None,
682 );
683 assert_eq!(
684 artifact_trust_level(&capability),
685 ArtifactTrustLevel::LocalDev
686 );
687 }
688
689 #[test]
690 fn path_containing_specs_substring_outside_a_governed_prefix_is_not_governed() {
691 let capability = test_capability("my-app/specs/comment-draft.json", SourceKind::Git, None);
695 assert_eq!(
696 artifact_trust_level(&capability),
697 ArtifactTrustLevel::LocalDev
698 );
699 }
700
701 #[test]
702 fn approved_keyword_in_url_and_path_no_longer_spoofs_governed_trust() {
703 let mut capability = test_capability(
707 "workspaces/ws-test/approved/comment-draft.json",
708 SourceKind::Git,
709 None,
710 );
711 capability.artifact.source.location = "https://example.com/not-governed".to_string();
712 assert_eq!(
713 artifact_trust_level(&capability),
714 ArtifactTrustLevel::LocalDev
715 );
716 }
717
718 #[test]
723 fn published_governed_unsigned_artifact_is_rejected_even_in_development_mode() {
724 let capability = test_capability(
725 "contracts/approved/comment-draft.json",
726 SourceKind::Git,
727 Some(unsigned_binary()),
728 );
729 let result = verify_artifact(&capability, b"bytes", &RuntimeSecurityConfig::development());
730 assert!(matches!(
731 result,
732 Err(ArtifactVerificationFailure::MissingSignature(_))
733 ));
734 }
735
736 #[test]
737 fn published_governed_signed_artifact_with_matching_checksum_verifies() {
738 let bytes = b"wasm-bytes";
739 let mut capability = test_capability(
740 "contracts/approved/comment-draft.json",
741 SourceKind::Git,
742 Some(signed_binary(bytes)),
743 );
744 capability.artifact.digests.binary_digest = Some(format!("sha256:{}", sha256_hex(bytes)));
745 let result = verify_artifact(&capability, bytes, &RuntimeSecurityConfig::production());
746 let record = result.expect("signed governed artifact with matching checksum must verify");
747 assert_eq!(record.status, ArtifactVerificationStatus::Verified);
748 assert_eq!(record.trust_level, ArtifactTrustLevel::PublishedGoverned);
749 }
750
751 #[test]
752 fn local_dev_unsigned_artifact_warns_in_development_mode() {
753 let capability = test_capability(
754 "workspaces/ws-test/registry/private/comment-draft@1.0.0/contract.json",
755 SourceKind::Local,
756 Some(unsigned_binary()),
757 );
758 let result = verify_artifact(&capability, b"bytes", &RuntimeSecurityConfig::development());
759 let record =
760 result.expect("unsigned local artifact must be allowed-but-warned in dev mode");
761 assert_eq!(record.status, ArtifactVerificationStatus::Warning);
762 assert_eq!(record.trust_level, ArtifactTrustLevel::LocalDev);
763 assert_eq!(
764 record.warning_code.as_deref(),
765 Some("unsigned_local_dev_artifact")
766 );
767 }
768
769 #[test]
770 fn local_dev_unsigned_artifact_is_rejected_in_production_mode() {
771 let capability = test_capability(
772 "workspaces/ws-test/registry/private/comment-draft@1.0.0/contract.json",
773 SourceKind::Local,
774 Some(unsigned_binary()),
775 );
776 let result = verify_artifact(&capability, b"bytes", &RuntimeSecurityConfig::production());
777 assert!(matches!(
778 result,
779 Err(ArtifactVerificationFailure::MissingSignature(_))
780 ));
781 }
782}