1use std::collections::{BTreeMap, BTreeSet};
3use std::ffi::OsString;
4use std::fmt;
5use std::fs;
6use std::io::{self, Read};
7use std::path::{Path, PathBuf};
8
9use base64::Engine;
10use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
11use ring::signature::{ED25519, UnparsedPublicKey};
12use runx_contracts::{Receipt, Reference, ReferenceType, sha256_prefixed};
13use runx_receipts::{
14 ReceiptProofContext, ReceiptVerifySignatureMode, ReceiptVerifyVerdict,
15 SignatureVerificationFailure, SignatureVerifier, verify_receipt_document_verdict,
16};
17use runx_runtime::{
18 Ed25519ReceiptVerifier, ReceiptPathInputs, ReceiptTreeConfig, RuntimeReceiptConfig,
19 RuntimeReceiptSignaturePolicy, resolve_receipt_path, verify_runtime_receipt_tree_with_policy,
20};
21use serde::Serialize;
22
23use crate::history::{
24 RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV, RUNX_RECEIPT_VERIFY_KID_ENV,
25};
26
27const RECEIPT_REFERENCE_PREFIX: &str = "runx:receipt:";
28const SINGLE_RECEIPT_MAX_BYTES: usize = 10 * 1024 * 1024;
29
30#[derive(Debug)]
31pub enum VerifyCliError {
32 InvalidArgs(String),
33 InvalidReceiptVerifier(String),
34 Store(String),
35 Serialize(serde_json::Error),
36}
37
38impl fmt::Display for VerifyCliError {
39 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40 match self {
41 Self::InvalidArgs(message) | Self::InvalidReceiptVerifier(message) => {
42 formatter.write_str(message)
43 }
44 Self::Store(message) => formatter.write_str(message),
45 Self::Serialize(error) => write!(formatter, "failed to serialize report: {error}"),
46 }
47 }
48}
49
50impl std::error::Error for VerifyCliError {}
51
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct VerifyCliResult {
54 pub output: String,
55 pub failed: bool,
56}
57
58#[derive(Clone, Debug, Default, PartialEq, Eq)]
59struct ParsedVerifyArgs {
60 receipt_id: Option<String>,
61 receipt_dir: Option<PathBuf>,
62 receipt: Option<ReceiptInput>,
63 notary: Option<ReceiptInput>,
64 notary_keys: Vec<PathBuf>,
65 allow_local_development_signatures: bool,
66 json: bool,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
70enum ReceiptInput {
71 Path(PathBuf),
72 Stdin,
73}
74
75#[derive(Clone, Debug, Serialize)]
76struct VerifyReport {
77 receipt_dir: String,
78 signature_mode: &'static str,
79 trees: Vec<TreeReport>,
80 unreadable_files: Vec<FileIssue>,
81 valid: bool,
82}
83
84#[derive(Clone, Debug, Serialize)]
85struct TreeReport {
86 root_receipt_id: String,
87 receipt_count: usize,
88 parent_missing: Option<String>,
89 valid: bool,
90 findings: Vec<FindingReport>,
91}
92
93#[derive(Clone, Debug, Serialize)]
94struct FindingReport {
95 code: String,
96 path: String,
97 message: String,
98}
99
100#[derive(Clone, Debug, Serialize)]
101struct FileIssue {
102 file: String,
103 message: String,
104}
105
106#[derive(Clone, Debug, Serialize)]
107struct NotaryVerifyVerdict {
108 schema: &'static str,
109 valid: bool,
110 counter_seal: NotaryCounterSealReport,
111 findings: Vec<FindingReport>,
112}
113
114#[derive(Clone, Debug, Serialize)]
115struct NotaryCounterSealReport {
116 schema: Option<String>,
117 digest_status: &'static str,
118 signature_status: &'static str,
119 trusted_key_count: usize,
120}
121
122pub fn run_verify_command(
123 args: &[OsString],
124 env: &BTreeMap<String, String>,
125 cwd: &Path,
126) -> Result<VerifyCliResult, VerifyCliError> {
127 run_verify_command_with_stdin(args, env, cwd, io::empty())
128}
129
130pub fn run_verify_command_with_stdin<R: Read>(
132 args: &[OsString],
133 env: &BTreeMap<String, String>,
134 cwd: &Path,
135 stdin: R,
136) -> Result<VerifyCliResult, VerifyCliError> {
137 let parsed = parse_verify_args(args)?;
138 if let Some(input) = parsed.notary.as_ref() {
139 return run_notary_verify(input, &parsed.notary_keys, parsed.json, cwd, stdin);
140 }
141 if let Some(input) = parsed.receipt.as_ref() {
142 return run_single_receipt_verify(
143 input,
144 parsed.json,
145 parsed.allow_local_development_signatures,
146 env,
147 cwd,
148 stdin,
149 );
150 }
151 let receipt_config = RuntimeReceiptConfig::default();
152 let resolved = resolve_receipt_path(ReceiptPathInputs {
153 explicit_dir: parsed.receipt_dir.as_deref(),
154 runtime_config: Some(&receipt_config),
155 env,
156 cwd,
157 });
158 let verifier = receipt_verifier(env, parsed.allow_local_development_signatures)?;
159 let signature_mode = if verifier.is_some() {
160 "production"
161 } else {
162 "local-development"
163 };
164
165 let (receipts, unreadable_files) = load_receipts(&resolved.path)?;
166 let trees = group_trees(&receipts);
167
168 let selected: Vec<&ReceiptTree> = match parsed.receipt_id.as_deref() {
169 Some(receipt_id) => {
170 let tree = trees
171 .iter()
172 .find(|tree| tree.member_ids.contains(receipt_id))
173 .ok_or_else(|| {
174 VerifyCliError::InvalidArgs(format!(
175 "receipt {receipt_id} was not found in {}",
176 resolved.path.display()
177 ))
178 })?;
179 vec![tree]
180 }
181 None => trees.iter().collect(),
182 };
183
184 let mut tree_reports = Vec::new();
185 for tree in selected {
186 let policy = match verifier.as_ref() {
187 Some(verifier) => RuntimeReceiptSignaturePolicy::production(verifier),
188 None => RuntimeReceiptSignaturePolicy::local_development(),
189 };
190 let members: Vec<Receipt> = tree
193 .member_ids
194 .iter()
195 .filter(|id| id.as_str() != tree.root.id.as_str())
196 .filter_map(|id| receipts.iter().find(|receipt| receipt.id.as_str() == id))
197 .cloned()
198 .collect();
199 let verification = verify_runtime_receipt_tree_with_policy(
200 &tree.root,
201 members,
202 ReceiptTreeConfig::default(),
203 policy,
204 );
205 let valid = verification.valid && tree.parent_missing.is_none();
206 tree_reports.push(TreeReport {
207 root_receipt_id: tree.root.id.to_string(),
208 receipt_count: tree.member_ids.len(),
209 parent_missing: tree.parent_missing.clone(),
210 valid,
211 findings: verification
212 .findings
213 .into_iter()
214 .map(|finding| FindingReport {
215 code: format!("{:?}", finding.code),
216 path: finding.path,
217 message: finding.message,
218 })
219 .collect(),
220 });
221 }
222
223 let valid = tree_reports.iter().all(|tree| tree.valid) && unreadable_files.is_empty();
224 let report = VerifyReport {
225 receipt_dir: resolved.path.display().to_string(),
226 signature_mode,
227 trees: tree_reports,
228 unreadable_files,
229 valid,
230 };
231
232 let output = if parsed.json {
233 format!(
234 "{}\n",
235 serde_json::to_string_pretty(&report).map_err(VerifyCliError::Serialize)?
236 )
237 } else {
238 render_report(&report)
239 };
240 Ok(VerifyCliResult {
241 output,
242 failed: !report.valid,
243 })
244}
245
246fn parse_verify_args(args: &[OsString]) -> Result<ParsedVerifyArgs, VerifyCliError> {
249 let mut parsed = ParsedVerifyArgs::default();
250 let mut iter = args.iter().skip(1);
251 while let Some(arg) = iter.next() {
252 let Some(text) = arg.to_str() else {
253 return Err(invalid_args("arguments must be valid UTF-8"));
254 };
255 match text {
256 "--json" | "-j" => parsed.json = true,
257 "--allow-local-development-signatures" => {
258 parsed.allow_local_development_signatures = true;
259 }
260 "--receipt-dir" => {
261 let value = iter
262 .next()
263 .ok_or_else(|| invalid_args("--receipt-dir requires a directory"))?;
264 parsed.receipt_dir = Some(PathBuf::from(value));
265 }
266 "--receipt" => {
267 let value = iter
268 .next()
269 .ok_or_else(|| invalid_args("--receipt requires a path or -"))?;
270 parsed.receipt = Some(parse_receipt_input(value)?);
271 }
272 "--notary" => {
273 let value = iter
274 .next()
275 .ok_or_else(|| invalid_args("--notary requires a path or -"))?;
276 parsed.notary = Some(parse_receipt_input(value)?);
277 }
278 "--notary-key" => {
279 let value = iter.next().ok_or_else(|| {
280 invalid_args("--notary-key requires a trusted public key PEM path")
281 })?;
282 parsed.notary_keys.push(PathBuf::from(value));
283 }
284 other if other.starts_with("--receipt=") => {
285 let value = other.trim_start_matches("--receipt=");
286 parsed.receipt = Some(parse_receipt_input_text(value)?);
287 }
288 other if other.starts_with("--notary=") => {
289 let value = other.trim_start_matches("--notary=");
290 parsed.notary = Some(parse_receipt_input_text(value)?);
291 }
292 other if other.starts_with("--notary-key=") => {
293 let value = other.trim_start_matches("--notary-key=");
294 if value.is_empty() {
295 return Err(invalid_args(
296 "--notary-key requires a trusted public key PEM path",
297 ));
298 }
299 parsed.notary_keys.push(PathBuf::from(value));
300 }
301 other if other.starts_with("--") => {
302 return Err(invalid_args(format!("unknown verify flag {other}")));
303 }
304 other => {
305 if parsed.receipt_id.is_some() {
306 return Err(invalid_args("verify accepts at most one receipt id"));
307 }
308 parsed.receipt_id = Some(other.to_owned());
309 }
310 }
311 }
312 if parsed.receipt.is_some() && (parsed.receipt_id.is_some() || parsed.receipt_dir.is_some()) {
313 return Err(invalid_args(
314 "--receipt cannot be combined with a receipt id or --receipt-dir",
315 ));
316 }
317 if parsed.notary.is_some()
318 && (parsed.receipt.is_some() || parsed.receipt_id.is_some() || parsed.receipt_dir.is_some())
319 {
320 return Err(invalid_args(
321 "--notary cannot be combined with --receipt, a receipt id, or --receipt-dir",
322 ));
323 }
324 if parsed.notary.is_none() && !parsed.notary_keys.is_empty() {
325 return Err(invalid_args("--notary-key requires --notary"));
326 }
327 if parsed.notary.is_some() && parsed.notary_keys.is_empty() {
328 return Err(invalid_args(
329 "--notary requires at least one external trusted public key via --notary-key",
330 ));
331 }
332 if parsed.notary.is_some() && parsed.allow_local_development_signatures {
333 return Err(invalid_args(
334 "--allow-local-development-signatures is only valid for receipt verification",
335 ));
336 }
337 Ok(parsed)
338}
339
340fn parse_receipt_input(value: &OsString) -> Result<ReceiptInput, VerifyCliError> {
341 let Some(text) = value.to_str() else {
342 return Err(invalid_args("--receipt path must be valid UTF-8"));
343 };
344 parse_receipt_input_text(text)
345}
346
347fn parse_receipt_input_text(value: &str) -> Result<ReceiptInput, VerifyCliError> {
348 if value.is_empty() {
349 return Err(invalid_args("--receipt requires a path or -"));
350 }
351 Ok(if value == "-" {
352 ReceiptInput::Stdin
353 } else {
354 ReceiptInput::Path(PathBuf::from(value))
355 })
356}
357
358fn run_single_receipt_verify<R: Read>(
359 input: &ReceiptInput,
360 json: bool,
361 allow_local_development_signatures: bool,
362 env: &BTreeMap<String, String>,
363 cwd: &Path,
364 stdin: R,
365) -> Result<VerifyCliResult, VerifyCliError> {
366 let document = read_single_receipt_input(input, cwd, stdin)?;
367 let verifier = if serde_json::from_slice::<Receipt>(&document).is_ok() {
371 receipt_verifier(env, allow_local_development_signatures)?
372 } else {
373 None
374 };
375 let local_verifier = LocalDevelopmentReceiptVerifier;
376 let (signature_mode, signature_verifier): (ReceiptVerifySignatureMode, &dyn SignatureVerifier) =
377 match verifier.as_ref() {
378 Some(verifier) => (ReceiptVerifySignatureMode::Production, verifier),
379 None => (
380 ReceiptVerifySignatureMode::LocalDevelopment,
381 &local_verifier,
382 ),
383 };
384 let context = ReceiptProofContext {
385 signature_verifier: Some(signature_verifier),
386 authority_verified: false,
387 external_attestations_verified: false,
388 verified_redaction_refs: BTreeSet::new(),
389 verified_hash_commitments: BTreeSet::new(),
390 };
391 let verdict = verify_receipt_document_verdict(&document, &context, signature_mode);
392 let output = if json {
393 format!(
394 "{}\n",
395 serde_json::to_string_pretty(&verdict).map_err(VerifyCliError::Serialize)?
396 )
397 } else {
398 render_single_receipt_verdict(&verdict)
399 };
400 Ok(VerifyCliResult {
401 output,
402 failed: !verdict.valid,
403 })
404}
405
406fn resolve_under_cwd(path: &Path, cwd: &Path) -> PathBuf {
409 if path.is_absolute() {
410 path.to_path_buf()
411 } else {
412 cwd.join(path)
413 }
414}
415
416fn read_single_receipt_input<R: Read>(
417 input: &ReceiptInput,
418 cwd: &Path,
419 stdin: R,
420) -> Result<Vec<u8>, VerifyCliError> {
421 match input {
422 ReceiptInput::Path(path) => {
423 let path = resolve_under_cwd(path, cwd);
424 if let Ok(metadata) = fs::metadata(&path) {
425 if metadata.len() > SINGLE_RECEIPT_MAX_BYTES as u64 {
426 return Err(single_receipt_too_large());
427 }
428 }
429 let document = fs::read(&path).map_err(|error| {
430 VerifyCliError::Store(format!(
431 "failed to read receipt {}: {error}",
432 path.display()
433 ))
434 })?;
435 if document.len() > SINGLE_RECEIPT_MAX_BYTES {
436 return Err(single_receipt_too_large());
437 }
438 Ok(document)
439 }
440 ReceiptInput::Stdin => read_limited_stdin(stdin),
441 }
442}
443
444fn read_limited_stdin<R: Read>(stdin: R) -> Result<Vec<u8>, VerifyCliError> {
445 let mut limited = stdin.take((SINGLE_RECEIPT_MAX_BYTES + 1) as u64);
446 let mut document = Vec::new();
447 limited.read_to_end(&mut document).map_err(|error| {
448 VerifyCliError::Store(format!("failed to read receipt from stdin: {error}"))
449 })?;
450 if document.len() > SINGLE_RECEIPT_MAX_BYTES {
451 return Err(single_receipt_too_large());
452 }
453 Ok(document)
454}
455
456fn run_notary_verify<R: Read>(
457 input: &ReceiptInput,
458 trusted_key_paths: &[PathBuf],
459 json: bool,
460 cwd: &Path,
461 stdin: R,
462) -> Result<VerifyCliResult, VerifyCliError> {
463 let document = read_single_receipt_input(input, cwd, stdin)?;
464 let trusted_keys = trusted_notary_keys_from_paths(trusted_key_paths, cwd)?;
465 let verdict = verify_notary_document(&document, &trusted_keys);
466 let output = if json {
467 format!(
468 "{}\n",
469 serde_json::to_string_pretty(&verdict).map_err(VerifyCliError::Serialize)?
470 )
471 } else {
472 render_notary_verdict(&verdict)
473 };
474 Ok(VerifyCliResult {
475 output,
476 failed: !verdict.valid,
477 })
478}
479
480fn verify_notary_document(document: &[u8], trusted_keys: &[Vec<u8>]) -> NotaryVerifyVerdict {
483 let mut findings = Vec::new();
484 let root = match serde_json::from_slice::<serde_json::Value>(document) {
485 Ok(value) => value,
486 Err(error) => {
487 findings.push(finding(
488 "notary_parse_error",
489 "$",
490 format!("notary verification document is not valid JSON: {error}"),
491 ));
492 return notary_verdict(None, "missing", "missing", 0, findings);
493 }
494 };
495 let Some(notary) = locate_notary_verification(&root) else {
496 findings.push(finding(
497 "notary_verification_missing",
498 "$",
499 "notary verification document must contain notary_verification or receipt.notary_verification",
500 ));
501 return notary_verdict(None, "missing", "missing", 0, findings);
502 };
503 let Some(counter_seal) = notary
504 .get("counter_seal")
505 .and_then(serde_json::Value::as_object)
506 else {
507 findings.push(finding(
508 "counter_seal_missing",
509 "notary_verification.counter_seal",
510 "notary verification is missing counter_seal",
511 ));
512 return notary_verdict(None, "missing", "missing", trusted_keys.len(), findings);
513 };
514 let schema = counter_seal
515 .get("schema")
516 .and_then(serde_json::Value::as_str)
517 .map(ToOwned::to_owned);
518 let Some(payload) = counter_seal.get("payload") else {
519 findings.push(finding(
520 "counter_seal_payload_missing",
521 "notary_verification.counter_seal.payload",
522 "counter seal is missing its signed payload",
523 ));
524 return notary_verdict(schema, "missing", "missing", trusted_keys.len(), findings);
525 };
526 let canonical_payload = match serde_json::to_string(payload) {
527 Ok(value) => value,
528 Err(error) => {
529 findings.push(finding(
530 "counter_seal_payload_invalid",
531 "notary_verification.counter_seal.payload",
532 format!("counter seal payload cannot be canonicalized: {error}"),
533 ));
534 return notary_verdict(schema, "invalid", "missing", trusted_keys.len(), findings);
535 }
536 };
537 let expected_digest = sha256_prefixed(canonical_payload.as_bytes());
538 let digest_status = match counter_seal
539 .get("digest")
540 .and_then(serde_json::Value::as_str)
541 {
542 Some(actual) if actual == expected_digest => "valid",
543 Some(_) => {
544 findings.push(finding(
545 "counter_seal_digest_mismatch",
546 "notary_verification.counter_seal.digest",
547 "counter seal digest does not match the canonical payload",
548 ));
549 "invalid"
550 }
551 None => {
552 findings.push(finding(
553 "counter_seal_digest_missing",
554 "notary_verification.counter_seal.digest",
555 "counter seal is missing digest",
556 ));
557 "missing"
558 }
559 };
560 bind_counter_seal_to_projection(&root, payload, counter_seal, &mut findings);
561 let signature_status = verify_counter_seal_signature(
562 notary,
563 counter_seal,
564 &canonical_payload,
565 trusted_keys,
566 &mut findings,
567 );
568 let trusted_key_count = trusted_keys.len();
569 notary_verdict(
570 schema,
571 digest_status,
572 signature_status,
573 trusted_key_count,
574 findings,
575 )
576}
577
578fn locate_notary_verification(
579 root: &serde_json::Value,
580) -> Option<&serde_json::Map<String, serde_json::Value>> {
581 root.get("receipt")
582 .and_then(|receipt| receipt.get("notary_verification"))
583 .or_else(|| root.get("notary_verification"))
584 .and_then(serde_json::Value::as_object)
585}
586
587fn verify_counter_seal_signature(
590 notary: &serde_json::Map<String, serde_json::Value>,
591 counter_seal: &serde_json::Map<String, serde_json::Value>,
592 canonical_payload: &str,
593 trusted_keys: &[Vec<u8>],
594 findings: &mut Vec<FindingReport>,
595) -> &'static str {
596 let Some(signature) = counter_seal
597 .get("signature")
598 .and_then(serde_json::Value::as_object)
599 else {
600 findings.push(finding(
601 "counter_seal_signature_missing",
602 "notary_verification.counter_seal.signature",
603 "counter seal is missing signature",
604 ));
605 return "missing";
606 };
607 if signature.get("alg").and_then(serde_json::Value::as_str) != Some("Ed25519") {
608 findings.push(finding(
609 "counter_seal_signature_algorithm_unsupported",
610 "notary_verification.counter_seal.signature.alg",
611 "counter seal signature algorithm must be Ed25519",
612 ));
613 return "invalid";
614 }
615 let Some(signature_value) = signature.get("value").and_then(serde_json::Value::as_str) else {
616 findings.push(finding(
617 "counter_seal_signature_value_missing",
618 "notary_verification.counter_seal.signature.value",
619 "counter seal signature value is missing",
620 ));
621 return "missing";
622 };
623 let signature_bytes = match decode_signature(signature_value) {
624 Ok(bytes) if bytes.len() == 64 => bytes,
625 Ok(_) | Err(_) => {
626 findings.push(finding(
627 "counter_seal_signature_malformed",
628 "notary_verification.counter_seal.signature.value",
629 "counter seal signature is not a valid Ed25519 signature",
630 ));
631 return "invalid";
632 }
633 };
634 if trusted_keys.is_empty() {
635 findings.push(finding(
636 "notary_trusted_key_missing",
637 "--notary-key",
638 "notary verification requires at least one external trusted Ed25519 public key",
639 ));
640 return "missing";
641 }
642 record_embedded_key_mismatch(notary, trusted_keys, findings);
643 if trusted_keys.iter().any(|key| {
644 UnparsedPublicKey::new(&ED25519, key)
645 .verify(canonical_payload.as_bytes(), &signature_bytes)
646 .is_ok()
647 }) {
648 return "valid";
649 }
650 findings.push(finding(
651 "counter_seal_signature_mismatch",
652 "notary_verification.counter_seal.signature",
653 "counter seal signature did not verify against any external trusted notary key",
654 ));
655 "invalid"
656}
657
658fn trusted_notary_keys_from_paths(
659 paths: &[PathBuf],
660 cwd: &Path,
661) -> Result<Vec<Vec<u8>>, VerifyCliError> {
662 let mut keys = Vec::with_capacity(paths.len());
663 for path in paths {
664 let path = resolve_under_cwd(path, cwd);
665 let pem = fs::read_to_string(&path).map_err(|error| {
666 VerifyCliError::Store(format!(
667 "failed to read notary key {}: {error}",
668 path.display()
669 ))
670 })?;
671 keys.push(ed25519_public_key_from_spki_pem(&pem).map_err(|message| {
672 VerifyCliError::InvalidReceiptVerifier(format!(
673 "invalid notary key {}: {message}",
674 path.display()
675 ))
676 })?);
677 }
678 Ok(keys)
679}
680
681fn embedded_notary_keys(
682 notary: &serde_json::Map<String, serde_json::Value>,
683 findings: &mut Vec<FindingReport>,
684) -> Vec<Vec<u8>> {
685 let Some(keys) = notary
686 .get("signer_public_keys")
687 .and_then(serde_json::Value::as_array)
688 else {
689 return Vec::new();
690 };
691 let mut decoded = Vec::new();
692 for (index, key) in keys.iter().enumerate() {
693 let Some(pem) = key
694 .get("public_key_pem")
695 .and_then(serde_json::Value::as_str)
696 else {
697 findings.push(finding(
698 "notary_trusted_key_missing_pem",
699 format!("notary_verification.signer_public_keys[{index}].public_key_pem"),
700 "trusted notary key is missing public_key_pem",
701 ));
702 continue;
703 };
704 match ed25519_public_key_from_spki_pem(pem) {
705 Ok(raw) => decoded.push(raw),
706 Err(message) => findings.push(finding(
707 "notary_trusted_key_malformed",
708 format!("notary_verification.signer_public_keys[{index}].public_key_pem"),
709 message,
710 )),
711 }
712 }
713 decoded
714}
715
716fn record_embedded_key_mismatch(
717 notary: &serde_json::Map<String, serde_json::Value>,
718 trusted_keys: &[Vec<u8>],
719 findings: &mut Vec<FindingReport>,
720) {
721 let embedded = embedded_notary_keys(notary, findings);
722 if embedded.is_empty() {
723 return;
724 }
725 let has_trusted_embedded_key = embedded
726 .iter()
727 .any(|candidate| trusted_keys.iter().any(|trusted| trusted == candidate));
728 if !has_trusted_embedded_key {
729 findings.push(finding(
730 "notary_embedded_key_untrusted",
731 "notary_verification.signer_public_keys",
732 "embedded notary keys do not include any externally trusted key",
733 ));
734 }
735}
736
737fn bind_counter_seal_to_projection(
738 root: &serde_json::Value,
739 payload: &serde_json::Value,
740 counter_seal: &serde_json::Map<String, serde_json::Value>,
741 findings: &mut Vec<FindingReport>,
742) {
743 let Some(receipt) = root.get("receipt").and_then(serde_json::Value::as_object) else {
744 return;
745 };
746 require_matching_text(
747 receipt,
748 "digest",
749 payload,
750 "digest",
751 "receipt.digest",
752 "notary_verification.counter_seal.payload.digest",
753 findings,
754 );
755 require_matching_text(
756 receipt,
757 "mode",
758 payload,
759 "mode",
760 "receipt.mode",
761 "notary_verification.counter_seal.payload.mode",
762 findings,
763 );
764 require_matching_text(
765 receipt,
766 "binary_version",
767 payload,
768 "binary_version",
769 "receipt.binary_version",
770 "notary_verification.counter_seal.payload.binary_version",
771 findings,
772 );
773 if let Some(projected_payload_digest) = counter_seal
774 .get("payload_digest")
775 .and_then(serde_json::Value::as_str)
776 {
777 match serde_json::to_string(payload) {
778 Ok(canonical_payload) => {
779 let actual_payload_digest = sha256_prefixed(canonical_payload.as_bytes());
780 if projected_payload_digest != actual_payload_digest {
781 findings.push(finding(
782 "counter_seal_payload_digest_mismatch",
783 "notary_verification.counter_seal.payload_digest",
784 "projected counter-seal payload digest does not match the signed payload",
785 ));
786 }
787 }
788 Err(error) => findings.push(finding(
789 "counter_seal_payload_digest_invalid",
790 "notary_verification.counter_seal.payload_digest",
791 format!("projected counter-seal payload cannot be canonicalized: {error}"),
792 )),
793 }
794 }
795}
796
797fn require_matching_text(
798 left: &serde_json::Map<String, serde_json::Value>,
799 left_key: &str,
800 right: &serde_json::Value,
801 right_key: &str,
802 left_path: &str,
803 right_path: &str,
804 findings: &mut Vec<FindingReport>,
805) {
806 let left_value = left.get(left_key).and_then(serde_json::Value::as_str);
807 let right_value = right.get(right_key).and_then(serde_json::Value::as_str);
808 if left_value != right_value {
809 findings.push(finding(
810 "notary_projection_binding_mismatch",
811 format!("{left_path} <-> {right_path}"),
812 "public projection field does not match the signed notary payload",
813 ));
814 }
815}
816
817fn ed25519_public_key_from_spki_pem(pem: &str) -> Result<Vec<u8>, String> {
818 const ED25519_SPKI_PREFIX: &[u8] = &[
819 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
820 ];
821 let body = pem
822 .lines()
823 .map(str::trim)
824 .filter(|line| !line.starts_with("-----BEGIN ") && !line.starts_with("-----END "))
825 .collect::<String>();
826 let der = STANDARD
827 .decode(body)
828 .map_err(|_| "trusted notary key PEM is not valid base64".to_owned())?;
829 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(ED25519_SPKI_PREFIX) {
830 return Err("trusted notary key PEM is not an Ed25519 SPKI public key".to_owned());
831 }
832 Ok(der[ED25519_SPKI_PREFIX.len()..].to_vec())
833}
834
835fn decode_signature(value: &str) -> Result<Vec<u8>, base64::DecodeError> {
836 let encoded = value.strip_prefix("base64:").unwrap_or(value);
837 URL_SAFE_NO_PAD
838 .decode(encoded)
839 .or_else(|_| STANDARD.decode(encoded))
840}
841
842fn notary_verdict(
843 schema: Option<String>,
844 digest_status: &'static str,
845 signature_status: &'static str,
846 trusted_key_count: usize,
847 findings: Vec<FindingReport>,
848) -> NotaryVerifyVerdict {
849 NotaryVerifyVerdict {
850 schema: "runx.notary_verify_verdict.v1",
851 valid: findings.is_empty() && digest_status == "valid" && signature_status == "valid",
852 counter_seal: NotaryCounterSealReport {
853 schema,
854 digest_status,
855 signature_status,
856 trusted_key_count,
857 },
858 findings,
859 }
860}
861
862fn finding(
863 code: impl Into<String>,
864 path: impl Into<String>,
865 message: impl Into<String>,
866) -> FindingReport {
867 FindingReport {
868 code: code.into(),
869 path: path.into(),
870 message: message.into(),
871 }
872}
873
874fn render_notary_verdict(verdict: &NotaryVerifyVerdict) -> String {
875 let mut output = String::new();
876 output.push_str(&format!(
877 "notary counter-seal: {}\n",
878 if verdict.valid { "ok" } else { "INVALID" }
879 ));
880 output.push_str(&format!(
881 "digest: {}\nsignature: {}\ntrusted keys: {}\n",
882 verdict.counter_seal.digest_status,
883 verdict.counter_seal.signature_status,
884 verdict.counter_seal.trusted_key_count
885 ));
886 for finding in &verdict.findings {
887 output.push_str(&format!(
888 "finding {} at {}: {}\n",
889 finding.code, finding.path, finding.message
890 ));
891 }
892 output
893}
894
895fn production_verifier(
896 env: &BTreeMap<String, String>,
897) -> Result<Option<Ed25519ReceiptVerifier>, VerifyCliError> {
898 let kid = non_empty_env(env, RUNX_RECEIPT_VERIFY_KID_ENV);
899 let public_key = non_empty_env(env, RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV);
900 match (kid, public_key) {
901 (None, None) => Ok(None),
902 (Some(kid), Some(public_key)) => {
903 Ed25519ReceiptVerifier::from_public_key_base64(kid.to_owned(), public_key)
904 .map(Some)
905 .map_err(|_| {
906 VerifyCliError::InvalidReceiptVerifier(format!(
907 "{RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV} is not valid Ed25519 public key material"
908 ))
909 })
910 }
911 _ => Err(VerifyCliError::InvalidReceiptVerifier(format!(
912 "set both {RUNX_RECEIPT_VERIFY_KID_ENV} and {RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV} for production verification"
913 ))),
914 }
915}
916
917fn receipt_verifier(
918 env: &BTreeMap<String, String>,
919 allow_local_development_signatures: bool,
920) -> Result<Option<Ed25519ReceiptVerifier>, VerifyCliError> {
921 let verifier = production_verifier(env)?;
922 if verifier.is_none() && !allow_local_development_signatures {
923 return Err(VerifyCliError::InvalidReceiptVerifier(format!(
924 "runx verify requires trusted receipt verification keys. Set both {RUNX_RECEIPT_VERIFY_KID_ENV} and {RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV}, or pass --allow-local-development-signatures for local fixture receipts only."
925 )));
926 }
927 Ok(verifier)
928}
929
930fn load_receipts(root: &Path) -> Result<(Vec<Receipt>, Vec<FileIssue>), VerifyCliError> {
931 let mut receipts = Vec::new();
932 let mut issues = Vec::new();
933 let entries = match fs::read_dir(root) {
934 Ok(entries) => entries,
935 Err(error) => {
936 return Err(VerifyCliError::Store(format!(
937 "failed to read receipt dir {}: {error}",
938 root.display()
939 )));
940 }
941 };
942 for entry in entries {
943 let entry = entry.map_err(|error| {
944 VerifyCliError::Store(format!(
945 "failed to read receipt dir {}: {error}",
946 root.display()
947 ))
948 })?;
949 let path = entry.path();
950 if path.extension().and_then(|value| value.to_str()) != Some("json")
951 || path.file_name().and_then(|value| value.to_str()) == Some("index.json")
952 {
953 continue;
954 }
955 match fs::read_to_string(&path) {
956 Ok(contents) => match serde_json::from_str::<Receipt>(&contents) {
957 Ok(receipt) => receipts.push(receipt),
958 Err(error) => issues.push(FileIssue {
959 file: path.display().to_string(),
960 message: format!("not a valid receipt: {error}"),
961 }),
962 },
963 Err(error) => issues.push(FileIssue {
964 file: path.display().to_string(),
965 message: format!("unreadable: {error}"),
966 }),
967 }
968 }
969 receipts.sort_by(|left, right| left.id.cmp(&right.id));
970 Ok((receipts, issues))
971}
972
973#[derive(Clone, Debug)]
974struct ReceiptTree {
975 root: Receipt,
976 member_ids: BTreeSet<String>,
977 parent_missing: Option<String>,
981}
982
983fn group_trees(receipts: &[Receipt]) -> Vec<ReceiptTree> {
984 let by_id: BTreeMap<&str, &Receipt> = receipts
985 .iter()
986 .map(|receipt| (receipt.id.as_str(), receipt))
987 .collect();
988
989 let mut trees: BTreeMap<String, ReceiptTree> = BTreeMap::new();
990 for receipt in receipts {
991 let (root_id, parent_missing) = resolve_root(receipt, &by_id);
992 let root = by_id
993 .get(root_id.as_str())
994 .copied()
995 .unwrap_or(receipt)
996 .clone();
997 let tree = trees.entry(root_id).or_insert_with(|| ReceiptTree {
998 root,
999 member_ids: BTreeSet::new(),
1000 parent_missing: None,
1001 });
1002 tree.member_ids.insert(receipt.id.to_string());
1003 if let Some(missing) = parent_missing {
1004 tree.parent_missing.get_or_insert(missing);
1005 }
1006 }
1007 trees.into_values().collect()
1008}
1009
1010fn resolve_root(receipt: &Receipt, by_id: &BTreeMap<&str, &Receipt>) -> (String, Option<String>) {
1013 let mut current = receipt;
1014 let mut seen = BTreeSet::new();
1015 loop {
1016 if !seen.insert(current.id.to_string()) {
1017 return (receipt.id.to_string(), None);
1020 }
1021 let Some(parent_id) = current
1022 .lineage
1023 .as_ref()
1024 .and_then(|lineage| lineage.parent.as_ref())
1025 .and_then(referenced_receipt_id)
1026 else {
1027 return (current.id.to_string(), None);
1028 };
1029 match by_id.get(parent_id) {
1030 Some(parent) => current = parent,
1031 None => return (current.id.to_string(), Some(parent_id.to_owned())),
1032 }
1033 }
1034}
1035
1036fn referenced_receipt_id(reference: &Reference) -> Option<&str> {
1037 if reference.reference_type != ReferenceType::Receipt {
1038 return None;
1039 }
1040 reference
1041 .uri
1042 .strip_prefix(RECEIPT_REFERENCE_PREFIX)
1043 .filter(|id| !id.is_empty())
1044}
1045
1046fn render_report(report: &VerifyReport) -> String {
1047 let mut output = String::new();
1048 output.push_str(&format!(
1049 "receipt dir: {}\nsignature mode: {}\n",
1050 report.receipt_dir, report.signature_mode
1051 ));
1052 if report.signature_mode == "local-development" {
1053 output.push_str(
1054 "note: local-development signatures were accepted only because --allow-local-development-signatures was set; set RUNX_RECEIPT_VERIFY_KID and RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64 to verify production signatures\n",
1055 );
1056 }
1057 if report.trees.is_empty() {
1058 output.push_str("no receipts found\n");
1059 }
1060 for tree in &report.trees {
1061 let status = if tree.valid { "ok" } else { "INVALID" };
1062 output.push_str(&format!(
1063 "tree {} ({} receipt{}): {status}\n",
1064 tree.root_receipt_id,
1065 tree.receipt_count,
1066 if tree.receipt_count == 1 { "" } else { "s" },
1067 ));
1068 if let Some(missing) = &tree.parent_missing {
1069 output.push_str(&format!(" missing parent receipt: {missing}\n"));
1070 }
1071 for finding in &tree.findings {
1072 output.push_str(&format!(
1073 " {} at {}: {}\n",
1074 finding.code,
1075 if finding.path.is_empty() {
1076 "<root>"
1077 } else {
1078 &finding.path
1079 },
1080 finding.message
1081 ));
1082 }
1083 }
1084 for issue in &report.unreadable_files {
1085 output.push_str(&format!("unreadable {}: {}\n", issue.file, issue.message));
1086 }
1087 output.push_str(if report.valid {
1088 "verification: ok\n"
1089 } else {
1090 "verification: FAILED\n"
1091 });
1092 output
1093}
1094
1095fn render_single_receipt_verdict(verdict: &ReceiptVerifyVerdict) -> String {
1096 let mut output = String::new();
1097 output.push_str("receipt verification\n");
1098 output.push_str(&format!(
1099 "receipt: {}\n",
1100 verdict.receipt_id.as_deref().unwrap_or("<unparsed>")
1101 ));
1102 output.push_str(&format!(
1103 "signature: {} ({})\n",
1104 verdict.signature.status, verdict.signature.mode
1105 ));
1106 output.push_str(&format!("digest: {}\n", verdict.digest.status));
1107 output.push_str(&format!(
1108 "content address: {}\n",
1109 verdict.content_address.status
1110 ));
1111 output.push_str(&format!("lineage: {}\n", verdict.lineage.status));
1112 for finding in &verdict.findings {
1113 output.push_str(&format!(
1114 " {} at {}: {}\n",
1115 finding.code,
1116 if finding.path.is_empty() {
1117 "<root>"
1118 } else {
1119 &finding.path
1120 },
1121 finding.message
1122 ));
1123 }
1124 output.push_str(if verdict.valid {
1125 "verification: ok\n"
1126 } else {
1127 "verification: FAILED\n"
1128 });
1129 output
1130}
1131
1132struct LocalDevelopmentReceiptVerifier;
1133
1134impl SignatureVerifier for LocalDevelopmentReceiptVerifier {
1135 fn verify(
1136 &self,
1137 _issuer: &runx_contracts::ReceiptIssuer,
1138 signature: &runx_contracts::ReceiptSignature,
1139 body_digest: &str,
1140 ) -> Result<(), SignatureVerificationFailure> {
1141 if !signature.value.starts_with("sig:sha256:") {
1142 return Err(SignatureVerificationFailure::MalformedSignature);
1143 }
1144 if signature.value == format!("sig:{body_digest}") {
1145 Ok(())
1146 } else {
1147 Err(SignatureVerificationFailure::SignatureMismatch)
1148 }
1149 }
1150}
1151
1152fn non_empty_env<'a>(env: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
1153 env.get(key)
1154 .map(|value| value.trim())
1155 .filter(|value| !value.is_empty())
1156}
1157
1158fn single_receipt_too_large() -> VerifyCliError {
1159 invalid_args(format!(
1160 "--receipt input exceeds {SINGLE_RECEIPT_MAX_BYTES} bytes"
1161 ))
1162}
1163
1164fn invalid_args(message: impl Into<String>) -> VerifyCliError {
1165 VerifyCliError::InvalidArgs(message.into())
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170 use std::fs;
1171 use std::io;
1172
1173 use super::*;
1174 use ring::signature::KeyPair;
1175 use runx_contracts::ReceiptIssuerType;
1176 use runx_runtime::receipts::step_receipt_with_signature_policy;
1177 use runx_runtime::{
1178 Ed25519ReceiptSigner, InvocationStatus, LocalReceiptStore, RuntimeError, SkillOutput,
1179 };
1180 use serde::Deserialize;
1181 use serde_json as test_json;
1182
1183 type JsonValue = test_json::Value;
1184
1185 const CORPUS_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../fixtures/receipt-verify");
1186 const FIXTURE_KID: &str = "runx-cli-verify-fixture-key";
1187 const FIXTURE_SEED: [u8; 32] = [0x47; 32];
1188
1189 #[derive(Debug, Deserialize)]
1190 struct CorpusVerifier {
1191 kid: String,
1192 public_key_base64: String,
1193 }
1194
1195 #[derive(Debug, Deserialize)]
1196 struct CorpusCase {
1197 name: String,
1198 receipt: String,
1199 expected: String,
1200 signature_mode: String,
1201 }
1202
1203 #[test]
1204 fn verifies_production_signed_receipt_store() -> Result<(), io::Error> {
1205 let temp = tempfile_dir()?;
1206 let receipt_dir = temp.join("receipts");
1207 let signer = fixture_signer().map_err(io::Error::other)?;
1208 let verifier = Ed25519ReceiptVerifier::new([signer.production_key()]);
1209 let receipt = production_signed_receipt(&signer)
1210 .map_err(|error| io::Error::other(error.to_string()))?;
1211 LocalReceiptStore::new(&receipt_dir)
1212 .write_receipt_with_policy(
1213 &receipt,
1214 RuntimeReceiptSignaturePolicy::production(&verifier),
1215 )
1216 .map_err(|error| io::Error::other(error.to_string()))?;
1217
1218 let env = verifier_env(&signer);
1219 let result = run_verify_command(
1220 &[
1221 "verify".into(),
1222 "--receipt-dir".into(),
1223 receipt_dir.clone().into_os_string(),
1224 "--json".into(),
1225 ],
1226 &env,
1227 &temp,
1228 )
1229 .map_err(|error| io::Error::other(error.to_string()))?;
1230
1231 assert!(
1232 !result.failed,
1233 "expected clean verification: {}",
1234 result.output
1235 );
1236 let report: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1237 assert_eq!(report["valid"], JsonValue::Bool(true));
1238 assert_eq!(report["signature_mode"], "production");
1239 Ok(())
1240 }
1241
1242 #[test]
1243 fn flags_tampered_receipt_body() -> Result<(), io::Error> {
1244 let temp = tempfile_dir()?;
1245 let receipt_dir = temp.join("receipts");
1246 let signer = fixture_signer().map_err(io::Error::other)?;
1247 let verifier = Ed25519ReceiptVerifier::new([signer.production_key()]);
1248 let receipt = production_signed_receipt(&signer)
1249 .map_err(|error| io::Error::other(error.to_string()))?;
1250 LocalReceiptStore::new(&receipt_dir)
1251 .write_receipt_with_policy(
1252 &receipt,
1253 RuntimeReceiptSignaturePolicy::production(&verifier),
1254 )
1255 .map_err(|error| io::Error::other(error.to_string()))?;
1256
1257 let receipt_file = LocalReceiptStore::new(&receipt_dir)
1259 .receipt_path(&receipt.id)
1260 .map_err(|error| io::Error::other(error.to_string()))?;
1261 let tampered = fs::read_to_string(&receipt_file)?
1262 .replace("production-verified", "production-tampered");
1263 fs::write(&receipt_file, tampered)?;
1264
1265 let env = verifier_env(&signer);
1266 let result = run_verify_command(
1267 &[
1268 "verify".into(),
1269 "--receipt-dir".into(),
1270 receipt_dir.into_os_string(),
1271 ],
1272 &env,
1273 &temp,
1274 )
1275 .map_err(|error| io::Error::other(error.to_string()))?;
1276
1277 assert!(
1278 result.failed,
1279 "tampered receipt must fail: {}",
1280 result.output
1281 );
1282 assert!(result.output.contains("verification: FAILED"));
1283 Ok(())
1284 }
1285
1286 #[test]
1287 fn missing_receipt_id_is_a_usage_error() -> Result<(), io::Error> {
1288 let temp = tempfile_dir()?;
1289 let receipt_dir = temp.join("receipts");
1290 fs::create_dir_all(&receipt_dir)?;
1291 let error = match run_verify_command(
1292 &[
1293 "verify".into(),
1294 "--allow-local-development-signatures".into(),
1295 "receipt_missing".into(),
1296 "--receipt-dir".into(),
1297 receipt_dir.into_os_string(),
1298 ],
1299 &BTreeMap::new(),
1300 &temp,
1301 ) {
1302 Ok(result) => {
1303 return Err(io::Error::other(format!(
1304 "unknown receipt id must error: {}",
1305 result.output
1306 )));
1307 }
1308 Err(error) => error,
1309 };
1310 assert!(matches!(error, VerifyCliError::InvalidArgs(_)));
1311 Ok(())
1312 }
1313
1314 #[test]
1315 fn verifies_single_receipt_file_as_machine_verdict() -> Result<(), io::Error> {
1316 let temp = tempfile_dir()?;
1317 let signer = fixture_signer().map_err(io::Error::other)?;
1318 let receipt = production_signed_receipt(&signer)
1319 .map_err(|error| io::Error::other(error.to_string()))?;
1320 let receipt_file = temp.join("receipt.json");
1321 fs::write(
1322 &receipt_file,
1323 serde_json::to_vec_pretty(&receipt).map_err(io::Error::other)?,
1324 )?;
1325
1326 let result = run_verify_command(
1327 &[
1328 "verify".into(),
1329 "--receipt".into(),
1330 receipt_file.into_os_string(),
1331 "--json".into(),
1332 ],
1333 &verifier_env(&signer),
1334 &temp,
1335 )
1336 .map_err(|error| io::Error::other(error.to_string()))?;
1337
1338 assert!(
1339 !result.failed,
1340 "expected clean single receipt verdict: {}",
1341 result.output
1342 );
1343 let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1344 assert_eq!(verdict["schema"], "runx.verify_verdict.v1");
1345 assert_eq!(verdict["valid"], JsonValue::Bool(true));
1346 assert_eq!(
1347 verdict["receipt_id"],
1348 JsonValue::String(receipt.id.to_string())
1349 );
1350 assert_eq!(verdict["signature"]["mode"], "production");
1351 assert_eq!(verdict["signature"]["status"], "valid");
1352 assert_eq!(verdict["digest"]["status"], "valid");
1353 assert_eq!(verdict["content_address"]["status"], "valid");
1354 assert_eq!(verdict["lineage"]["status"], "unverified");
1355 assert!(verdict["findings"].as_array().is_some_and(Vec::is_empty));
1356 Ok(())
1357 }
1358
1359 #[test]
1360 fn verifies_single_receipt_from_stdin() -> Result<(), io::Error> {
1361 let temp = tempfile_dir()?;
1362 let signer = fixture_signer().map_err(io::Error::other)?;
1363 let receipt = production_signed_receipt(&signer)
1364 .map_err(|error| io::Error::other(error.to_string()))?;
1365 let input = serde_json::to_vec(&receipt).map_err(io::Error::other)?;
1366
1367 let result = run_verify_command_with_stdin(
1368 &[
1369 "verify".into(),
1370 "--receipt".into(),
1371 "-".into(),
1372 "--json".into(),
1373 ],
1374 &verifier_env(&signer),
1375 &temp,
1376 io::Cursor::new(input),
1377 )
1378 .map_err(|error| io::Error::other(error.to_string()))?;
1379
1380 assert!(!result.failed, "stdin verdict failed: {}", result.output);
1381 let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1382 assert_eq!(
1383 verdict["receipt_id"],
1384 JsonValue::String(receipt.id.to_string())
1385 );
1386 assert_eq!(verdict["valid"], JsonValue::Bool(true));
1387 Ok(())
1388 }
1389
1390 #[test]
1391 fn single_receipt_requires_trusted_keys_by_default() -> Result<(), io::Error> {
1392 let temp = tempfile_dir()?;
1393 let signer = fixture_signer().map_err(io::Error::other)?;
1394 let receipt = production_signed_receipt(&signer)
1395 .map_err(|error| io::Error::other(error.to_string()))?;
1396 let receipt_file = temp.join("receipt.json");
1397 fs::write(
1398 &receipt_file,
1399 serde_json::to_vec_pretty(&receipt).map_err(io::Error::other)?,
1400 )?;
1401
1402 let error = match run_verify_command(
1403 &[
1404 "verify".into(),
1405 "--receipt".into(),
1406 receipt_file.into_os_string(),
1407 "--json".into(),
1408 ],
1409 &BTreeMap::new(),
1410 &temp,
1411 ) {
1412 Ok(result) => {
1413 return Err(io::Error::other(format!(
1414 "receipt verification must fail closed without trusted keys: {}",
1415 result.output
1416 )));
1417 }
1418 Err(error) => error,
1419 };
1420 assert!(
1421 matches!(error, VerifyCliError::InvalidReceiptVerifier(message) if message.contains("requires trusted receipt verification keys"))
1422 );
1423 Ok(())
1424 }
1425
1426 #[test]
1427 fn verifies_hosted_notary_counter_seal_from_stdin() -> Result<(), io::Error> {
1428 let temp = tempfile_dir()?;
1429 let key_pair = ring::signature::Ed25519KeyPair::from_seed_unchecked(&FIXTURE_SEED)
1430 .map_err(|_| io::Error::other("fixture key must be valid"))?;
1431 let public_key_path = temp.join("trusted-notary.pem");
1432 fs::write(
1433 &public_key_path,
1434 ed25519_spki_pem(key_pair.public_key().as_ref()),
1435 )?;
1436 let payload = test_json::json!({
1437 "binary_version": "runx-test",
1438 "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1439 "issued_at": "2026-06-10T00:00:00Z",
1440 "mode": "full",
1441 "schema": "runx.hosted_notary_counter_seal_payload.v1",
1442 "verdict_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
1443 });
1444 let canonical_payload = serde_json::to_string(&payload).map_err(io::Error::other)?;
1445 let signature = key_pair.sign(canonical_payload.as_bytes());
1446 let document = test_json::json!({
1447 "notary_verification": {
1448 "counter_seal": {
1449 "schema": "runx.hosted_notary_counter_seal.v1",
1450 "payload": payload,
1451 "digest": sha256_prefixed(canonical_payload.as_bytes()),
1452 "signature": {
1453 "alg": "Ed25519",
1454 "value": format!("base64:{}", base64_standard(signature.as_ref()))
1455 }
1456 },
1457 "signer_public_keys": [{
1458 "kid": "trusted-hosted-receipt-notary-1",
1459 "public_key_pem": ed25519_spki_pem(key_pair.public_key().as_ref())
1460 }]
1461 }
1462 });
1463
1464 let result = run_verify_command_with_stdin(
1465 &[
1466 "verify".into(),
1467 "--notary".into(),
1468 "-".into(),
1469 "--notary-key".into(),
1470 public_key_path.into_os_string(),
1471 "--json".into(),
1472 ],
1473 &BTreeMap::new(),
1474 &temp,
1475 io::Cursor::new(serde_json::to_vec(&document).map_err(io::Error::other)?),
1476 )
1477 .map_err(|error| io::Error::other(error.to_string()))?;
1478
1479 assert!(!result.failed, "notary verifier failed: {}", result.output);
1480 let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1481 assert_eq!(verdict["schema"], "runx.notary_verify_verdict.v1");
1482 assert_eq!(verdict["valid"], JsonValue::Bool(true));
1483 assert_eq!(verdict["counter_seal"]["digest_status"], "valid");
1484 assert_eq!(verdict["counter_seal"]["signature_status"], "valid");
1485 Ok(())
1486 }
1487
1488 #[test]
1489 fn hosted_notary_flags_object_document_without_notary_verification() -> Result<(), io::Error> {
1490 let temp = tempfile_dir()?;
1491 let key_pair = ring::signature::Ed25519KeyPair::from_seed_unchecked(&FIXTURE_SEED)
1492 .map_err(|_| io::Error::other("fixture key must be valid"))?;
1493 let public_key_path = temp.join("trusted-notary.pem");
1494 fs::write(
1495 &public_key_path,
1496 ed25519_spki_pem(key_pair.public_key().as_ref()),
1497 )?;
1498 let document = test_json::json!({ "counter_seal": "not-a-notary-block" });
1502
1503 let result = run_verify_command_with_stdin(
1504 &[
1505 "verify".into(),
1506 "--notary".into(),
1507 "-".into(),
1508 "--notary-key".into(),
1509 public_key_path.into_os_string(),
1510 "--json".into(),
1511 ],
1512 &BTreeMap::new(),
1513 &temp,
1514 io::Cursor::new(serde_json::to_vec(&document).map_err(io::Error::other)?),
1515 )
1516 .map_err(|error| io::Error::other(error.to_string()))?;
1517
1518 assert!(
1519 result.failed,
1520 "document without notary_verification must fail: {}",
1521 result.output
1522 );
1523 let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1524 assert_eq!(verdict["valid"], JsonValue::Bool(false));
1525 let codes = finding_codes(&verdict);
1526 assert!(
1527 codes.contains(&"notary_verification_missing".to_owned()),
1528 "expected notary_verification_missing, got {codes:?}"
1529 );
1530 assert!(
1531 !codes.contains(&"counter_seal_missing".to_owned()),
1532 "must not fall through to counter_seal_missing: {codes:?}"
1533 );
1534 Ok(())
1535 }
1536
1537 #[test]
1538 fn hosted_notary_rejects_embedded_key_without_external_trust() -> Result<(), io::Error> {
1541 let temp = tempfile_dir()?;
1542 let signer = ring::signature::Ed25519KeyPair::from_seed_unchecked(&FIXTURE_SEED)
1543 .map_err(|_| io::Error::other("fixture key must be valid"))?;
1544 let untrusted = ring::signature::Ed25519KeyPair::from_seed_unchecked(&[7u8; 32])
1545 .map_err(|_| io::Error::other("fixture key must be valid"))?;
1546 let untrusted_key_path = temp.join("untrusted-notary.pem");
1547 fs::write(
1548 &untrusted_key_path,
1549 ed25519_spki_pem(untrusted.public_key().as_ref()),
1550 )?;
1551 let payload = test_json::json!({
1552 "binary_version": "runx-test",
1553 "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1554 "issued_at": "2026-06-10T00:00:00Z",
1555 "mode": "full",
1556 "schema": "runx.hosted_notary_counter_seal_payload.v1",
1557 "verdict_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
1558 });
1559 let canonical_payload = serde_json::to_string(&payload).map_err(io::Error::other)?;
1560 let signature = signer.sign(canonical_payload.as_bytes());
1561 let document = test_json::json!({
1562 "notary_verification": {
1563 "counter_seal": {
1564 "schema": "runx.hosted_notary_counter_seal.v1",
1565 "payload": payload,
1566 "digest": sha256_prefixed(canonical_payload.as_bytes()),
1567 "signature": {
1568 "alg": "Ed25519",
1569 "value": format!("base64:{}", base64_standard(signature.as_ref()))
1570 }
1571 },
1572 "signer_public_keys": [{
1573 "kid": "self-attested-hosted-receipt-notary",
1574 "public_key_pem": ed25519_spki_pem(signer.public_key().as_ref())
1575 }]
1576 }
1577 });
1578
1579 let result = run_verify_command_with_stdin(
1580 &[
1581 "verify".into(),
1582 "--notary".into(),
1583 "-".into(),
1584 "--notary-key".into(),
1585 untrusted_key_path.into_os_string(),
1586 "--json".into(),
1587 ],
1588 &BTreeMap::new(),
1589 &temp,
1590 io::Cursor::new(serde_json::to_vec(&document).map_err(io::Error::other)?),
1591 )
1592 .map_err(|error| io::Error::other(error.to_string()))?;
1593
1594 assert!(
1595 result.failed,
1596 "notary verifier should fail: {}",
1597 result.output
1598 );
1599 let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1600 assert_eq!(verdict["valid"], JsonValue::Bool(false));
1601 assert_eq!(verdict["counter_seal"]["signature_status"], "invalid");
1602 assert!(finding_codes(&verdict).contains(&"counter_seal_signature_mismatch".to_owned()));
1603 assert!(finding_codes(&verdict).contains(&"notary_embedded_key_untrusted".to_owned()));
1604 Ok(())
1605 }
1606
1607 #[test]
1608 fn hosted_notary_binds_signed_payload_to_public_projection() -> Result<(), io::Error> {
1611 let temp = tempfile_dir()?;
1612 let key_pair = ring::signature::Ed25519KeyPair::from_seed_unchecked(&FIXTURE_SEED)
1613 .map_err(|_| io::Error::other("fixture key must be valid"))?;
1614 let public_key_path = temp.join("trusted-notary.pem");
1615 fs::write(
1616 &public_key_path,
1617 ed25519_spki_pem(key_pair.public_key().as_ref()),
1618 )?;
1619 let payload = test_json::json!({
1620 "binary_version": "runx-test",
1621 "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1622 "issued_at": "2026-06-10T00:00:00Z",
1623 "mode": "full",
1624 "schema": "runx.hosted_notary_counter_seal_payload.v1",
1625 "verdict_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
1626 });
1627 let canonical_payload = serde_json::to_string(&payload).map_err(io::Error::other)?;
1628 let signature = key_pair.sign(canonical_payload.as_bytes());
1629 let document = test_json::json!({
1630 "receipt": {
1631 "digest": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1632 "mode": "full",
1633 "binary_version": "runx-test",
1634 "notary_verification": {
1635 "counter_seal": {
1636 "schema": "runx.hosted_notary_counter_seal.v1",
1637 "payload": payload,
1638 "payload_digest": sha256_prefixed(canonical_payload.as_bytes()),
1639 "digest": sha256_prefixed(canonical_payload.as_bytes()),
1640 "signature": {
1641 "alg": "Ed25519",
1642 "value": format!("base64:{}", base64_standard(signature.as_ref()))
1643 }
1644 },
1645 "signer_public_keys": [{
1646 "kid": "trusted-hosted-receipt-notary-1",
1647 "public_key_pem": ed25519_spki_pem(key_pair.public_key().as_ref())
1648 }]
1649 }
1650 }
1651 });
1652
1653 let result = run_verify_command_with_stdin(
1654 &[
1655 "verify".into(),
1656 "--notary".into(),
1657 "-".into(),
1658 "--notary-key".into(),
1659 public_key_path.into_os_string(),
1660 "--json".into(),
1661 ],
1662 &BTreeMap::new(),
1663 &temp,
1664 io::Cursor::new(serde_json::to_vec(&document).map_err(io::Error::other)?),
1665 )
1666 .map_err(|error| io::Error::other(error.to_string()))?;
1667
1668 assert!(
1669 result.failed,
1670 "projection binding should fail: {}",
1671 result.output
1672 );
1673 let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1674 assert_eq!(verdict["counter_seal"]["signature_status"], "valid");
1675 assert!(finding_codes(&verdict).contains(&"notary_projection_binding_mismatch".to_owned()));
1676 Ok(())
1677 }
1678
1679 #[test]
1681 fn malformed_single_receipt_returns_invalid_verdict() -> Result<(), io::Error> {
1682 let temp = tempfile_dir()?;
1683
1684 let result = run_verify_command_with_stdin(
1685 &[
1686 "verify".into(),
1687 "--receipt".into(),
1688 "-".into(),
1689 "--json".into(),
1690 ],
1691 &BTreeMap::new(),
1692 &temp,
1693 io::Cursor::new(br#"{"schema":"runx.receipt.v1","#.to_vec()),
1694 )
1695 .map_err(|error| io::Error::other(error.to_string()))?;
1696
1697 assert!(result.failed, "malformed receipt must fail");
1698 let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1699 assert_eq!(verdict["schema"], "runx.verify_verdict.v1");
1700 assert_eq!(verdict["valid"], JsonValue::Bool(false));
1701 assert_eq!(verdict["receipt_id"], JsonValue::Null);
1702 assert_eq!(verdict["findings"][0]["code"], "receipt_parse_error");
1703 Ok(())
1704 }
1705
1706 #[test]
1707 fn single_receipt_rejects_store_selection_flags() -> Result<(), io::Error> {
1708 let temp = tempfile_dir()?;
1709 let error = match run_verify_command(
1710 &[
1711 "verify".into(),
1712 "receipt_1".into(),
1713 "--receipt".into(),
1714 "receipt.json".into(),
1715 ],
1716 &BTreeMap::new(),
1717 &temp,
1718 ) {
1719 Ok(result) => {
1720 return Err(io::Error::other(format!(
1721 "--receipt must be mutually exclusive with receipt ids: {}",
1722 result.output
1723 )));
1724 }
1725 Err(error) => error,
1726 };
1727 assert!(matches!(error, VerifyCliError::InvalidArgs(_)));
1728 Ok(())
1729 }
1730
1731 #[test]
1732 fn single_receipt_stdin_is_size_capped() -> Result<(), io::Error> {
1733 let temp = tempfile_dir()?;
1734 let error = match run_verify_command_with_stdin(
1735 &["verify".into(), "--receipt".into(), "-".into()],
1736 &BTreeMap::new(),
1737 &temp,
1738 io::Cursor::new(vec![b' '; SINGLE_RECEIPT_MAX_BYTES + 1]),
1739 ) {
1740 Ok(result) => {
1741 return Err(io::Error::other(format!(
1742 "oversized stdin must be a usage error: {}",
1743 result.output
1744 )));
1745 }
1746 Err(error) => error,
1747 };
1748 assert!(matches!(error, VerifyCliError::InvalidArgs(_)));
1749 Ok(())
1750 }
1751
1752 #[test]
1753 fn receipt_verify_corpus_replays_through_cli_surface() -> Result<(), io::Error> {
1754 let root = PathBuf::from(CORPUS_ROOT);
1755 let production_env = corpus_production_env(&root)?;
1756 for (case_dir, case) in corpus_cases(&root)? {
1757 let env = if case.signature_mode == "production" {
1758 production_env.clone()
1759 } else {
1760 BTreeMap::new()
1761 };
1762 let mut args = vec!["verify".into()];
1763 if case.signature_mode == "local-development" {
1764 args.push("--allow-local-development-signatures".into());
1765 }
1766 args.extend([
1767 "--receipt".into(),
1768 case_dir.join(&case.receipt).into_os_string(),
1769 "--json".into(),
1770 ]);
1771 let result = run_verify_command(&args, &env, &root)
1772 .map_err(|error| io::Error::other(error.to_string()))?;
1773 let actual: JsonValue =
1774 serde_json::from_str(&result.output).map_err(io::Error::other)?;
1775 let expected = expected_verdict(&case_dir, &case)?;
1776
1777 assert_eq!(actual, expected, "corpus case {} drifted", case.name);
1778 assert_eq!(
1779 result.failed,
1780 !expected["valid"].as_bool().unwrap_or(false),
1781 "corpus case {} had inconsistent exit status",
1782 case.name
1783 );
1784 }
1785 Ok(())
1786 }
1787
1788 fn corpus_production_env(root: &Path) -> Result<BTreeMap<String, String>, io::Error> {
1789 let verifier: CorpusVerifier =
1790 serde_json::from_str(&fs::read_to_string(root.join("verifier.json"))?)
1791 .map_err(io::Error::other)?;
1792 Ok(BTreeMap::from([
1793 (RUNX_RECEIPT_VERIFY_KID_ENV.to_owned(), verifier.kid),
1794 (
1795 RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV.to_owned(),
1796 verifier.public_key_base64,
1797 ),
1798 ]))
1799 }
1800
1801 fn corpus_cases(root: &Path) -> Result<Vec<(PathBuf, CorpusCase)>, io::Error> {
1802 let mut cases = Vec::new();
1803 for entry in fs::read_dir(root)? {
1804 let path = entry?.path();
1805 if !path.is_dir() {
1806 continue;
1807 }
1808 let case_path = path.join("case.json");
1809 if !case_path.exists() {
1810 continue;
1811 }
1812 let case: CorpusCase =
1813 serde_json::from_str(&fs::read_to_string(case_path)?).map_err(io::Error::other)?;
1814 cases.push((path, case));
1815 }
1816 cases.sort_by(|left, right| left.1.name.cmp(&right.1.name));
1817 Ok(cases)
1818 }
1819
1820 fn expected_verdict(case_dir: &Path, case: &CorpusCase) -> Result<JsonValue, io::Error> {
1821 serde_json::from_str(&fs::read_to_string(case_dir.join(&case.expected))?)
1822 .map_err(io::Error::other)
1823 }
1824
1825 fn verifier_env(signer: &Ed25519ReceiptSigner) -> BTreeMap<String, String> {
1826 BTreeMap::from([
1827 (
1828 RUNX_RECEIPT_VERIFY_KID_ENV.to_owned(),
1829 FIXTURE_KID.to_owned(),
1830 ),
1831 (
1832 RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV.to_owned(),
1833 base64_standard(signer.public_key()),
1834 ),
1835 ])
1836 }
1837
1838 fn fixture_signer() -> Result<Ed25519ReceiptSigner, runx_runtime::RuntimeReceiptSigningError> {
1839 Ed25519ReceiptSigner::from_seed(FIXTURE_KID, ReceiptIssuerType::Hosted, &FIXTURE_SEED)
1840 }
1841
1842 fn production_signed_receipt(signer: &Ed25519ReceiptSigner) -> Result<Receipt, RuntimeError> {
1843 let verifier = Ed25519ReceiptVerifier::new([signer.production_key()]);
1844 let output = SkillOutput {
1845 status: InvocationStatus::Success,
1846 stdout:
1847 r#"{"artifact":{"artifact_id":"artifact_cli_verify","artifact_type":"artifact"}}"#
1848 .to_owned(),
1849 stderr: String::new(),
1850 exit_code: Some(0),
1851 duration_ms: 10,
1852 metadata: BTreeMap::new(),
1853 };
1854 step_receipt_with_signature_policy(
1855 "cli-verify",
1856 "production-verified",
1857 1,
1858 &output,
1859 "2026-06-10T00:00:00Z",
1860 RuntimeReceiptSignaturePolicy::production_signing(signer, &verifier),
1861 )
1862 }
1863
1864 fn base64_standard(bytes: &[u8]) -> String {
1865 const TABLE: &[u8; 64] =
1866 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1867 let mut output = String::new();
1868 for chunk in bytes.chunks(3) {
1869 let b0 = chunk[0] as u32;
1870 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1871 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1872 let triple = (b0 << 16) | (b1 << 8) | b2;
1873 output.push(TABLE[(triple >> 18) as usize & 0x3f] as char);
1874 output.push(TABLE[(triple >> 12) as usize & 0x3f] as char);
1875 output.push(if chunk.len() > 1 {
1876 TABLE[(triple >> 6) as usize & 0x3f] as char
1877 } else {
1878 '='
1879 });
1880 output.push(if chunk.len() > 2 {
1881 TABLE[triple as usize & 0x3f] as char
1882 } else {
1883 '='
1884 });
1885 }
1886 output
1887 }
1888
1889 fn ed25519_spki_pem(raw_public_key: &[u8]) -> String {
1890 let mut der = Vec::from([
1891 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1892 ]);
1893 der.extend_from_slice(raw_public_key);
1894 format!(
1895 "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----",
1896 base64_standard(&der)
1897 )
1898 }
1899
1900 fn finding_codes(verdict: &JsonValue) -> Vec<String> {
1901 verdict["findings"]
1902 .as_array()
1903 .into_iter()
1904 .flatten()
1905 .filter_map(|finding| finding["code"].as_str().map(ToOwned::to_owned))
1906 .collect()
1907 }
1908
1909 fn tempfile_dir() -> Result<PathBuf, io::Error> {
1910 let path = std::env::temp_dir().join(format!(
1911 "runx-cli-verify-{}-{}",
1912 std::process::id(),
1913 std::time::SystemTime::now()
1914 .duration_since(std::time::UNIX_EPOCH)
1915 .map_err(|error| io::Error::other(error.to_string()))?
1916 .as_nanos()
1917 ));
1918 fs::create_dir_all(&path)?;
1919 Ok(path)
1920 }
1921}