1pub mod ots;
33pub mod rekor;
34pub mod trusted_root;
35
36use std::fmt;
37use std::path::{Path, PathBuf};
38use std::time::Duration;
39
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42use thiserror::Error;
43
44use crate::anchor::{verify_anchor, AnchorParseError, AnchorVerifyError, LedgerAnchor};
45use crate::sha256::sha256_hex;
46
47pub use trusted_root::{
48 active_trusted_root, ActiveTrustedRoot, TransparencyLogInstance, TransparencyLogPublicKey,
49 TrustRootStalenessAnchor, TrustRootStalenessError, TrustedRoot, TrustedRootIoError,
50 TrustedRootKeyError, TrustedRootParseError, ValidityPeriod, CACHED_ROOT_STATUS,
51 DEFAULT_MAX_TRUST_ROOT_AGE, EMBEDDED_ROOT_STATUS, EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE,
52 REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT, TRUSTED_ROOT_CACHE_STALE_INVARIANT,
53 TRUSTED_ROOT_JSON, TRUSTED_ROOT_PARSE_INVARIANT, TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT,
54 TRUSTED_ROOT_STALE_INVARIANT,
55};
56
57pub const EXTERNAL_RECEIPT_FORMAT_HEADER_V1: &str = "# cortex-external-anchor-receipt-format: 1";
59
60const SHA256_HEX_LEN: usize = 64;
62
63const BLAKE3_HEX_LEN: usize = 64;
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum ExternalSink {
76 None,
78 Rekor,
80 OpenTimestamps,
82}
83
84impl ExternalSink {
85 #[must_use]
87 pub const fn as_wire_str(self) -> &'static str {
88 match self {
89 Self::None => "none",
90 Self::Rekor => "rekor",
91 Self::OpenTimestamps => "opentimestamps",
92 }
93 }
94
95 pub fn from_wire_str(value: &str) -> Result<Self, ExternalReceiptParseError> {
97 match value {
98 "none" => Ok(Self::None),
99 "rekor" => Ok(Self::Rekor),
100 "opentimestamps" => Ok(Self::OpenTimestamps),
101 other => Err(ExternalReceiptParseError::UnknownSink {
102 observed: other.to_string(),
103 }),
104 }
105 }
106}
107
108impl fmt::Display for ExternalSink {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 f.write_str(self.as_wire_str())
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct ExternalReceipt {
123 pub sink: ExternalSink,
125 pub anchor_text_sha256: String,
129 pub anchor_event_count: u64,
131 pub anchor_chain_head_hash: String,
133 pub submitted_at: DateTime<Utc>,
135 pub sink_endpoint: String,
137 pub receipt: serde_json::Value,
139}
140
141impl ExternalReceipt {
142 pub fn to_record_text(&self) -> Result<String, ExternalReceiptParseError> {
145 let body = serde_json::to_string(self).map_err(|source| {
146 ExternalReceiptParseError::MalformedBody {
147 reason: format!("failed to serialize receipt body: {source}"),
148 }
149 })?;
150 Ok(format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n"))
151 }
152}
153
154impl ExternalSink {
155 fn serialize_sink<S>(sink: &Self, ser: S) -> Result<S::Ok, S::Error>
159 where
160 S: serde::Serializer,
161 {
162 ser.serialize_str(sink.as_wire_str())
163 }
164
165 fn deserialize_sink<'de, D>(de: D) -> Result<Self, D::Error>
166 where
167 D: serde::Deserializer<'de>,
168 {
169 let raw = String::deserialize(de)?;
170 Self::from_wire_str(&raw).map_err(serde::de::Error::custom)
171 }
172}
173
174impl Serialize for ExternalSink {
178 fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
179 where
180 S: serde::Serializer,
181 {
182 Self::serialize_sink(self, ser)
183 }
184}
185
186impl<'de> Deserialize<'de> for ExternalSink {
187 fn deserialize<D>(de: D) -> Result<Self, D::Error>
188 where
189 D: serde::Deserializer<'de>,
190 {
191 Self::deserialize_sink(de)
192 }
193}
194
195pub fn parse_external_receipt(input: &str) -> Result<ExternalReceipt, ExternalReceiptParseError> {
201 let mut lines = input.lines();
202 let Some(header) = lines.next() else {
203 return Err(ExternalReceiptParseError::MissingHeader);
204 };
205 if header != EXTERNAL_RECEIPT_FORMAT_HEADER_V1 {
206 return Err(ExternalReceiptParseError::UnknownFormatHeader {
207 observed: header.to_string(),
208 });
209 }
210
211 let Some(body) = lines.next() else {
212 return Err(ExternalReceiptParseError::MissingBody);
213 };
214 if body.trim() != body {
215 return Err(ExternalReceiptParseError::MalformedBody {
216 reason: "body line must not have leading or trailing whitespace".to_string(),
217 });
218 }
219 if body.is_empty() {
220 return Err(ExternalReceiptParseError::MalformedBody {
221 reason: "body line must not be empty".to_string(),
222 });
223 }
224 if lines.next().is_some() {
225 return Err(ExternalReceiptParseError::TrailingContent);
226 }
227
228 let receipt: ExternalReceipt =
229 serde_json::from_str(body).map_err(|source| ExternalReceiptParseError::MalformedBody {
230 reason: format!("invalid receipt JSON: {source}"),
231 })?;
232 validate_external_receipt_fields(&receipt)?;
233 Ok(receipt)
234}
235
236pub fn parse_external_receipt_history(
244 input: &str,
245) -> Result<Vec<ExternalReceipt>, ExternalReceiptParseError> {
246 let mut lines = input.lines();
247 let mut receipts = Vec::new();
248
249 loop {
250 let Some(header) = lines.next() else {
251 break;
252 };
253 let Some(body) = lines.next() else {
254 return Err(ExternalReceiptParseError::MissingBody);
255 };
256 receipts.push(parse_external_receipt(&format!("{header}\n{body}\n"))?);
257 }
258
259 if receipts.is_empty() {
260 return Err(ExternalReceiptParseError::MissingHeader);
261 }
262
263 let mut previous_event_count: Option<u64> = None;
264 for (index, receipt) in receipts.iter().enumerate() {
265 if let Some(previous) = previous_event_count {
266 if receipt.anchor_event_count < previous {
267 return Err(ExternalReceiptParseError::NonMonotonic {
268 receipt_index: index + 1,
269 previous_event_count: previous,
270 event_count: receipt.anchor_event_count,
271 });
272 }
273 }
274 previous_event_count = Some(receipt.anchor_event_count);
275 }
276
277 Ok(receipts)
278}
279
280fn validate_external_receipt_fields(
281 receipt: &ExternalReceipt,
282) -> Result<(), ExternalReceiptParseError> {
283 if receipt.sink == ExternalSink::None {
284 return Err(ExternalReceiptParseError::UnknownSink {
285 observed: ExternalSink::None.as_wire_str().to_string(),
286 });
287 }
288 validate_lower_hex(
289 &receipt.anchor_text_sha256,
290 SHA256_HEX_LEN,
291 "anchor_text_sha256",
292 )?;
293 validate_lower_hex(
294 &receipt.anchor_chain_head_hash,
295 BLAKE3_HEX_LEN,
296 "anchor_chain_head_hash",
297 )?;
298 if receipt.sink_endpoint.is_empty() {
299 return Err(ExternalReceiptParseError::MalformedBody {
300 reason: "sink_endpoint must not be empty".to_string(),
301 });
302 }
303 if receipt.anchor_event_count == 0 {
304 return Err(ExternalReceiptParseError::MalformedBody {
305 reason: "anchor_event_count must be positive".to_string(),
306 });
307 }
308 if !receipt.receipt.is_object() {
309 return Err(ExternalReceiptParseError::MalformedBody {
310 reason: "receipt body field must be a JSON object".to_string(),
311 });
312 }
313 Ok(())
314}
315
316fn validate_lower_hex(
317 value: &str,
318 expected_len: usize,
319 field: &'static str,
320) -> Result<(), ExternalReceiptParseError> {
321 if value.len() != expected_len
322 || !value
323 .bytes()
324 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
325 {
326 return Err(ExternalReceiptParseError::InvalidHexField {
327 field,
328 value: value.to_string(),
329 expected_len,
330 });
331 }
332 Ok(())
333}
334
335#[derive(Debug, Clone, PartialEq, Eq, Error)]
337pub enum ExternalReceiptParseError {
338 #[error("missing external anchor receipt format header")]
340 MissingHeader,
341 #[error("unknown external anchor receipt format header: {observed}")]
343 UnknownFormatHeader {
344 observed: String,
346 },
347 #[error("missing external anchor receipt body")]
349 MissingBody,
350 #[error("malformed external anchor receipt body: {reason}")]
352 MalformedBody {
353 reason: String,
355 },
356 #[error("external anchor receipt has trailing content")]
358 TrailingContent,
359 #[error("unknown external anchor receipt sink: {observed}")]
361 UnknownSink {
362 observed: String,
364 },
365 #[error("invalid external anchor receipt {field}: expected {expected_len} lowercase hex chars, got `{value}`")]
367 InvalidHexField {
368 field: &'static str,
370 value: String,
372 expected_len: usize,
374 },
375 #[error(
377 "external anchor receipt history is non-monotonic at record {receipt_index}: event_count {event_count} follows {previous_event_count}"
378 )]
379 NonMonotonic {
380 receipt_index: usize,
382 previous_event_count: u64,
384 event_count: u64,
386 },
387}
388
389#[derive(Debug, Error)]
391pub enum ExternalReceiptHistoryIoError {
392 #[error("failed to read external anchor receipt history {path:?}: {source}")]
394 ReadHistory {
395 path: PathBuf,
397 source: std::io::Error,
399 },
400 #[error("invalid external anchor receipt history {path:?}: {source}")]
402 Parse {
403 path: PathBuf,
405 source: ExternalReceiptParseError,
407 },
408}
409
410pub fn read_external_receipt_history(
412 path: impl Into<PathBuf>,
413) -> Result<Vec<ExternalReceipt>, ExternalReceiptHistoryIoError> {
414 let path = path.into();
415 let text = std::fs::read_to_string(&path).map_err(|source| {
416 ExternalReceiptHistoryIoError::ReadHistory {
417 path: path.clone(),
418 source,
419 }
420 })?;
421 parse_external_receipt_history(&text)
422 .map_err(|source| ExternalReceiptHistoryIoError::Parse { path, source })
423}
424
425pub const ANCHOR_TEXT_HASH_MISMATCH_INVARIANT: &str =
430 "external_anchor_receipts.anchor_text_hash.mismatch";
431
432pub const PARSED_ONLY_VERIFICATION_STATUS: &str = "parsed_only_signature_verification_pending";
439
440#[derive(Debug, Clone, PartialEq, Eq)]
442pub struct ExternalReceiptVerification {
443 pub path: PathBuf,
445 pub receipts_path: PathBuf,
447 pub db_count: u64,
450 pub receipts_verified: usize,
452 pub latest_receipt: ExternalReceipt,
455 pub status: &'static str,
457 pub trust_root_status: &'static str,
462 pub trust_root_signed_at: Option<DateTime<Utc>>,
464}
465
466#[derive(Debug, Error)]
469pub enum ExternalReceiptVerifyError {
470 #[error("failed to read external anchor receipt history {path:?}: {source}")]
472 ReadHistory {
473 path: PathBuf,
475 source: std::io::Error,
477 },
478 #[error("invalid external anchor receipt history {path:?}: {source}")]
480 Parse {
481 path: PathBuf,
483 source: ExternalReceiptParseError,
485 },
486 #[error(
489 "external anchor receipt {receipt_index} in {path:?} carries a malformed anchor: {source}"
490 )]
491 Anchor {
492 path: PathBuf,
494 receipt_index: usize,
496 source: AnchorParseError,
498 },
499 #[error(
501 "external anchor receipt {receipt_index} in {path:?} failed local anchor verification: {source}"
502 )]
503 AnchorVerify {
504 path: PathBuf,
506 receipt_index: usize,
508 source: Box<AnchorVerifyError>,
510 },
511 #[error(
513 "{invariant}: external anchor receipt {receipt_index} in {path:?} declared anchor_text_sha256 {declared} but local ledger recomputes {observed}"
514 )]
515 AnchorTextHashMismatch {
516 invariant: &'static str,
518 path: PathBuf,
520 receipt_index: usize,
522 declared: String,
524 observed: String,
526 },
527 #[error(
531 "{invariant}: trusted_root.json (status={trust_root_status}) signed_at {signed_at:?} is stale beyond max_age {max_age:?} at now {now}"
532 )]
533 TrustedRootStale {
534 invariant: &'static str,
536 trust_root_status: &'static str,
538 cache_path: Option<PathBuf>,
540 signed_at: Option<DateTime<Utc>>,
542 now: DateTime<Utc>,
544 max_age: Duration,
546 },
547 #[error("{invariant}: failed to load trusted_root.json from {path:?}: {source}")]
552 TrustedRootIo {
553 invariant: &'static str,
555 path: PathBuf,
557 source: Box<TrustedRootIoError>,
559 },
560}
561
562pub fn verify_external_receipts(
589 ledger_path: impl AsRef<Path>,
590 receipts_path: impl Into<PathBuf>,
591) -> Result<ExternalReceiptVerification, ExternalReceiptVerifyError> {
592 verify_external_receipts_with_options(
593 ledger_path,
594 receipts_path,
595 None,
596 Utc::now(),
597 DEFAULT_MAX_TRUST_ROOT_AGE,
598 )
599}
600
601pub fn verify_external_receipts_with_options(
611 ledger_path: impl AsRef<Path>,
612 receipts_path: impl Into<PathBuf>,
613 trust_root_cache: Option<&Path>,
614 now: DateTime<Utc>,
615 max_age: Duration,
616) -> Result<ExternalReceiptVerification, ExternalReceiptVerifyError> {
617 let receipts_path = receipts_path.into();
618 let active = match active_trusted_root(trust_root_cache) {
619 Ok(active) => active,
620 Err(source) => {
621 let path = trust_root_cache
622 .map(Path::to_path_buf)
623 .unwrap_or_else(|| PathBuf::from("<embedded>"));
624 return Err(ExternalReceiptVerifyError::TrustedRootIo {
625 invariant: TRUSTED_ROOT_PARSE_INVARIANT,
626 path,
627 source: Box::new(source),
628 });
629 }
630 };
631 let trust_root_status = active.status;
648 let trust_root_signed_at = active.root.metadata_signed_at();
649 if active.status == CACHED_ROOT_STATUS {
650 let cache_path = active
651 .cache_path
652 .as_deref()
653 .expect("CACHED_ROOT_STATUS implies a cache path was inspected");
654 let anchor = TrustRootStalenessAnchor::cache_file_mtime(cache_path);
655 match active.root.is_stale_at(now, max_age, anchor) {
656 Ok(true) => {
657 return Err(ExternalReceiptVerifyError::TrustedRootStale {
658 invariant: TRUSTED_ROOT_CACHE_STALE_INVARIANT,
659 trust_root_status,
660 cache_path: active.cache_path,
661 signed_at: trust_root_signed_at,
662 now,
663 max_age,
664 });
665 }
666 Ok(false) => {}
667 Err(TrustRootStalenessError::CacheFutureDated { .. }) => {
668 return Err(ExternalReceiptVerifyError::TrustedRootStale {
673 invariant: trusted_root::STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED,
674 trust_root_status,
675 cache_path: active.cache_path,
676 signed_at: trust_root_signed_at,
677 now,
678 max_age,
679 });
680 }
681 Err(source) => {
682 return Err(ExternalReceiptVerifyError::TrustedRootIo {
683 invariant: TRUSTED_ROOT_PARSE_INVARIANT,
684 path: cache_path.to_path_buf(),
685 source: Box::new(TrustedRootIoError::Read {
686 path: cache_path.to_path_buf(),
687 source: std::io::Error::other(source.to_string()),
688 }),
689 });
690 }
691 }
692 }
693
694 let text = std::fs::read_to_string(&receipts_path).map_err(|source| {
695 ExternalReceiptVerifyError::ReadHistory {
696 path: receipts_path.clone(),
697 source,
698 }
699 })?;
700 let receipts = parse_external_receipt_history(&text).map_err(|source| {
701 ExternalReceiptVerifyError::Parse {
702 path: receipts_path.clone(),
703 source,
704 }
705 })?;
706
707 let mut latest_db_count = 0u64;
710 let mut latest_receipt: Option<ExternalReceipt> = None;
711
712 for (index, receipt) in receipts.iter().enumerate() {
713 let record_index = index + 1;
714 let anchor = LedgerAnchor::new(
719 receipt.submitted_at,
720 receipt.anchor_event_count,
721 receipt.anchor_chain_head_hash.clone(),
722 )
723 .map_err(|source| ExternalReceiptVerifyError::Anchor {
724 path: receipts_path.clone(),
725 receipt_index: record_index,
726 source,
727 })?;
728 let verified = verify_anchor(ledger_path.as_ref(), &anchor).map_err(|source| {
729 ExternalReceiptVerifyError::AnchorVerify {
730 path: receipts_path.clone(),
731 receipt_index: record_index,
732 source: Box::new(source),
733 }
734 })?;
735 let recomputed = sha256_hex(anchor.to_anchor_text().as_bytes());
736 if recomputed != receipt.anchor_text_sha256 {
737 return Err(ExternalReceiptVerifyError::AnchorTextHashMismatch {
738 invariant: ANCHOR_TEXT_HASH_MISMATCH_INVARIANT,
739 path: receipts_path,
740 receipt_index: record_index,
741 declared: receipt.anchor_text_sha256.clone(),
742 observed: recomputed,
743 });
744 }
745 latest_db_count = verified.db_count;
746 latest_receipt = Some(receipt.clone());
747 }
748
749 let latest_receipt = latest_receipt.expect("parse_external_receipt_history returns non-empty");
750 Ok(ExternalReceiptVerification {
751 path: ledger_path.as_ref().to_path_buf(),
752 receipts_path,
753 db_count: latest_db_count,
754 receipts_verified: receipts.len(),
755 latest_receipt,
756 status: PARSED_ONLY_VERIFICATION_STATUS,
757 trust_root_status,
758 trust_root_signed_at,
759 })
760}
761
762#[must_use]
766pub fn anchor_text_sha256(anchor: &LedgerAnchor) -> String {
767 sha256_hex(anchor.to_anchor_text().as_bytes())
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773 use chrono::TimeZone;
774
775 fn sample_receipt(event_count: u64, sink: ExternalSink) -> ExternalReceipt {
776 ExternalReceipt {
777 sink,
778 anchor_text_sha256: "0".repeat(SHA256_HEX_LEN),
779 anchor_event_count: event_count,
780 anchor_chain_head_hash: "a".repeat(BLAKE3_HEX_LEN),
781 submitted_at: Utc.with_ymd_and_hms(2026, 5, 12, 18, 0, 0).unwrap(),
782 sink_endpoint: "https://rekor.sigstore.dev".to_string(),
783 receipt: serde_json::json!({"logIndex": 1, "uuid": "abc"}),
784 }
785 }
786
787 #[test]
788 fn parses_clean_record_round_trip() {
789 let receipt = sample_receipt(7, ExternalSink::Rekor);
790 let text = receipt.to_record_text().unwrap();
791 assert!(text.starts_with(EXTERNAL_RECEIPT_FORMAT_HEADER_V1));
792 let parsed = parse_external_receipt(&text).unwrap();
793 assert_eq!(parsed, receipt);
794 }
795
796 #[test]
797 fn rejects_unknown_sink_token() {
798 let body = serde_json::json!({
799 "sink": "unknown-sink",
800 "anchor_text_sha256": "0".repeat(SHA256_HEX_LEN),
801 "anchor_event_count": 1,
802 "anchor_chain_head_hash": "a".repeat(BLAKE3_HEX_LEN),
803 "submitted_at": "2026-05-12T18:00:00Z",
804 "sink_endpoint": "https://example.invalid",
805 "receipt": {},
806 });
807 let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
808 let err = parse_external_receipt(&text).unwrap_err();
809 match err {
810 ExternalReceiptParseError::MalformedBody { reason } => {
811 assert!(
812 reason.contains("unknown external anchor receipt sink"),
813 "{reason}"
814 );
815 }
816 other => panic!("expected MalformedBody, got {other:?}"),
817 }
818 }
819
820 #[test]
821 fn rejects_missing_header() {
822 let body = serde_json::to_string(&sample_receipt(1, ExternalSink::Rekor)).unwrap();
823 let err = parse_external_receipt(&body).unwrap_err();
824 assert!(matches!(
825 err,
826 ExternalReceiptParseError::UnknownFormatHeader { .. }
827 ));
828 }
829
830 #[test]
831 fn rejects_unknown_format_header() {
832 let body = serde_json::to_string(&sample_receipt(1, ExternalSink::Rekor)).unwrap();
833 let text = format!("# cortex-external-anchor-receipt-format: 2\n{body}\n");
834 let err = parse_external_receipt(&text).unwrap_err();
835 assert!(matches!(
836 err,
837 ExternalReceiptParseError::UnknownFormatHeader { .. }
838 ));
839 }
840
841 #[test]
842 fn rejects_missing_required_field() {
843 let body = serde_json::json!({
844 "sink": "rekor",
845 "anchor_event_count": 1,
846 "anchor_chain_head_hash": "a".repeat(BLAKE3_HEX_LEN),
847 "submitted_at": "2026-05-12T18:00:00Z",
848 "sink_endpoint": "https://example.invalid",
849 "receipt": {},
850 });
851 let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
852 let err = parse_external_receipt(&text).unwrap_err();
853 match err {
854 ExternalReceiptParseError::MalformedBody { reason } => {
855 assert!(reason.contains("anchor_text_sha256"), "{reason}");
856 }
857 other => panic!("expected MalformedBody, got {other:?}"),
858 }
859 }
860
861 #[test]
862 fn rejects_invalid_hex_lengths() {
863 let mut receipt = sample_receipt(1, ExternalSink::Rekor);
864 receipt.anchor_text_sha256 = "abc".to_string();
865 let body = serde_json::to_string(&receipt).unwrap();
866 let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
867 let err = parse_external_receipt(&text).unwrap_err();
868 assert!(matches!(
869 err,
870 ExternalReceiptParseError::InvalidHexField {
871 field: "anchor_text_sha256",
872 ..
873 }
874 ));
875 }
876
877 #[test]
878 fn rejects_trailing_content() {
879 let body = serde_json::to_string(&sample_receipt(1, ExternalSink::Rekor)).unwrap();
880 let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\nextra\n");
881 let err = parse_external_receipt(&text).unwrap_err();
882 assert_eq!(err, ExternalReceiptParseError::TrailingContent);
883 }
884
885 #[test]
886 fn rejects_none_sink_in_payload() {
887 let body = serde_json::json!({
888 "sink": "none",
889 "anchor_text_sha256": "0".repeat(SHA256_HEX_LEN),
890 "anchor_event_count": 1,
891 "anchor_chain_head_hash": "a".repeat(BLAKE3_HEX_LEN),
892 "submitted_at": "2026-05-12T18:00:00Z",
893 "sink_endpoint": "https://example.invalid",
894 "receipt": {},
895 });
896 let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
897 let err = parse_external_receipt(&text).unwrap_err();
898 assert!(matches!(err, ExternalReceiptParseError::UnknownSink { .. }));
899 }
900
901 #[test]
902 fn parses_history_round_trip_with_monotonic_records() {
903 let r1 = sample_receipt(1, ExternalSink::Rekor);
904 let r2 = sample_receipt(3, ExternalSink::OpenTimestamps);
905 let text = format!(
906 "{}{}",
907 r1.to_record_text().unwrap(),
908 r2.to_record_text().unwrap()
909 );
910 let parsed = parse_external_receipt_history(&text).unwrap();
911 assert_eq!(parsed, vec![r1, r2]);
912 }
913
914 #[test]
915 fn history_rejects_non_monotonic_event_count() {
916 let r1 = sample_receipt(5, ExternalSink::Rekor);
917 let r2 = sample_receipt(2, ExternalSink::Rekor);
918 let text = format!(
919 "{}{}",
920 r1.to_record_text().unwrap(),
921 r2.to_record_text().unwrap()
922 );
923 let err = parse_external_receipt_history(&text).unwrap_err();
924 assert!(matches!(
925 err,
926 ExternalReceiptParseError::NonMonotonic {
927 receipt_index: 2,
928 previous_event_count: 5,
929 event_count: 2,
930 }
931 ));
932 }
933
934 #[test]
935 fn history_rejects_truncated_record() {
936 let err = parse_external_receipt_history(EXTERNAL_RECEIPT_FORMAT_HEADER_V1).unwrap_err();
937 assert_eq!(err, ExternalReceiptParseError::MissingBody);
938 }
939
940 #[test]
941 fn history_rejects_empty_input() {
942 let err = parse_external_receipt_history("").unwrap_err();
943 assert_eq!(err, ExternalReceiptParseError::MissingHeader);
944 }
945
946 #[test]
947 fn sink_wire_tokens_are_stable() {
948 assert_eq!(ExternalSink::None.as_wire_str(), "none");
949 assert_eq!(ExternalSink::Rekor.as_wire_str(), "rekor");
950 assert_eq!(ExternalSink::OpenTimestamps.as_wire_str(), "opentimestamps");
951 }
952
953 #[test]
954 fn sink_from_wire_str_rejects_garbage() {
955 let err = ExternalSink::from_wire_str("garbage").unwrap_err();
956 assert!(matches!(err, ExternalReceiptParseError::UnknownSink { .. }));
957 }
958
959 use cortex_core::{Event, EventId, EventSource, EventType, SCHEMA_VERSION};
962 use tempfile::tempdir;
963
964 use crate::JsonlLog;
965
966 fn ledger_event(seq: u64) -> Event {
967 Event {
968 id: EventId::new(),
969 schema_version: SCHEMA_VERSION,
970 observed_at: Utc.with_ymd_and_hms(2026, 5, 12, 18, 0, 0).unwrap(),
971 recorded_at: Utc.with_ymd_and_hms(2026, 5, 12, 18, 0, 1).unwrap(),
972 source: EventSource::User,
973 event_type: EventType::UserMessage,
974 trace_id: None,
975 session_id: Some("ext-receipt".into()),
976 domain_tags: vec![],
977 payload: serde_json::json!({"seq": seq}),
978 payload_hash: String::new(),
979 prev_event_hash: None,
980 event_hash: String::new(),
981 }
982 }
983
984 fn build_ledger(count: u64) -> (tempfile::TempDir, std::path::PathBuf, Vec<String>) {
985 let dir = tempdir().unwrap();
986 let path = dir.path().join("events.jsonl");
987 let mut log = JsonlLog::open(&path).unwrap();
988 let mut heads = Vec::new();
989 let policy = crate::append_policy_decision_test_allow();
990 for seq in 0..count {
991 heads.push(log.append(ledger_event(seq), &policy).unwrap());
992 }
993 (dir, path, heads)
994 }
995
996 fn make_canonical_receipt(
997 timestamp: DateTime<Utc>,
998 event_count: u64,
999 chain_head: &str,
1000 sink: ExternalSink,
1001 ) -> ExternalReceipt {
1002 let anchor = LedgerAnchor::new(timestamp, event_count, chain_head.to_string()).unwrap();
1003 ExternalReceipt {
1004 sink,
1005 anchor_text_sha256: anchor_text_sha256(&anchor),
1006 anchor_event_count: event_count,
1007 anchor_chain_head_hash: chain_head.to_string(),
1008 submitted_at: timestamp,
1009 sink_endpoint: "https://rekor.sigstore.dev".to_string(),
1010 receipt: serde_json::json!({"logIndex": event_count, "uuid": "fixture"}),
1011 }
1012 }
1013
1014 fn write_receipt_history(path: &Path, receipts: &[ExternalReceipt]) {
1015 let mut text = String::new();
1016 for receipt in receipts {
1017 text.push_str(&receipt.to_record_text().unwrap());
1018 }
1019 std::fs::write(path, text).unwrap();
1020 }
1021
1022 #[test]
1023 fn clean_receipt_history_parses_and_verifies() {
1024 let (_dir, ledger, heads) = build_ledger(3);
1025 let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
1026 let r1 = make_canonical_receipt(
1027 Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
1028 1,
1029 &heads[0],
1030 ExternalSink::Rekor,
1031 );
1032 let r2 = make_canonical_receipt(
1033 Utc.with_ymd_and_hms(2026, 5, 12, 18, 10, 0).unwrap(),
1034 3,
1035 &heads[2],
1036 ExternalSink::Rekor,
1037 );
1038 write_receipt_history(&receipts_path, &[r1, r2.clone()]);
1039
1040 let verification = verify_external_receipts(&ledger, &receipts_path).unwrap();
1041 assert_eq!(verification.receipts_verified, 2);
1042 assert_eq!(verification.latest_receipt, r2);
1043 assert_eq!(verification.db_count, 3);
1044 assert_eq!(verification.status, PARSED_ONLY_VERIFICATION_STATUS);
1045 }
1046
1047 #[test]
1048 fn tampered_anchor_text_hash_fails_closed_with_stable_invariant() {
1049 let (_dir, ledger, heads) = build_ledger(3);
1050 let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
1051 let mut r1 = make_canonical_receipt(
1052 Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
1053 3,
1054 &heads[2],
1055 ExternalSink::Rekor,
1056 );
1057 r1.anchor_text_sha256 = "f".repeat(SHA256_HEX_LEN);
1058 write_receipt_history(&receipts_path, &[r1]);
1059
1060 let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
1061 match err {
1062 ExternalReceiptVerifyError::AnchorTextHashMismatch {
1063 invariant,
1064 receipt_index,
1065 ..
1066 } => {
1067 assert_eq!(invariant, ANCHOR_TEXT_HASH_MISMATCH_INVARIANT);
1068 assert_eq!(receipt_index, 1);
1069 }
1070 other => panic!("expected AnchorTextHashMismatch, got {other:?}"),
1071 }
1072 }
1073
1074 #[test]
1075 fn tampered_anchor_chain_head_hash_fails_closed() {
1076 let (_dir, ledger, heads) = build_ledger(3);
1077 let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
1078 let mut r1 = make_canonical_receipt(
1079 Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
1080 3,
1081 &heads[2],
1082 ExternalSink::Rekor,
1083 );
1084 r1.anchor_chain_head_hash = "0".repeat(BLAKE3_HEX_LEN);
1086 let bogus_anchor = LedgerAnchor::new(
1090 r1.submitted_at,
1091 r1.anchor_event_count,
1092 r1.anchor_chain_head_hash.clone(),
1093 )
1094 .unwrap();
1095 r1.anchor_text_sha256 = anchor_text_sha256(&bogus_anchor);
1096 write_receipt_history(&receipts_path, &[r1]);
1097
1098 let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
1099 assert!(
1100 matches!(err, ExternalReceiptVerifyError::AnchorVerify { .. }),
1101 "got {err:?}"
1102 );
1103 }
1104
1105 #[test]
1106 fn non_monotonic_receipt_history_fails_closed_before_anchor_check() {
1107 let (_dir, ledger, heads) = build_ledger(3);
1108 let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
1109 let r1 = make_canonical_receipt(
1110 Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
1111 3,
1112 &heads[2],
1113 ExternalSink::Rekor,
1114 );
1115 let r2 = make_canonical_receipt(
1116 Utc.with_ymd_and_hms(2026, 5, 12, 18, 10, 0).unwrap(),
1117 1,
1118 &heads[0],
1119 ExternalSink::Rekor,
1120 );
1121 write_receipt_history(&receipts_path, &[r1, r2]);
1122
1123 let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
1124 match err {
1125 ExternalReceiptVerifyError::Parse { source, .. } => {
1126 assert!(matches!(
1127 source,
1128 ExternalReceiptParseError::NonMonotonic {
1129 receipt_index: 2,
1130 previous_event_count: 3,
1131 event_count: 1,
1132 }
1133 ));
1134 }
1135 other => panic!("expected Parse(NonMonotonic), got {other:?}"),
1136 }
1137 }
1138
1139 #[test]
1140 fn missing_receipt_history_file_fails_closed() {
1141 let dir = tempdir().unwrap();
1142 let ledger = dir.path().join("events.jsonl");
1143 std::fs::write(&ledger, "").unwrap();
1144 let receipts_path = dir.path().join("missing-receipts");
1145 let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
1146 assert!(matches!(
1147 err,
1148 ExternalReceiptVerifyError::ReadHistory { .. }
1149 ));
1150 }
1151
1152 fn near_root_now() -> DateTime<Utc> {
1155 let root = TrustedRoot::embedded().unwrap();
1159 let signed_at = root.metadata_signed_at().unwrap();
1160 signed_at + chrono::Duration::days(1)
1161 }
1162
1163 fn build_receipts_fixture() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
1164 let (dir, ledger, heads) = build_ledger(3);
1165 let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
1166 let r1 = make_canonical_receipt(
1167 Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
1168 3,
1169 &heads[2],
1170 ExternalSink::Rekor,
1171 );
1172 write_receipt_history(&receipts_path, &[r1]);
1173 (dir, ledger, receipts_path)
1174 }
1175
1176 #[test]
1177 fn fresh_cached_root_allows_verification() {
1178 let (dir, ledger, receipts_path) = build_receipts_fixture();
1179 let trust_root_path = dir.path().join("trusted_root.json");
1180 TrustedRoot::embedded()
1181 .unwrap()
1182 .write_atomic(&trust_root_path)
1183 .unwrap();
1184
1185 let now = near_root_now();
1186 let mtime_systemtime = std::time::SystemTime::UNIX_EPOCH
1194 + std::time::Duration::from_secs(now.timestamp() as u64);
1195 std::fs::File::options()
1196 .write(true)
1197 .open(&trust_root_path)
1198 .unwrap()
1199 .set_modified(mtime_systemtime)
1200 .expect("set mtime");
1201 let verification = verify_external_receipts_with_options(
1202 &ledger,
1203 &receipts_path,
1204 Some(&trust_root_path),
1205 now,
1206 DEFAULT_MAX_TRUST_ROOT_AGE,
1207 )
1208 .expect("fresh cached root verifies");
1209 assert_eq!(verification.trust_root_status, CACHED_ROOT_STATUS);
1210 assert!(verification.trust_root_signed_at.is_some());
1211 assert_eq!(verification.status, PARSED_ONLY_VERIFICATION_STATUS);
1212 }
1213
1214 #[test]
1215 fn cached_root_older_than_31_days_fails_closed_with_cache_stale_invariant() {
1216 let (dir, ledger, receipts_path) = build_receipts_fixture();
1217 let trust_root_path = dir.path().join("trusted_root.json");
1218 TrustedRoot::embedded()
1219 .unwrap()
1220 .write_atomic(&trust_root_path)
1221 .unwrap();
1222
1223 let now = Utc::now();
1228 let old_mtime = now - chrono::Duration::days(31);
1229 let mtime_systemtime = std::time::SystemTime::UNIX_EPOCH
1230 + std::time::Duration::from_secs(old_mtime.timestamp() as u64);
1231 std::fs::File::options()
1232 .write(true)
1233 .open(&trust_root_path)
1234 .unwrap()
1235 .set_modified(mtime_systemtime)
1236 .expect("set mtime");
1237
1238 let err = verify_external_receipts_with_options(
1239 &ledger,
1240 &receipts_path,
1241 Some(&trust_root_path),
1242 now,
1243 DEFAULT_MAX_TRUST_ROOT_AGE,
1244 )
1245 .unwrap_err();
1246 match err {
1247 ExternalReceiptVerifyError::TrustedRootStale {
1248 invariant,
1249 trust_root_status,
1250 ..
1251 } => {
1252 assert_eq!(invariant, TRUSTED_ROOT_CACHE_STALE_INVARIANT);
1253 assert_eq!(trust_root_status, CACHED_ROOT_STATUS);
1254 }
1255 other => panic!("expected TrustedRootStale, got {other:?}"),
1256 }
1257 }
1258
1259 #[test]
1260 fn cached_root_with_fresh_mtime_passes_even_when_metadata_old() {
1261 let (dir, ledger, receipts_path) = build_receipts_fixture();
1267 let trust_root_path = dir.path().join("trusted_root.json");
1268 TrustedRoot::embedded()
1269 .unwrap()
1270 .write_atomic(&trust_root_path)
1271 .unwrap();
1272 let now = TrustedRoot::embedded()
1277 .unwrap()
1278 .metadata_signed_at()
1279 .unwrap()
1280 + chrono::Duration::days(365);
1281 let mtime_systemtime = std::time::SystemTime::UNIX_EPOCH
1285 + std::time::Duration::from_secs(now.timestamp() as u64);
1286 std::fs::File::options()
1287 .write(true)
1288 .open(&trust_root_path)
1289 .unwrap()
1290 .set_modified(mtime_systemtime)
1291 .expect("set mtime");
1292 let verification = verify_external_receipts_with_options(
1293 &ledger,
1294 &receipts_path,
1295 Some(&trust_root_path),
1296 now,
1297 DEFAULT_MAX_TRUST_ROOT_AGE,
1298 )
1299 .expect("fresh cache mtime must pass the staleness gate");
1300 assert_eq!(verification.trust_root_status, CACHED_ROOT_STATUS);
1301 }
1302
1303 #[test]
1304 fn missing_cache_falls_back_to_embedded_and_allows_even_when_stale() {
1305 let (dir, ledger, receipts_path) = build_receipts_fixture();
1306 let missing_cache = dir.path().join("nonexistent-trusted_root.json");
1307 let now = Utc.with_ymd_and_hms(2099, 1, 1, 0, 0, 0).unwrap();
1309
1310 let verification = verify_external_receipts_with_options(
1311 &ledger,
1312 &receipts_path,
1313 Some(&missing_cache),
1314 now,
1315 DEFAULT_MAX_TRUST_ROOT_AGE,
1316 )
1317 .expect("missing cache + stale embedded still allows (warn-only)");
1318 assert_eq!(verification.trust_root_status, EMBEDDED_ROOT_STATUS);
1319 }
1320
1321 #[test]
1322 fn unparseable_cache_fails_closed_with_parse_invariant() {
1323 let (dir, ledger, receipts_path) = build_receipts_fixture();
1324 let trust_root_path = dir.path().join("trusted_root.json");
1325 std::fs::write(&trust_root_path, b"this is not valid json").unwrap();
1326
1327 let now = near_root_now();
1328 let err = verify_external_receipts_with_options(
1329 &ledger,
1330 &receipts_path,
1331 Some(&trust_root_path),
1332 now,
1333 DEFAULT_MAX_TRUST_ROOT_AGE,
1334 )
1335 .unwrap_err();
1336 match err {
1337 ExternalReceiptVerifyError::TrustedRootIo { invariant, .. } => {
1338 assert_eq!(invariant, TRUSTED_ROOT_PARSE_INVARIANT);
1339 }
1340 other => panic!("expected TrustedRootIo, got {other:?}"),
1341 }
1342 }
1343
1344 #[test]
1350 fn cached_root_with_future_dated_mtime_fails_closed_with_future_dated_invariant() {
1351 let (dir, ledger, receipts_path) = build_receipts_fixture();
1352 let trust_root_path = dir.path().join("trusted_root.json");
1353 TrustedRoot::embedded()
1354 .unwrap()
1355 .write_atomic(&trust_root_path)
1356 .unwrap();
1357
1358 let now = near_root_now();
1359 let future = now + chrono::Duration::days(365 * 70);
1362 let future_systemtime = std::time::SystemTime::UNIX_EPOCH
1363 + std::time::Duration::from_secs(future.timestamp() as u64);
1364 std::fs::File::options()
1365 .write(true)
1366 .open(&trust_root_path)
1367 .unwrap()
1368 .set_modified(future_systemtime)
1369 .expect("set mtime");
1370
1371 let err = verify_external_receipts_with_options(
1372 &ledger,
1373 &receipts_path,
1374 Some(&trust_root_path),
1375 now,
1376 DEFAULT_MAX_TRUST_ROOT_AGE,
1377 )
1378 .unwrap_err();
1379 match err {
1380 ExternalReceiptVerifyError::TrustedRootStale {
1381 invariant,
1382 trust_root_status,
1383 ..
1384 } => {
1385 assert_eq!(
1386 invariant,
1387 trusted_root::STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED
1388 );
1389 assert_eq!(trust_root_status, CACHED_ROOT_STATUS);
1390 }
1391 other => panic!("expected TrustedRootStale with cache_future_dated, got {other:?}"),
1392 }
1393 }
1394}