diff --git a/crates/rvm-rvf/src/verify.rs b/crates/rvm-rvf/src/verify.rs index 5d98b329..7e725d39 100644 --- a/crates/rvm-rvf/src/verify.rs +++ b/crates/rvm-rvf/src/verify.rs @@ -153,8 +153,9 @@ impl VerificationReport { #[derive(Debug, Clone, Default)] pub struct VerifyOptions { /// Ed25519 public keys whose signatures are accepted. With none supplied, - /// signature checks are recorded as skipped rather than passed — an - /// unverifiable signature is never treated as a valid one. + /// non-executable signature checks are recorded as skipped rather than + /// passed. Executable signatures without a trust anchor fail strict + /// eligibility: unverifiable code is never treated as verified code. pub trusted_keys: Vec<[u8; 32]>, /// Permit executable segments with no signature. Off by default; exists /// for unsigned development builds, which are labeled as such and are not @@ -395,7 +396,7 @@ fn signature_record(data: &[u8], seg: &ParsedSegment, opts: &VerifyOptions) -> V if footer.sig_algo != SIG_ALGO_ED25519 || footer.sig_length != ED25519_SIGNATURE_LENGTH { return segment_record( CheckKind::Signature, - Outcome::Skip, + signature_incomplete_outcome(seg), seg, DetailCode::UnsupportedSignatureAlgorithm, ); @@ -403,7 +404,7 @@ fn signature_record(data: &[u8], seg: &ParsedSegment, opts: &VerifyOptions) -> V if opts.trusted_keys.is_empty() { return segment_record( CheckKind::Signature, - Outcome::Skip, + signature_incomplete_outcome(seg), seg, DetailCode::NoTrustedKey, ); @@ -422,6 +423,18 @@ fn signature_record(data: &[u8], seg: &ParsedSegment, opts: &VerifyOptions) -> V ) } +/// Missing verifier support may remain informational for signed data, but it +/// is a hard refusal for code. ADR-155 requires every executable segment to be +/// authenticated before load; a signature footer alone proves no publisher +/// identity. +const fn signature_incomplete_outcome(seg: &ParsedSegment) -> Outcome { + if seg.is_executable() { + Outcome::Fail + } else { + Outcome::Skip + } +} + /// Whether any trusted key signed this segment. fn signature_verifies( data: &[u8], diff --git a/crates/rvm-rvf/src/verify_tests.rs b/crates/rvm-rvf/src/verify_tests.rs index 750317f4..e40557ae 100644 --- a/crates/rvm-rvf/src/verify_tests.rs +++ b/crates/rvm-rvf/src/verify_tests.rs @@ -152,8 +152,30 @@ fn a_signature_is_skipped_not_passed_when_no_key_is_trusted() { record_for(&report, CheckKind::Signature).detail, DetailCode::NoTrustedKey ); - // A skip is not a failure, and the executable is signed, so this passes. - assert!(report.ok, "{:?}", report.failures()); + // A distribution verifier without a trust anchor cannot establish who + // signed executable code. Preserve the precise diagnostic, but refuse to + // turn the incomplete check into an execution-eligible report. + assert!(!report.ok); +} + +#[test] +fn an_unsupported_executable_signature_algorithm_is_not_execution_eligible() { + let kp = TestKeypair::deterministic(8); + let mut data = manifest_only(); + data.extend(signed_segment(SEG_TYPE_WASM, b"\0asm", 6, &kp)); + + let segments = crate::container::walk(&data).unwrap(); + let footer = segments + .iter() + .find(|segment| segment.header.seg_type == SEG_TYPE_WASM) + .and_then(|segment| segment.footer.clone()) + .unwrap(); + data[footer.start..footer.start + 2].copy_from_slice(&99u16.to_le_bytes()); + + let report = verify(&data, &VerifyOptions::with_trusted_keys(vec![kp.public])).unwrap(); + let signature = record_for(&report, CheckKind::Signature); + assert_eq!(signature.detail, DetailCode::UnsupportedSignatureAlgorithm); + assert!(!report.ok); } #[test] diff --git a/crates/rvm-rvf/src/witness.rs b/crates/rvm-rvf/src/witness.rs index 9f15a5b1..20f56eb4 100644 --- a/crates/rvm-rvf/src/witness.rs +++ b/crates/rvm-rvf/src/witness.rs @@ -178,8 +178,8 @@ mod tests { fn a_skip_is_recorded_as_escalated_not_as_a_pass() { let kp = TestKeypair::deterministic(7); let mut data = manifest_only(); - data.extend(signed_segment(SEG_TYPE_WASM, b"\0asm", 2, &kp)); - // No trusted key, so the signature check skips. + data.extend(signed_segment(SEG_TYPE_META, b"metadata", 2, &kp)); + // A non-executable signature with no trusted key remains informational. let report = verify(&data, &VerifyOptions::default()).unwrap(); let skipped = report diff --git a/docs/adr/ADR-155-rvf-execution-contract.md b/docs/adr/ADR-155-rvf-execution-contract.md index 209f2639..851a8565 100644 --- a/docs/adr/ADR-155-rvf-execution-contract.md +++ b/docs/adr/ADR-155-rvf-execution-contract.md @@ -2,6 +2,7 @@ **Status**: Accepted **Date**: 2026-08-05 +**Updated**: 2026-08-05 — strict executable verification now requires a supported signature algorithm and at least one configured trusted key; incomplete trust checks are witnessed refusals, not execution-eligible skips (rvm#20). **Authors**: Claude Code **Supersedes**: None **Related**: ADR-132 (Hypervisor Core), ADR-134 (Witness Schema), ADR-135 (Proof Verifier), ADR-140 (Agent Runtime Adapter), ADR-149 (RVF Integration), RuVector ADR-284 (RVF Execution Contract), RuVector ADR-285 (Hosted RVM Security Boundary), RuVector ADR-286 (Capability Schema Mapping), RuVector ADR-287 (WASM Component Model), RuVector ADR-291 (Runtime Compatibility and Version Negotiation) @@ -119,6 +120,10 @@ inspection and packaging tooling: independently against its manifest-declared hash. 3. **Reject unsigned executable segments by default.** A model or data segment may be policy-permitted unsigned; an executable one may not. + A signed executable is also rejected when no configured trust anchor can + authenticate it, or when its signature algorithm is unsupported. These + conditions retain precise witness diagnostics but never produce an + execution-eligible verification report. 4. **Never execute RVF content during inspection or packaging.** `rvm inspect` and `rvm verify` are first-class operations distinct from `rvm run` precisely so that pointing a scanner at a hostile artifact is safe. This is the runtime