cortex_ledger/external_sink/trusted_root.rs
1//! TUF-rooted Rekor trust bundle (ADR 0013 Mechanism C trust channel).
2//!
3//! This module pins the **trusted_root.json** Sigstore TUF artifact used to
4//! verify Rekor `SignedEntryTimestamp` payloads. Council Decision #1 (TUF
5//! trust root channel, fail-closed when stale > 30 days) is the operator
6//! contract enforced here:
7//!
8//! 1. A known-good `trusted_root.json` snapshot is embedded into the binary
9//! via [`include_bytes!`] so that the verifier can decide what counts as
10//! valid Rekor public keys even when the operator has never run
11//! `cortex audit anchor refresh-trust`. The snapshot date is recorded
12//! in [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`].
13//! 2. Operators may stage a fresher cached `trusted_root.json` on disk via
14//! [`TrustedRoot::load_cached`]. The CLI exposes this through the
15//! `cortex audit anchor refresh-trust` subcommand.
16//! 3. [`TrustedRoot::is_stale_at`] returns `true` when an
17//! operator-supplied freshness anchor (build-time snapshot date for
18//! the embedded path, file mtime for the cached refresh path) is
19//! older than `max_age` at the supplied `now`. Default operator
20//! policy is 30 days. Anchor selection is the explicit
21//! [`TrustRootStalenessAnchor`] caller argument — the
22//! [`Self::metadata_signed_at`]-derived field is NOT a freshness
23//! proxy (Bug J + 2026-05-15 portfolio-extension footnote on ADR
24//! 0013).
25//!
26//! ## What this module does **not** do
27//!
28//! - It does not verify TUF metadata signatures. The embedded snapshot is
29//! trusted on the first build; subsequent refresh is structurally
30//! validated (parse + currency) but the cryptographic chain-of-trust
31//! over the TUF root → snapshot → targets path is an operator action
32//! gated by the live HTTP refresh flow.
33//! - It does not perform any I/O outside [`TrustedRoot::load_cached`]
34//! (read) and [`TrustedRoot::write_atomic`] (write). Both are isolated
35//! to file paths the CLI passes in.
36//! - It does not consume the Rekor public key bytes. Rekor signature
37//! verification is deferred per
38//! `docs/design/DESIGN_external_anchor_authority.md` §Residual risk.
39//! This slice unlocks the *channel* through which keys will be
40//! distributed, not the verification of an individual entry.
41//!
42//! ## Cosign GHSA-whqx-f9j3-ch6m mitigation
43//!
44//! The trust root channel is the load-bearing primitive for the Cosign
45//! advisory class: a verifier that hard-codes Rekor public keys cannot
46//! follow key rotation, and a verifier that fetches keys without TUF
47//! semantics can be tricked into accepting attacker-controlled keys.
48//! Cortex's mitigation is the same as Sigstore's: ship a TUF-validated
49//! `trusted_root.json` with explicit operator-driven refresh, and refuse
50//! to mark anchor evidence authoritative when the trust root has aged
51//! past the operator's risk-acceptance window. Subject binding stays
52//! enforced at the verifier path (see
53//! [`crate::external_sink::verify_external_receipts`]).
54
55use std::fs::OpenOptions;
56use std::io::Write;
57use std::path::{Path, PathBuf};
58use std::time::Duration;
59
60use chrono::{DateTime, NaiveDate, Utc};
61use serde::{Deserialize, Serialize};
62use thiserror::Error;
63
64/// Raw bytes for the embedded `trusted_root.json` snapshot.
65///
66/// Fetched from
67/// `https://tuf-repo-cdn.sigstore.dev/targets/<hash>.trusted_root.json` on
68/// the snapshot date below. SHA-256 of the embedded bytes:
69/// `6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66`.
70///
71/// The embed targets the binary at build time; serde validation happens at
72/// the [`TrustedRoot::embedded`] boundary so a corrupted embed cannot
73/// silently produce a usable `TrustedRoot`.
74pub const TRUSTED_ROOT_JSON: &[u8] =
75 include_bytes!("embedded/sigstore_trusted_root_2026-05-12.json");
76
77/// Operator-visible date the embedded `trusted_root.json` snapshot was
78/// pulled. Used by diagnostics so an operator can correlate a stale
79/// embedded root with the public Sigstore TUF history.
80///
81/// **F2 closure (Finding 3 in
82/// `docs/reviews/BUG_HUNT_2026-05-12_post_8f43450.md`).** This constant
83/// USED to be a hand-written sibling literal next to
84/// [`TRUSTED_ROOT_JSON`]'s `include_bytes!("embedded/sigstore_trusted_root_YYYY-MM-DD.json")`
85/// argument. A snapshot refresh that bumped the filename but forgot to
86/// bump the constant would silently anchor the 30-day freshness gate to
87/// the stale capture date. The value is now derived at build time by
88/// `crates/cortex-ledger/build.rs` from the embedded snapshot's
89/// filename, so the two literals cannot drift independently. Mutating
90/// the filename to something that does not match
91/// `sigstore_trusted_root_YYYY-MM-DD.json`, or dropping the snapshot
92/// altogether, fails the build with a hard error.
93pub const EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE: &str = env!("EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE");
94
95/// Default operator policy: a trusted root older than this is fail-closed
96/// for the external-anchor authority path. Operators may override the
97/// policy through the verify command's `--max-trust-root-age-days` flag
98/// (slice 4) when their risk-acceptance window differs.
99pub const DEFAULT_MAX_TRUST_ROOT_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
100
101/// Tolerance window for future-dated freshness anchors before they are
102/// treated as a bypass attempt.
103///
104/// Prior F3 closure (CODE_REVIEW_2026-05-12_post_fd779d7.md): a
105/// future-dated cache `mtime` (e.g. via `touch -d 2099-01-01`) used to
106/// silently pass the freshness gate because `now - mtime` went negative
107/// and the `> max_age` comparison stayed false indefinitely. Refusing on
108/// any future mtime would be too strict (filesystem clock drift between
109/// hosts is real); instead we permit a small skew and refuse anything
110/// beyond it as
111/// [`TrustRootStalenessError::CacheFutureDated`] /
112/// [`STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED`].
113pub const TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE: Duration = Duration::from_secs(60);
114
115/// Stable invariant emitted when a trusted-root cache file's `mtime` is
116/// dated more than [`TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE`] ahead of
117/// wall-clock at the freshness check. See
118/// [`TrustRootStalenessError::CacheFutureDated`].
119pub const STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED: &str =
120 "audit.verify.trusted_root.cache_future_dated";
121
122/// Stable invariant emitted when an external-anchor receipt verifier
123/// refuses to mark anchor authority authoritative because the cached
124/// trusted_root.json has aged past
125/// [`DEFAULT_MAX_TRUST_ROOT_AGE`]. CLI diagnostics and JSON envelopes
126/// surface this exact token so dashboards and grep-based wrappers can
127/// distinguish "trust root stale" from "receipt parse failure".
128///
129/// Retained as a back-compat catch-all that is emitted ALONGSIDE the
130/// branch-specific token (see [`TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT`]
131/// and [`TRUSTED_ROOT_CACHE_STALE_INVARIANT`]) so existing dashboards do
132/// not break.
133pub const TRUSTED_ROOT_STALE_INVARIANT: &str = "audit.verify.trusted_root.stale_beyond_max_age";
134
135/// Stable invariant emitted when the embedded trusted_root.json snapshot
136/// is older than [`DEFAULT_MAX_TRUST_ROOT_AGE`]. Anchor is the explicit
137/// [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`] constant — NOT the Sigstore
138/// tlog signing-key activation date inside the JSON.
139///
140/// ADR 0013 footnote (2026-05-12 clarification on Council Decision #1)
141/// codifies the anchor field-choice that closes Bug J. The 2026-05-15
142/// portfolio-extension footnote extends that fix to ALL trust-root
143/// freshness call sites, not just the production-restore drill path.
144pub const TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT: &str = "audit.verify.trusted_root.snapshot_stale";
145
146/// Stable invariant emitted when a cached `trusted_root.json` written by
147/// `cortex audit refresh-trust` is older (by file mtime) than
148/// [`DEFAULT_MAX_TRUST_ROOT_AGE`]. Anchor is the cache file's
149/// modification time — NOT the Sigstore tlog signing-key activation
150/// date.
151pub const TRUSTED_ROOT_CACHE_STALE_INVARIANT: &str = "audit.verify.trusted_root.cache_stale";
152
153/// Stable invariant emitted by [`crate::external_sink::verify_external_receipts`] when the
154/// operator-supplied `trusted_root.json` (cache or embedded) cannot be
155/// parsed at all. Distinguished from `audit.verify.trusted_root.stale_beyond_max_age`
156/// so operators see whether to refresh or to debug the file shape.
157pub const TRUSTED_ROOT_PARSE_INVARIANT: &str = "audit.verify.trusted_root.parse_error";
158
159/// Stable status emitted by the trusted-root channel when the active root
160/// is the embedded snapshot rather than a cached operator refresh.
161pub const EMBEDDED_ROOT_STATUS: &str = "embedded_snapshot";
162/// Stable status emitted by the trusted-root channel when the active root
163/// is a cached `trusted_root.json` written by `cortex audit anchor refresh-trust`.
164pub const CACHED_ROOT_STATUS: &str = "operator_cached";
165
166/// Stable invariant emitted when [`TrustedRoot::rekor_verifying_key`]
167/// refuses a Rekor receipt because no transparency-log instance in the
168/// trusted root declares a `logId.keyId` matching the receipt's
169/// `body.logID`.
170///
171/// Closes BH-3 (Finding 3 in
172/// `docs/reviews/BUG_HUNT_2026-05-12_post_8f43450.md`): the previous
173/// "latest activation wins" selector verified entries against the wrong
174/// key when `trusted_root.json` carried multiple historical tlogs
175/// (key-rotation case) or a newly-added attacker-mirror tlog (Cosign
176/// GHSA-whqx-f9j3-ch6m class). The verifier now requires log-bound
177/// selection — no silent fall-back to "latest" is permitted.
178pub const REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT: &str =
179 "rekor.trusted_root.tlog_logid_no_match";
180
181/// Parsed Sigstore `trusted_root.json` (v0.1 `protobuf-go` JSON shape).
182///
183/// Only the fields cortex needs are deserialised. Unknown fields are
184/// preserved as `serde_json::Value` so a refresh from a slightly newer
185/// schema still round-trips.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct TrustedRoot {
188 /// Media type from the Sigstore protobuf descriptor.
189 /// Example: `application/vnd.dev.sigstore.trustedroot+json;version=0.1`.
190 #[serde(rename = "mediaType")]
191 pub media_type: String,
192 /// Rekor transparency log declarations. Cortex enforces a non-empty
193 /// list at parse time; an empty array fails closed.
194 #[serde(default)]
195 pub tlogs: Vec<TransparencyLogInstance>,
196 /// Fulcio certificate authority declarations. Cortex does not consume
197 /// these on the trust path today; they are kept so the cached file is
198 /// a faithful copy and so a future signature verifier can use them.
199 #[serde(rename = "certificateAuthorities", default)]
200 pub certificate_authorities: serde_json::Value,
201 /// Certificate transparency log declarations. Same posture as
202 /// `certificateAuthorities` above.
203 #[serde(default)]
204 pub ctlogs: serde_json::Value,
205 /// RFC 3161 timestamp authority declarations.
206 #[serde(rename = "timestampAuthorities", default)]
207 pub timestamp_authorities: serde_json::Value,
208}
209
210/// Single Rekor transparency log entry inside `trusted_root.json`.
211///
212/// The full Sigstore protobuf has additional metadata; cortex's slice 1
213/// trust-channel path only consumes the activation date and the key
214/// bytes, so the rest is captured as `serde_json::Value` and round-tripped
215/// without interpretation.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct TransparencyLogInstance {
218 /// Rekor API base URL.
219 #[serde(rename = "baseUrl")]
220 pub base_url: String,
221 /// `logId.keyId` base64; opaque to cortex today.
222 #[serde(rename = "logId", default)]
223 pub log_id: serde_json::Value,
224 /// Hash algorithm declaration; opaque to cortex today.
225 #[serde(rename = "hashAlgorithm", default)]
226 pub hash_algorithm: serde_json::Value,
227 /// Public key envelope; opaque except for `validFor.start`.
228 #[serde(rename = "publicKey")]
229 pub public_key: TransparencyLogPublicKey,
230 /// Rekor log shard identity (`log_index`, `tree_size`, etc.); opaque
231 /// to cortex today.
232 #[serde(default)]
233 pub log_index: serde_json::Value,
234 /// Tree-id metadata; opaque to cortex today.
235 #[serde(rename = "treeId", default)]
236 pub tree_id: serde_json::Value,
237 /// Catch-all so a refresh from a newer schema does not drop fields.
238 #[serde(flatten)]
239 pub extra: serde_json::Map<String, serde_json::Value>,
240}
241
242/// Sigstore public-key envelope inside [`TransparencyLogInstance`].
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct TransparencyLogPublicKey {
245 /// Base64 DER public key bytes; opaque on the trust-channel path
246 /// today.
247 #[serde(rename = "rawBytes", default)]
248 pub raw_bytes: Option<String>,
249 /// Sigstore protobuf `KeyDetails` enum string (e.g.
250 /// `PKIX_ECDSA_P256_SHA_256`, `PKIX_ED25519`). Cortex emits this in
251 /// diagnostics so operators can correlate the active key shape.
252 #[serde(rename = "keyDetails", default)]
253 pub key_details: Option<String>,
254 /// Activation window for the key. `start` is the canonical "trust
255 /// root signed at" proxy cortex uses for staleness checks.
256 #[serde(rename = "validFor", default)]
257 pub valid_for: ValidityPeriod,
258 /// Catch-all for unmodelled fields.
259 #[serde(flatten)]
260 pub extra: serde_json::Map<String, serde_json::Value>,
261}
262
263/// Activation window emitted by Sigstore protobufs.
264#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
265pub struct ValidityPeriod {
266 /// Inclusive start of the window. RFC 3339; required by Sigstore.
267 #[serde(default)]
268 pub start: Option<DateTime<Utc>>,
269 /// Inclusive end of the window. Absent means "still active".
270 #[serde(default)]
271 pub end: Option<DateTime<Utc>>,
272}
273
274/// Caller-selected freshness anchor used by [`TrustedRoot::is_stale_at`].
275///
276/// Bug J fix + 2026-05-15 portfolio extension on ADR 0013 footnote:
277/// every trust-root freshness gate in Cortex MUST anchor against an
278/// operator-meaningful datum, not the Sigstore tlog signing-key
279/// activation date inside the JSON. The two operator-meaningful
280/// anchors are:
281///
282/// - **Embedded snapshot path** ([`Self::EmbeddedSnapshotDate`]) — the
283/// build-time date Cortex captured the embedded snapshot, parsed
284/// from [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`]. Fresh until 30 days
285/// past that release date.
286/// - **Cached refresh path** ([`Self::CacheFileMtime`]) — the
287/// filesystem modification time of the cache file written by
288/// `cortex audit refresh-trust`. Fresh until 30 days past the last
289/// operator-driven refresh.
290///
291/// Callers select the variant by inspecting whether they have a
292/// readable cache file at the configured cache path. The verifier
293/// helpers in [`crate::external_sink::verify_external_receipts_with_options`]
294/// and the production-restore drill follow the same convention.
295#[derive(Debug)]
296pub enum TrustRootStalenessAnchor<'a> {
297 /// Anchor against the build-time embedded snapshot date.
298 /// `snapshot_iso_date` defaults to [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`]
299 /// at construction via [`Self::embedded_snapshot()`].
300 EmbeddedSnapshotDate {
301 /// `YYYY-MM-DD` string for the embedded snapshot date. Parsed
302 /// at anchor-resolution time so a malformed constant fails
303 /// loud rather than silently treating the root as stale.
304 snapshot_iso_date: &'a str,
305 },
306 /// Anchor against a cache file's modification time.
307 CacheFileMtime {
308 /// Path to the cache file written by
309 /// `cortex audit refresh-trust`. The file MUST exist and be
310 /// readable; missing/unreadable files surface as
311 /// [`TrustRootStalenessError`] so callers can distinguish a
312 /// missing cache (fall back to embedded) from an unreadable
313 /// cache (operator-actionable I/O error).
314 cache_path: &'a Path,
315 },
316}
317
318impl<'a> TrustRootStalenessAnchor<'a> {
319 /// Build the embedded-snapshot anchor anchored to the
320 /// build-time [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`] constant.
321 #[must_use]
322 pub const fn embedded_snapshot() -> Self {
323 Self::EmbeddedSnapshotDate {
324 snapshot_iso_date: EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE,
325 }
326 }
327
328 /// Build the cache-file-mtime anchor at the supplied path.
329 #[must_use]
330 pub const fn cache_file_mtime(cache_path: &'a Path) -> Self {
331 Self::CacheFileMtime { cache_path }
332 }
333
334 /// Short, stable label used in [`TrustRootStalenessError::CacheFutureDated`]
335 /// diagnostics so the operator transcript names which anchor source
336 /// resolved to a future timestamp.
337 fn diagnostic_label(&self) -> String {
338 match self {
339 Self::EmbeddedSnapshotDate { snapshot_iso_date } => {
340 format!("embedded_snapshot_date={snapshot_iso_date}")
341 }
342 Self::CacheFileMtime { cache_path } => {
343 format!("cache_file_mtime path={}", cache_path.display())
344 }
345 }
346 }
347
348 /// Resolve the anchor into a wall-clock timestamp. `_now` is
349 /// reserved for potential future anchors (e.g. fixture-injected
350 /// clocks) but not used today.
351 fn resolve(&self, _now: DateTime<Utc>) -> Result<DateTime<Utc>, TrustRootStalenessError> {
352 match self {
353 Self::EmbeddedSnapshotDate { snapshot_iso_date } => {
354 let date =
355 NaiveDate::parse_from_str(snapshot_iso_date, "%Y-%m-%d").map_err(|err| {
356 TrustRootStalenessError::MalformedEmbeddedSnapshotDate {
357 observed: (*snapshot_iso_date).to_string(),
358 reason: err.to_string(),
359 }
360 })?;
361 let utc = date
362 .and_hms_opt(0, 0, 0)
363 .ok_or(TrustRootStalenessError::EmbeddedSnapshotMidnightConstruction)?
364 .and_utc();
365 Ok(utc)
366 }
367 Self::CacheFileMtime { cache_path } => {
368 let meta = std::fs::metadata(cache_path).map_err(|source| {
369 TrustRootStalenessError::CacheMetadata {
370 path: cache_path.to_path_buf(),
371 source,
372 }
373 })?;
374 let mtime =
375 meta.modified()
376 .map_err(|source| TrustRootStalenessError::CacheMtime {
377 path: cache_path.to_path_buf(),
378 source,
379 })?;
380 Ok(DateTime::<Utc>::from(mtime))
381 }
382 }
383 }
384}
385
386/// Errors produced while resolving a [`TrustRootStalenessAnchor`].
387///
388/// These surface to the CLI when the caller's selected anchor source
389/// is structurally unavailable (e.g. unreadable cache file). They are
390/// **not** the "trust root is stale" branch — that is the `Ok(true)`
391/// return of [`TrustedRoot::is_stale_at`].
392#[derive(Debug, Error)]
393pub enum TrustRootStalenessError {
394 /// `EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE` constant did not parse as
395 /// `YYYY-MM-DD`. Build-time bug — surfaces to operators only when
396 /// a fresh build is mis-tagged.
397 #[error(
398 "EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE `{observed}` is not RFC 3339 YYYY-MM-DD: {reason}"
399 )]
400 MalformedEmbeddedSnapshotDate {
401 /// The observed snapshot-date string that failed to parse.
402 observed: String,
403 /// Underlying chrono parse error message.
404 reason: String,
405 },
406 /// `NaiveDate::and_hms_opt(0,0,0)` failed; not actually reachable
407 /// for any well-formed date but retained so the anchor resolver
408 /// can fail closed rather than panic.
409 #[error("EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE midnight construction failed")]
410 EmbeddedSnapshotMidnightConstruction,
411 /// `std::fs::metadata` on the cache file failed (file missing,
412 /// unreadable permissions, etc.).
413 #[error("cannot stat trusted_root.json cache `{path}`: {source}", path = path.display())]
414 CacheMetadata {
415 /// Cache path the caller supplied.
416 path: PathBuf,
417 /// Underlying I/O failure.
418 source: std::io::Error,
419 },
420 /// File metadata was read but `modified()` was unsupported / failed.
421 #[error("cannot read mtime on trusted_root.json cache `{path}`: {source}", path = path.display())]
422 CacheMtime {
423 /// Cache path the caller supplied.
424 path: PathBuf,
425 /// Underlying I/O failure.
426 source: std::io::Error,
427 },
428 /// The resolved freshness anchor is dated more than the operator
429 /// tolerance ahead of wall-clock.
430 ///
431 /// Prior F3 closure: a future-dated cache `mtime` (e.g. set via
432 /// `touch -d 2099-01-01`) used to silently pass the freshness gate
433 /// because the `now - mtime > max_age` comparison goes false for a
434 /// negative duration. The check is now fail-closed: any anchor more
435 /// than [`TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE`] in the future
436 /// surfaces this error and the caller MUST refuse the operation with
437 /// [`STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED`].
438 #[error(
439 "trusted_root anchor `{anchor}` is dated {anchor_ts} which is more than {tolerance_seconds}s ahead of now={now}: \
440 refusing freshness gate (future-dated bypass guard)"
441 )]
442 CacheFutureDated {
443 /// Diagnostic label naming the anchor source (`embedded_snapshot_date=…`
444 /// or `cache_file_mtime path=…`).
445 anchor: String,
446 /// Resolved anchor timestamp (the future-dated value).
447 anchor_ts: DateTime<Utc>,
448 /// Wall-clock at the check.
449 now: DateTime<Utc>,
450 /// Tolerance window expressed in seconds (matches
451 /// [`TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE`]).
452 tolerance_seconds: u64,
453 },
454}
455
456impl TrustedRoot {
457 /// Parse the embedded snapshot. Panics only when the embedded JSON is
458 /// structurally invalid — which is a build-time bug, not a runtime
459 /// failure mode.
460 ///
461 /// Operators MUST NOT rely on the embedded root staying fresh; it is
462 /// the fail-closed floor that exists so verifying with a brand-new
463 /// data directory does not silently degrade. Refresh is an operator
464 /// action.
465 pub fn embedded() -> Result<Self, TrustedRootParseError> {
466 Self::parse_bytes(TRUSTED_ROOT_JSON)
467 }
468
469 /// Parse a `trusted_root.json` payload supplied as raw bytes.
470 pub fn parse_bytes(bytes: &[u8]) -> Result<Self, TrustedRootParseError> {
471 let root: Self =
472 serde_json::from_slice(bytes).map_err(|source| TrustedRootParseError::Malformed {
473 reason: source.to_string(),
474 })?;
475 root.validate()?;
476 Ok(root)
477 }
478
479 /// Read a cached `trusted_root.json` off disk. Missing files return
480 /// `Ok(None)` so callers can fall back to [`Self::embedded`]; parse
481 /// failures fail closed.
482 pub fn load_cached(path: impl AsRef<Path>) -> Result<Option<Self>, TrustedRootIoError> {
483 let path = path.as_ref();
484 let bytes = match std::fs::read(path) {
485 Ok(bytes) => bytes,
486 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
487 Err(source) => {
488 return Err(TrustedRootIoError::Read {
489 path: path.to_path_buf(),
490 source,
491 });
492 }
493 };
494 Self::parse_bytes(&bytes)
495 .map(Some)
496 .map_err(|source| TrustedRootIoError::Parse {
497 path: path.to_path_buf(),
498 source,
499 })
500 }
501
502 /// Validate the structural invariants cortex enforces on every
503 /// `trusted_root.json`:
504 ///
505 /// - `mediaType` is non-empty and starts with the Sigstore
506 /// `application/vnd.dev.sigstore.trustedroot+json` prefix so a
507 /// misrouted file (e.g. `targets.json`, `timestamp.json`) cannot be
508 /// silently accepted.
509 /// - At least one tlog is declared; an empty list cannot witness
510 /// anything and is treated as a hard parse failure rather than as
511 /// "trust nothing".
512 /// - Every tlog has a parseable activation start.
513 pub fn validate(&self) -> Result<(), TrustedRootParseError> {
514 if !self
515 .media_type
516 .starts_with("application/vnd.dev.sigstore.trustedroot+json")
517 {
518 return Err(TrustedRootParseError::WrongMediaType {
519 observed: self.media_type.clone(),
520 });
521 }
522 if self.tlogs.is_empty() {
523 return Err(TrustedRootParseError::EmptyTlogs);
524 }
525 for (index, tlog) in self.tlogs.iter().enumerate() {
526 if tlog.base_url.is_empty() {
527 return Err(TrustedRootParseError::MissingBaseUrl { tlog_index: index });
528 }
529 if tlog.public_key.valid_for.start.is_none() {
530 return Err(TrustedRootParseError::MissingValidityStart { tlog_index: index });
531 }
532 }
533 Ok(())
534 }
535
536 /// Latest tlog activation timestamp across every transparency log in
537 /// the root. Used as the proxy for "this trust root was signed at"
538 /// — a fresher activation entry means the trust root saw at least
539 /// one signing-key rotation event after that date.
540 ///
541 /// Returns `None` only when validation was skipped or every tlog
542 /// lacked a start (which `validate` would have rejected).
543 #[must_use]
544 pub fn metadata_signed_at(&self) -> Option<DateTime<Utc>> {
545 self.tlogs
546 .iter()
547 .filter_map(|tlog| tlog.public_key.valid_for.start)
548 .max()
549 }
550
551 /// Return `true` when `now - anchor` exceeds `max_age`, where the
552 /// `anchor` is the explicit freshness datum the caller selected via
553 /// [`TrustRootStalenessAnchor`].
554 ///
555 /// Bug J + 2026-05-15 portfolio-extension fix: callers MUST pass an
556 /// explicit anchor source rather than letting the implementation
557 /// fall through to [`Self::metadata_signed_at`] — the latter is the
558 /// Sigstore tlog signing-key activation date, which rotates rarely
559 /// (months/years) and would make every release immediately stale
560 /// under a 30-day policy. The correct anchors are documented on
561 /// [`TrustRootStalenessAnchor`].
562 ///
563 /// Returns `Err(TrustRootStalenessError)` only when the anchor
564 /// source cannot be resolved (e.g. cache file unreadable,
565 /// embedded snapshot date constant is malformed). I/O failures on
566 /// the cache file are reported verbatim so callers can map them to
567 /// the correct CLI exit code; they are NOT silently treated as
568 /// stale.
569 pub fn is_stale_at(
570 &self,
571 now: DateTime<Utc>,
572 max_age: Duration,
573 anchor: TrustRootStalenessAnchor<'_>,
574 ) -> Result<bool, TrustRootStalenessError> {
575 let anchor_ts = anchor.resolve(now)?;
576 // Prior F3 closure: refuse a future-dated freshness anchor before
577 // any direction-sensitive comparison. The bug used to be
578 // let age = now.signed_duration_since(anchor_ts);
579 // age > max_age
580 // returning false when `anchor_ts > now` (the `Duration` goes
581 // negative). An operator who runs `touch -d 2099-01-01` on the
582 // cached `trusted_root.json` would then bypass the staleness
583 // window indefinitely. We allow a small tolerance for legitimate
584 // wall-clock skew (e.g. between an NFS server and the host) and
585 // refuse anything beyond it as a hard error so callers can map
586 // the case to a stable invariant.
587 let tolerance = chrono::Duration::from_std(TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE)
588 .unwrap_or_default();
589 if anchor_ts > now + tolerance {
590 return Err(TrustRootStalenessError::CacheFutureDated {
591 anchor: anchor.diagnostic_label(),
592 anchor_ts,
593 now,
594 tolerance_seconds: TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE.as_secs(),
595 });
596 }
597 // F3 closure: use saturating millisecond arithmetic instead of
598 // chrono's `now - anchor_ts` subtraction, which can produce
599 // unexpected values near i64::MIN/MAX (e.g. an adversarially
600 // crafted anchor near DateTime::MIN could underflow the i64
601 // millisecond counter and produce a large positive duration that
602 // falsely reports "not stale"). `saturating_sub` clamps to i64::MAX
603 // (always-stale) on underflow and to 0 (not-stale) on overflow,
604 // both of which are safe failure modes for this gate.
605 let age_ms = now
606 .timestamp_millis()
607 .saturating_sub(anchor_ts.timestamp_millis());
608 let max_age_ms = chrono::Duration::from_std(max_age)
609 .unwrap_or(chrono::Duration::MAX)
610 .num_milliseconds();
611 Ok(age_ms > max_age_ms)
612 }
613
614 /// Convenience alias for [`Self::is_stale_at`] with the default
615 /// operator policy ([`DEFAULT_MAX_TRUST_ROOT_AGE`]). Used by the
616 /// Rekor live adapter (council Decision #2) to keep the call sites
617 /// readable.
618 pub fn is_stale(
619 &self,
620 now: DateTime<Utc>,
621 anchor: TrustRootStalenessAnchor<'_>,
622 ) -> Result<bool, TrustRootStalenessError> {
623 self.is_stale_at(now, DEFAULT_MAX_TRUST_ROOT_AGE, anchor)
624 }
625
626 /// Convenience alias for [`Self::embedded`]. Used by the Rekor live
627 /// adapter (council Decision #2) which was authored against the
628 /// pre-merge shim API before Decision #1 landed.
629 pub fn from_embedded() -> Result<Self, TrustedRootParseError> {
630 Self::embedded()
631 }
632
633 /// Construct a single-tlog `TrustedRoot` from a fixture
634 /// ECDSA P-256 SubjectPublicKeyInfo PEM. Test-only path used by
635 /// adapter unit tests that synthesise their own Rekor receipts with
636 /// a deterministic signing seed and need a matching verifying key
637 /// out of the trust root. Activation is pinned to `signed_at` so
638 /// the staleness gate can be exercised deterministically.
639 ///
640 /// `log_id` is the `logId.keyId` string the constructed tlog will
641 /// declare. BH-3 fix
642 /// (`docs/reviews/BUG_HUNT_2026-05-12_post_8f43450.md` Finding 3)
643 /// requires log-bound key selection; pass the same string the
644 /// fixture's Rekor receipt declares as `body.logID` so
645 /// [`Self::rekor_verifying_key`] resolves the fixture key.
646 #[cfg(any(test, feature = "test-support"))]
647 pub fn from_fixture_rekor_pem(
648 pem: &str,
649 signed_at: DateTime<Utc>,
650 log_id: &str,
651 ) -> Result<Self, TrustedRootKeyError> {
652 use base64::Engine;
653 use p256::pkcs8::{DecodePublicKey, EncodePublicKey};
654
655 let key = p256::ecdsa::VerifyingKey::from_public_key_pem(pem).map_err(|source| {
656 TrustedRootKeyError::DecodeKey {
657 reason: source.to_string(),
658 }
659 })?;
660 let encoded = key
661 .to_public_key_der()
662 .map_err(|source| TrustedRootKeyError::DecodeKey {
663 reason: source.to_string(),
664 })?;
665 let raw_b64 = base64::engine::general_purpose::STANDARD.encode(encoded.as_bytes());
666 Ok(Self {
667 media_type: "application/vnd.dev.sigstore.trustedroot+json;version=0.1".to_string(),
668 tlogs: vec![TransparencyLogInstance {
669 base_url: "https://rekor.test.fixture".to_string(),
670 log_id: serde_json::json!({ "keyId": log_id }),
671 hash_algorithm: serde_json::Value::String("SHA2_256".to_string()),
672 public_key: TransparencyLogPublicKey {
673 raw_bytes: Some(raw_b64),
674 key_details: Some("PKIX_ECDSA_P256_SHA_256".to_string()),
675 valid_for: ValidityPeriod {
676 start: Some(signed_at),
677 end: None,
678 },
679 extra: serde_json::Map::new(),
680 },
681 log_index: serde_json::Value::Null,
682 tree_id: serde_json::Value::Null,
683 extra: serde_json::Map::new(),
684 }],
685 ctlogs: serde_json::Value::Null,
686 certificate_authorities: serde_json::Value::Null,
687 timestamp_authorities: serde_json::Value::Null,
688 })
689 }
690
691 /// Best-effort signed-at accessor for the staleness gate.
692 ///
693 /// Falls back to the Unix epoch when no tlog activation is parseable —
694 /// trust-root validation refuses that case earlier, so the fallback is
695 /// only reachable when the operator deliberately constructs a trust
696 /// root with no tlog. Callers that need to distinguish "no activation"
697 /// from a real timestamp should use [`Self::metadata_signed_at`].
698 #[must_use]
699 pub fn signed_at(&self) -> DateTime<Utc> {
700 self.metadata_signed_at()
701 .unwrap_or_else(|| DateTime::<Utc>::from_timestamp(0, 0).expect("epoch is valid"))
702 }
703
704 /// Return the parsed ECDSA P-256 verifying key for the Rekor
705 /// `SignedEntryTimestamp` signature **bound to the receipt's
706 /// `body.logID`**.
707 ///
708 /// BH-3 fix (Finding 3 in
709 /// `docs/reviews/BUG_HUNT_2026-05-12_post_8f43450.md`,
710 /// Cosign GHSA-whqx-f9j3-ch6m class). The selector finds the tlog
711 /// whose `logId.keyId` byte-decodes to the same value as the
712 /// receipt's `body.logID`, then returns that tlog's ECDSA P-256
713 /// public key. It does **not** silently fall back to a "latest
714 /// activation" tlog when the logId is unknown — Sigstore's
715 /// `trusted_root.json` declares multiple historical tlogs during
716 /// rotation, each with a distinct `logId` and key; an entry signed
717 /// by an older tlog (or a newly-added attacker-controlled tlog
718 /// that wins the activation-date race) would otherwise be verified
719 /// against the wrong key.
720 ///
721 /// Encoding: Sigstore's `logId.keyId` is base64 of the raw bytes
722 /// (typically SHA-256(public_key_der)); Rekor's REST API returns
723 /// `body.logID` as the same bytes in lowercase hex. The match
724 /// function decodes both sides to bytes (preferring hex then
725 /// base64 for the receipt, base64 then raw for the tlog id) and
726 /// compares any pair for equality. Fixture log-ids that are
727 /// neither hex nor base64 (e.g. the literal `"fixture-log-id"`
728 /// used in unit tests) are matched on raw byte equality so the
729 /// fixture surface keeps working without inflating the production
730 /// matching rules.
731 ///
732 /// Fails closed with [`TrustedRootKeyError::TlogLogIdNoMatch`]
733 /// (carrying the stable invariant
734 /// [`REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT`]) when no
735 /// declared tlog matches the receipt's logId, and with the
736 /// existing [`TrustedRootKeyError`] variants when the matched
737 /// tlog's `rawBytes` is missing or does not decode as DER
738 /// SubjectPublicKeyInfo.
739 pub fn rekor_verifying_key(
740 &self,
741 receipt_log_id: &str,
742 ) -> Result<p256::ecdsa::VerifyingKey, TrustedRootKeyError> {
743 use p256::pkcs8::DecodePublicKey;
744
745 // 1. Filter to tlogs declaring ECDSA P-256 — Cortex does not
746 // yet accept Ed25519 Rekor signatures, so we must NOT pick
747 // an Ed25519 tlog even if its logId happened to match.
748 let ecdsa_tlogs = self
749 .tlogs
750 .iter()
751 .filter(|tlog| {
752 tlog.public_key
753 .key_details
754 .as_deref()
755 .is_some_and(|details| details.contains("ECDSA_P256"))
756 })
757 .collect::<Vec<_>>();
758 if ecdsa_tlogs.is_empty() {
759 return Err(TrustedRootKeyError::NoEcdsaP256Tlog);
760 }
761
762 // 2. Of those, find the one whose `logId.keyId` matches the
763 // receipt's `body.logID`. No silent fall-back to "latest
764 // activation" — refusal is the explicit Cosign mitigation.
765 let tlog = ecdsa_tlogs
766 .into_iter()
767 .find(|tlog| {
768 tlog_log_id_key_id(&tlog.log_id)
769 .as_deref()
770 .is_some_and(|tlog_key_id| log_id_matches(tlog_key_id, receipt_log_id))
771 })
772 .ok_or_else(|| TrustedRootKeyError::TlogLogIdNoMatch {
773 invariant: REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT,
774 receipt_log_id: receipt_log_id.to_string(),
775 tlog_log_ids: self
776 .tlogs
777 .iter()
778 .filter_map(|tlog| tlog_log_id_key_id(&tlog.log_id))
779 .collect(),
780 })?;
781
782 // 3. Decode the matched tlog's DER SPKI to a typed P-256
783 // VerifyingKey. Same fail-closed semantics as the previous
784 // implementation for the remaining branches.
785 use base64::Engine;
786 let raw_b64 = tlog
787 .public_key
788 .raw_bytes
789 .as_deref()
790 .ok_or(TrustedRootKeyError::MissingRawBytes)?;
791 let der = base64::engine::general_purpose::STANDARD
792 .decode(raw_b64)
793 .map_err(|source| TrustedRootKeyError::Base64 {
794 reason: source.to_string(),
795 })?;
796 p256::ecdsa::VerifyingKey::from_public_key_der(&der).map_err(|source| {
797 TrustedRootKeyError::DecodeKey {
798 reason: source.to_string(),
799 }
800 })
801 }
802
803 /// Atomically install this trusted root at `path`.
804 ///
805 /// Writes to a sibling temp file then renames into place so a partial
806 /// write cannot leave a half-written trust root on disk for the next
807 /// verify call to load.
808 pub fn write_atomic(&self, path: impl AsRef<Path>) -> Result<(), TrustedRootIoError> {
809 let path = path.as_ref();
810 let parent = path.parent().unwrap_or_else(|| Path::new("."));
811 std::fs::create_dir_all(parent).map_err(|source| TrustedRootIoError::Write {
812 path: path.to_path_buf(),
813 source,
814 })?;
815 let temp = path.with_extension("json.tmp");
816 let body = serde_json::to_vec(self).map_err(|source| TrustedRootIoError::Serialize {
817 path: path.to_path_buf(),
818 source,
819 })?;
820 {
821 let mut file = OpenOptions::new()
822 .write(true)
823 .create(true)
824 .truncate(true)
825 .open(&temp)
826 .map_err(|source| TrustedRootIoError::Write {
827 path: temp.clone(),
828 source,
829 })?;
830 file.write_all(&body)
831 .map_err(|source| TrustedRootIoError::Write {
832 path: temp.clone(),
833 source,
834 })?;
835 file.sync_all()
836 .map_err(|source| TrustedRootIoError::Write {
837 path: temp.clone(),
838 source,
839 })?;
840 }
841 std::fs::rename(&temp, path).map_err(|source| TrustedRootIoError::Write {
842 path: path.to_path_buf(),
843 source,
844 })
845 }
846}
847
848/// Bundle returned by [`active_trusted_root`] describing which root is in
849/// force for the current verification call.
850#[derive(Debug, Clone, PartialEq, Eq)]
851pub struct ActiveTrustedRoot {
852 /// Parsed trust root currently in force.
853 pub root: TrustedRoot,
854 /// Stable status token: [`EMBEDDED_ROOT_STATUS`] when the embedded
855 /// snapshot is in force, [`CACHED_ROOT_STATUS`] when a cached file
856 /// was loaded.
857 pub status: &'static str,
858 /// Cache path that was inspected. `None` when the caller did not
859 /// supply one.
860 pub cache_path: Option<PathBuf>,
861}
862
863/// Resolve the active trusted root for verification: cached file if
864/// present and parseable, embedded snapshot otherwise. The embedded
865/// snapshot is the fail-closed floor.
866pub fn active_trusted_root(
867 cache_path: Option<&Path>,
868) -> Result<ActiveTrustedRoot, TrustedRootIoError> {
869 if let Some(path) = cache_path {
870 if let Some(root) = TrustedRoot::load_cached(path)? {
871 return Ok(ActiveTrustedRoot {
872 root,
873 status: CACHED_ROOT_STATUS,
874 cache_path: Some(path.to_path_buf()),
875 });
876 }
877 }
878 let root = TrustedRoot::embedded().map_err(|source| TrustedRootIoError::Parse {
879 path: PathBuf::from("<embedded>"),
880 source,
881 })?;
882 Ok(ActiveTrustedRoot {
883 root,
884 status: EMBEDDED_ROOT_STATUS,
885 cache_path: cache_path.map(Path::to_path_buf),
886 })
887}
888
889/// Errors produced while parsing a `trusted_root.json` payload.
890#[derive(Debug, Clone, PartialEq, Eq, Error)]
891pub enum TrustedRootParseError {
892 /// Bytes did not parse as JSON in the Sigstore trust-root shape.
893 #[error("malformed trusted_root.json: {reason}")]
894 Malformed {
895 /// Underlying serde decode message.
896 reason: String,
897 },
898 /// `mediaType` did not start with the Sigstore trust-root prefix.
899 #[error("trusted_root.json mediaType `{observed}` is not application/vnd.dev.sigstore.trustedroot+json")]
900 WrongMediaType {
901 /// `mediaType` value observed in the payload.
902 observed: String,
903 },
904 /// Trust root declared zero Rekor tlogs.
905 #[error("trusted_root.json has no Rekor tlogs declared")]
906 EmptyTlogs,
907 /// Trust root declared a tlog with no Rekor `baseUrl`.
908 #[error("trusted_root.json tlog at index {tlog_index} is missing baseUrl")]
909 MissingBaseUrl {
910 /// Zero-based tlog index in the payload.
911 tlog_index: usize,
912 },
913 /// Trust root declared a tlog with no `validFor.start` timestamp.
914 #[error("trusted_root.json tlog at index {tlog_index} is missing publicKey.validFor.start")]
915 MissingValidityStart {
916 /// Zero-based tlog index in the payload.
917 tlog_index: usize,
918 },
919}
920
921/// Errors produced when reading or writing a cached trust root.
922#[derive(Debug, Error)]
923pub enum TrustedRootIoError {
924 /// Cache file could not be read off disk.
925 #[error("failed to read trusted_root.json {path:?}: {source}")]
926 Read {
927 /// Cache path that was being read.
928 path: PathBuf,
929 /// Underlying I/O failure.
930 source: std::io::Error,
931 },
932 /// Cache file existed but did not parse.
933 #[error("invalid trusted_root.json {path:?}: {source}")]
934 Parse {
935 /// Cache path that was being parsed.
936 path: PathBuf,
937 /// Underlying parse failure.
938 source: TrustedRootParseError,
939 },
940 /// Cache file could not be serialised before write.
941 #[error("failed to serialise trusted_root.json for {path:?}: {source}")]
942 Serialize {
943 /// Cache path that was being written.
944 path: PathBuf,
945 /// Underlying serde encode failure.
946 source: serde_json::Error,
947 },
948 /// Cache file could not be written to disk.
949 #[error("failed to write trusted_root.json {path:?}: {source}")]
950 Write {
951 /// Cache path that was being written.
952 path: PathBuf,
953 /// Underlying I/O failure.
954 source: std::io::Error,
955 },
956}
957
958/// Errors when extracting a typed Rekor verifying key from the active
959/// trusted root.
960#[derive(Debug, Error)]
961pub enum TrustedRootKeyError {
962 /// No transparency-log instance in the trusted root declared an
963 /// ECDSA P-256 key. Cortex does not (yet) accept Ed25519 Rekor
964 /// signatures.
965 #[error("trusted_root.json has no ECDSA P-256 tlog entry")]
966 NoEcdsaP256Tlog,
967 /// No transparency-log instance declared a `logId.keyId` that
968 /// matches the Rekor receipt's `body.logID`. Closes BH-3
969 /// (`docs/reviews/BUG_HUNT_2026-05-12_post_8f43450.md` Finding 3,
970 /// Cosign GHSA-whqx-f9j3-ch6m class). Refusal is structural — there
971 /// is no silent fall-back to the latest-activated tlog.
972 #[error(
973 "{invariant}: trusted_root.json has no tlog whose logId.keyId matches Rekor receipt logID `{receipt_log_id}` (declared tlogs: {})",
974 tlog_log_ids.join(", ")
975 )]
976 TlogLogIdNoMatch {
977 /// Stable invariant token, equal to
978 /// [`REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT`].
979 invariant: &'static str,
980 /// `body.logID` from the Rekor receipt that failed to bind.
981 receipt_log_id: String,
982 /// The tlog `logId.keyId` values declared in the trusted root,
983 /// preserved verbatim from the JSON so operators can correlate
984 /// with the Sigstore TUF history. Empty when every tlog
985 /// omitted `logId.keyId`.
986 tlog_log_ids: Vec<String>,
987 },
988 /// The selected tlog declared no `rawBytes` field for its
989 /// `publicKey`. The verifier refuses to fall back to inferring keys
990 /// from other channels.
991 #[error("trusted_root.json tlog publicKey is missing rawBytes")]
992 MissingRawBytes,
993 /// `rawBytes` was present but did not decode as base64.
994 #[error("trusted_root.json tlog publicKey rawBytes is not base64: {reason}")]
995 Base64 {
996 /// Underlying base64 decode failure.
997 reason: String,
998 },
999 /// The decoded DER bytes did not parse as a P-256 SubjectPublicKeyInfo.
1000 #[error("trusted_root.json tlog publicKey did not decode as P-256 SPKI: {reason}")]
1001 DecodeKey {
1002 /// Underlying p256/pkcs8 failure.
1003 reason: String,
1004 },
1005}
1006
1007/// Project a [`TransparencyLogInstance::log_id`] JSON value to its
1008/// `keyId` string, or `None` if the tlog declared no `logId.keyId`.
1009///
1010/// Sigstore's protobuf-go JSON encodes `LogId` as
1011/// `{"keyId": "<base64>"}`; we round-trip via `serde_json::Value` to
1012/// avoid binding the field shape on the trust-channel path. Returns
1013/// `None` for missing / non-string `keyId` rather than failing closed
1014/// here — the no-match branch in [`TrustedRoot::rekor_verifying_key`]
1015/// surfaces the verifier-visible refusal.
1016fn tlog_log_id_key_id(log_id: &serde_json::Value) -> Option<String> {
1017 log_id
1018 .as_object()?
1019 .get("keyId")
1020 .and_then(serde_json::Value::as_str)
1021 .map(str::to_string)
1022}
1023
1024/// Match a trusted-root tlog `logId.keyId` against a Rekor receipt
1025/// `body.logID`. Returns `true` when the two encode the same byte
1026/// sequence.
1027///
1028/// Decoding rules (BH-3 fix + post-fd779d7 base64-url widening):
1029/// - The tlog side is canonically base64 per Sigstore's protobuf-JSON
1030/// shape. Both STANDARD (`+/`, padded) and URL-safe (`-_`, padded
1031/// or unpadded) alphabets are accepted, because proto3 JSON encodes
1032/// binary fields as URL-safe base64 (RFC 7515 §2) — the doctrine
1033/// reference cited by the previous docstring. Raw bytes equality
1034/// is also accepted for fixtures that pre-date the BH-3 fix and
1035/// stash a human-readable string.
1036/// - The receipt side is canonically lowercase hex per Rekor's REST
1037/// API; we also accept STANDARD and URL-safe base64 for receipt
1038/// sources that emit the protobuf-JSON shape, and raw bytes
1039/// equality for fixtures.
1040///
1041/// Match holds when ANY candidate byte vector on the tlog side equals
1042/// ANY candidate byte vector on the receipt side.
1043fn log_id_matches(tlog_key_id: &str, receipt_log_id: &str) -> bool {
1044 use base64::Engine;
1045 let b64_std = base64::engine::general_purpose::STANDARD;
1046 let b64_url = base64::engine::general_purpose::URL_SAFE;
1047 let b64_url_nopad = base64::engine::general_purpose::URL_SAFE_NO_PAD;
1048
1049 let mut tlog_candidates: Vec<Vec<u8>> = Vec::with_capacity(4);
1050 if let Ok(bytes) = b64_std.decode(tlog_key_id) {
1051 tlog_candidates.push(bytes);
1052 }
1053 if let Ok(bytes) = b64_url.decode(tlog_key_id) {
1054 tlog_candidates.push(bytes);
1055 }
1056 if let Ok(bytes) = b64_url_nopad.decode(tlog_key_id) {
1057 tlog_candidates.push(bytes);
1058 }
1059 tlog_candidates.push(tlog_key_id.as_bytes().to_vec());
1060
1061 let mut receipt_candidates: Vec<Vec<u8>> = Vec::with_capacity(5);
1062 if let Ok(bytes) = decode_lowercase_hex(receipt_log_id) {
1063 receipt_candidates.push(bytes);
1064 }
1065 if let Ok(bytes) = b64_std.decode(receipt_log_id) {
1066 receipt_candidates.push(bytes);
1067 }
1068 if let Ok(bytes) = b64_url.decode(receipt_log_id) {
1069 receipt_candidates.push(bytes);
1070 }
1071 if let Ok(bytes) = b64_url_nopad.decode(receipt_log_id) {
1072 receipt_candidates.push(bytes);
1073 }
1074 receipt_candidates.push(receipt_log_id.as_bytes().to_vec());
1075
1076 for tlog_bytes in &tlog_candidates {
1077 for receipt_bytes in &receipt_candidates {
1078 if tlog_bytes == receipt_bytes {
1079 return true;
1080 }
1081 }
1082 }
1083 false
1084}
1085
1086/// Decode a lowercase ASCII hex string to bytes. Returns `Err(())` on
1087/// any non-hex character or odd length. Kept local to this module so
1088/// the BH-3 matcher does not pull a hex decoder transitively into the
1089/// trust-channel path.
1090fn decode_lowercase_hex(s: &str) -> Result<Vec<u8>, ()> {
1091 if s.is_empty() || s.len() % 2 != 0 {
1092 return Err(());
1093 }
1094 let bytes = s.as_bytes();
1095 let mut out = Vec::with_capacity(s.len() / 2);
1096 for chunk in bytes.chunks_exact(2) {
1097 let hi = hex_nibble(chunk[0])?;
1098 let lo = hex_nibble(chunk[1])?;
1099 out.push((hi << 4) | lo);
1100 }
1101 Ok(out)
1102}
1103
1104fn hex_nibble(b: u8) -> Result<u8, ()> {
1105 match b {
1106 b'0'..=b'9' => Ok(b - b'0'),
1107 b'a'..=b'f' => Ok(10 + b - b'a'),
1108 _ => Err(()),
1109 }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115 use chrono::TimeZone;
1116 use tempfile::tempdir;
1117
1118 #[test]
1119 fn embedded_root_parses_and_has_rekor_tlogs() {
1120 let root = TrustedRoot::embedded().expect("embedded root parses");
1121 assert!(root
1122 .media_type
1123 .starts_with("application/vnd.dev.sigstore.trustedroot+json"));
1124 assert!(!root.tlogs.is_empty(), "embedded root must declare tlogs");
1125 let signed_at = root
1126 .metadata_signed_at()
1127 .expect("embedded root has a validity start");
1128 // Sanity floor: the embedded snapshot must carry a tlog
1129 // activation after the Sigstore v1 launch.
1130 let v1_launch = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap();
1131 assert!(
1132 signed_at >= v1_launch,
1133 "tlog start {signed_at} predates v1 launch"
1134 );
1135 }
1136
1137 fn embedded_snapshot_anchor_date() -> DateTime<Utc> {
1138 NaiveDate::parse_from_str(EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE, "%Y-%m-%d")
1139 .expect("EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE parses")
1140 .and_hms_opt(0, 0, 0)
1141 .expect("midnight is valid")
1142 .and_utc()
1143 }
1144
1145 #[test]
1146 fn fresh_root_is_not_stale_within_30_days_against_embedded_snapshot_anchor() {
1147 let root = TrustedRoot::embedded().unwrap();
1148 let anchor = embedded_snapshot_anchor_date();
1149 let now = anchor + chrono::Duration::days(29);
1150 let stale = root
1151 .is_stale_at(
1152 now,
1153 DEFAULT_MAX_TRUST_ROOT_AGE,
1154 TrustRootStalenessAnchor::embedded_snapshot(),
1155 )
1156 .expect("anchor resolves");
1157 assert!(!stale);
1158 }
1159
1160 #[test]
1161 fn root_is_stale_after_31_days_against_embedded_snapshot_anchor() {
1162 let root = TrustedRoot::embedded().unwrap();
1163 let anchor = embedded_snapshot_anchor_date();
1164 let now = anchor + chrono::Duration::days(31);
1165 let stale = root
1166 .is_stale_at(
1167 now,
1168 DEFAULT_MAX_TRUST_ROOT_AGE,
1169 TrustRootStalenessAnchor::embedded_snapshot(),
1170 )
1171 .expect("anchor resolves");
1172 assert!(stale);
1173 }
1174
1175 #[test]
1176 fn cache_mtime_anchor_resolves_to_file_mtime() {
1177 let tmp = tempdir().unwrap();
1178 let path = tmp.path().join("trusted_root.json");
1179 let root = TrustedRoot::embedded().unwrap();
1180 root.write_atomic(&path).unwrap();
1181 let now = Utc::now();
1182 let old_mtime = now - chrono::Duration::days(40);
1183 let mtime_systemtime = std::time::SystemTime::UNIX_EPOCH
1184 + std::time::Duration::from_secs(old_mtime.timestamp() as u64);
1185 std::fs::File::options()
1186 .write(true)
1187 .open(&path)
1188 .unwrap()
1189 .set_modified(mtime_systemtime)
1190 .expect("set mtime");
1191 let stale = root
1192 .is_stale_at(
1193 now,
1194 DEFAULT_MAX_TRUST_ROOT_AGE,
1195 TrustRootStalenessAnchor::cache_file_mtime(&path),
1196 )
1197 .expect("anchor resolves");
1198 assert!(stale, "40-day-old cache must be stale");
1199 }
1200
1201 #[test]
1202 fn cache_mtime_anchor_fails_loud_when_cache_missing() {
1203 let tmp = tempdir().unwrap();
1204 let path = tmp.path().join("does-not-exist.json");
1205 let root = TrustedRoot::embedded().unwrap();
1206 let err = root
1207 .is_stale_at(
1208 Utc::now(),
1209 DEFAULT_MAX_TRUST_ROOT_AGE,
1210 TrustRootStalenessAnchor::cache_file_mtime(&path),
1211 )
1212 .unwrap_err();
1213 assert!(matches!(err, TrustRootStalenessError::CacheMetadata { .. }));
1214 }
1215
1216 // -----------------------------------------------------------------
1217 // Prior F3 closure
1218 // (`docs/reviews/CODE_REVIEW_2026-05-12_post_fd779d7.md`):
1219 // a future-dated cache `mtime` (e.g. via `touch -d 2099-01-01`)
1220 // used to bypass the freshness gate because `now - mtime` went
1221 // negative and the `> max_age` comparison stayed false
1222 // indefinitely. `is_stale_at` now refuses with
1223 // `TrustRootStalenessError::CacheFutureDated` when the anchor
1224 // is more than [`TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE`]
1225 // ahead of `now`.
1226 // -----------------------------------------------------------------
1227
1228 fn write_cache_with_systemtime(path: &Path, mtime: std::time::SystemTime) {
1229 let root = TrustedRoot::embedded().unwrap();
1230 root.write_atomic(path).unwrap();
1231 std::fs::File::options()
1232 .write(true)
1233 .open(path)
1234 .unwrap()
1235 .set_modified(mtime)
1236 .expect("set mtime");
1237 }
1238
1239 #[test]
1240 fn cache_mtime_anchor_refuses_far_future_dated_cache() {
1241 let tmp = tempdir().unwrap();
1242 let path = tmp.path().join("trusted_root.json");
1243 let now = Utc::now();
1244 // 70 years ahead — the original Bug 3 attack shape
1245 // (`touch -d 2099-01-01`).
1246 let future = now + chrono::Duration::days(365 * 70);
1247 let future_systemtime = std::time::SystemTime::UNIX_EPOCH
1248 + std::time::Duration::from_secs(future.timestamp() as u64);
1249 write_cache_with_systemtime(&path, future_systemtime);
1250 let root = TrustedRoot::embedded().unwrap();
1251 let err = root
1252 .is_stale_at(
1253 now,
1254 DEFAULT_MAX_TRUST_ROOT_AGE,
1255 TrustRootStalenessAnchor::cache_file_mtime(&path),
1256 )
1257 .unwrap_err();
1258 match err {
1259 TrustRootStalenessError::CacheFutureDated {
1260 anchor,
1261 tolerance_seconds,
1262 ..
1263 } => {
1264 assert!(
1265 anchor.contains("cache_file_mtime"),
1266 "diagnostic label must name the cache_file_mtime anchor: got {anchor}"
1267 );
1268 assert_eq!(
1269 tolerance_seconds,
1270 TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE.as_secs(),
1271 );
1272 }
1273 other => panic!("expected CacheFutureDated, got {other:?}"),
1274 }
1275 }
1276
1277 #[test]
1278 fn cache_mtime_anchor_tolerates_small_clock_skew() {
1279 // The future-dated guard allows a small tolerance for legitimate
1280 // wall-clock skew between an NFS server and the host. An mtime
1281 // that is `TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE / 2` ahead
1282 // of `now` must still pass.
1283 let tmp = tempdir().unwrap();
1284 let path = tmp.path().join("trusted_root.json");
1285 let now = Utc::now();
1286 let half_tolerance = chrono::Duration::seconds(
1287 (TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE.as_secs() / 2) as i64,
1288 );
1289 let skewed = now + half_tolerance;
1290 let skewed_systemtime = std::time::SystemTime::UNIX_EPOCH
1291 + std::time::Duration::from_secs(skewed.timestamp() as u64);
1292 write_cache_with_systemtime(&path, skewed_systemtime);
1293 let root = TrustedRoot::embedded().unwrap();
1294 let stale = root
1295 .is_stale_at(
1296 now,
1297 DEFAULT_MAX_TRUST_ROOT_AGE,
1298 TrustRootStalenessAnchor::cache_file_mtime(&path),
1299 )
1300 .expect("small skew must not trigger the future-dated guard");
1301 // A fresh-mtime cache must NOT be reported as stale.
1302 assert!(!stale);
1303 }
1304
1305 // ---------------------------------------------------------------
1306 // F3 closure (new finding, adversarial review post-fd779d7):
1307 // chrono Duration arithmetic near i64 overflow can produce
1308 // unexpected values. `is_stale_at` now uses saturating millisecond
1309 // arithmetic instead of `now - anchor_ts`. Drive the boundary by
1310 // constructing an anchor_ts that is exactly at `now`; the saturated
1311 // age_ms must be 0 and the root must NOT be reported as stale.
1312 // ---------------------------------------------------------------
1313 #[test]
1314 fn is_stale_at_saturates_instead_of_panic_on_backward_clock() {
1315 // Scenario: anchor_ts == now (age_ms == 0 after saturating_sub).
1316 // Stale must return false regardless of max_age.
1317 let root = TrustedRoot::embedded().unwrap();
1318 let anchor_date = embedded_snapshot_anchor_date();
1319 // Set now == anchor so the saturated age is 0 ms.
1320 let now = anchor_date;
1321 let stale = root
1322 .is_stale_at(
1323 now,
1324 DEFAULT_MAX_TRUST_ROOT_AGE,
1325 TrustRootStalenessAnchor::embedded_snapshot(),
1326 )
1327 .expect("anchor resolves");
1328 assert!(
1329 !stale,
1330 "age_ms == 0 must never be stale; saturating_sub must clamp at 0 not wrap"
1331 );
1332
1333 // Edge: anchor very slightly in the past (1 ms) — within the
1334 // max-age window — must also be not-stale.
1335 let just_before = anchor_date - chrono::Duration::milliseconds(1);
1336 let stale_edge = root
1337 .is_stale_at(
1338 anchor_date,
1339 DEFAULT_MAX_TRUST_ROOT_AGE,
1340 TrustRootStalenessAnchor::EmbeddedSnapshotDate {
1341 snapshot_iso_date: EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE,
1342 },
1343 )
1344 .expect("anchor resolves");
1345 // `anchor_date - anchor_date` == 0 ms < 30d → not stale
1346 assert!(!stale_edge);
1347 let _ = just_before; // suppress unused-variable warning
1348 }
1349
1350 #[test]
1351 fn trusted_root_cache_future_dated_invariant_token_is_stable() {
1352 assert_eq!(
1353 STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED,
1354 "audit.verify.trusted_root.cache_future_dated"
1355 );
1356 }
1357
1358 // 2026-05-15 portfolio-extension wave 2 invariant-coverage audit: pin the
1359 // stable invariant strings emitted by the three trusted-root staleness
1360 // branches so a string-level dashboards-and-grep regression is caught at
1361 // unit-test time, not from a flaky end-to-end consumer. The actual emit
1362 // sites live in `cortex-cli`'s audit verify path
1363 // (`crates/cortex-cli/src/cmd/audit.rs`), but the invariant constants are
1364 // the carrier identity that downstream wrappers key on.
1365 #[test]
1366 fn trusted_root_staleness_invariant_tokens_are_stable() {
1367 assert_eq!(
1368 TRUSTED_ROOT_STALE_INVARIANT,
1369 "audit.verify.trusted_root.stale_beyond_max_age"
1370 );
1371 assert_eq!(
1372 TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT,
1373 "audit.verify.trusted_root.snapshot_stale"
1374 );
1375 assert_eq!(
1376 TRUSTED_ROOT_CACHE_STALE_INVARIANT,
1377 "audit.verify.trusted_root.cache_stale"
1378 );
1379 // The branch-specific tokens MUST NOT collide with the generic
1380 // back-compat token; consumers route on the branch-specific token
1381 // FIRST and fall back to the generic one for legacy dashboards.
1382 assert_ne!(
1383 TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT, TRUSTED_ROOT_STALE_INVARIANT,
1384 "snapshot_stale token must differ from the generic back-compat token"
1385 );
1386 assert_ne!(
1387 TRUSTED_ROOT_CACHE_STALE_INVARIANT, TRUSTED_ROOT_STALE_INVARIANT,
1388 "cache_stale token must differ from the generic back-compat token"
1389 );
1390 assert_ne!(
1391 TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT, TRUSTED_ROOT_CACHE_STALE_INVARIANT,
1392 "snapshot_stale and cache_stale tokens must be distinct branches"
1393 );
1394 }
1395
1396 // 2026-05-15 wave-2 invariant-coverage audit: drive the embedded-snapshot
1397 // staleness predicate to fire, then assert the invariant constant the
1398 // audit-verify CLI emit site keys on
1399 // (`crates/cortex-cli/src/cmd/audit.rs::1138`) carries the stable token.
1400 // CLI-level integration emit requires `Utc::now() > snapshot+30d`, which
1401 // is not driveable from a deterministic test; this unit test pins the
1402 // emit precondition (is_stale returns true) AND the surface string the
1403 // CLI feeds to `eprintln!` so a string-level regression cannot land
1404 // silently.
1405 #[test]
1406 fn embedded_snapshot_stale_emit_precondition_pins_invariant_token() {
1407 let root = TrustedRoot::embedded().expect("embedded root parses");
1408 let anchor = embedded_snapshot_anchor_date();
1409 // Drive the same condition the CLI audit-verify emit site checks:
1410 // `TrustedRoot::embedded().is_stale(now, embedded_snapshot())`. Use
1411 // a far-future `now` so the staleness predicate must fire even when
1412 // the embedded snapshot has just been refreshed.
1413 let now = anchor + chrono::Duration::days(31);
1414 let stale = root
1415 .is_stale(now, TrustRootStalenessAnchor::embedded_snapshot())
1416 .expect("anchor resolves");
1417 assert!(
1418 stale,
1419 "embedded snapshot must be stale once now > snapshot+30d"
1420 );
1421 // Pin the surface string the CLI emit site feeds to `eprintln!` so
1422 // a refactor that renames the constant breaks at unit-test time.
1423 assert_eq!(
1424 TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT, "audit.verify.trusted_root.snapshot_stale",
1425 "CLI audit-verify emit site keys on this exact stable token"
1426 );
1427 // The CLI emit site surfaces BOTH the branch-specific token and the
1428 // legacy generic token so existing dashboards keep working. Pin
1429 // that companion token here as well.
1430 assert_eq!(
1431 TRUSTED_ROOT_STALE_INVARIANT,
1432 "audit.verify.trusted_root.stale_beyond_max_age"
1433 );
1434 }
1435
1436 // 2026-05-15 wave-2 invariant-coverage audit, negative companion: when
1437 // the embedded snapshot is fresh (now within snapshot+30d), the
1438 // staleness predicate MUST return false so the CLI emit site does NOT
1439 // surface the `snapshot_stale` invariant. Pairs with
1440 // `embedded_snapshot_stale_emit_precondition_pins_invariant_token`.
1441 #[test]
1442 fn embedded_snapshot_not_stale_emit_precondition_holds_within_30d() {
1443 let root = TrustedRoot::embedded().expect("embedded root parses");
1444 let anchor = embedded_snapshot_anchor_date();
1445 let now = anchor + chrono::Duration::days(29);
1446 let stale = root
1447 .is_stale(now, TrustRootStalenessAnchor::embedded_snapshot())
1448 .expect("anchor resolves");
1449 assert!(
1450 !stale,
1451 "embedded snapshot must NOT be stale within snapshot+30d; \
1452 CLI emit site for `{TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT}` must stay silent"
1453 );
1454 }
1455
1456 #[test]
1457 fn parse_rejects_wrong_media_type() {
1458 let body = serde_json::json!({
1459 "mediaType": "application/json",
1460 "tlogs": [],
1461 });
1462 let err = TrustedRoot::parse_bytes(body.to_string().as_bytes()).unwrap_err();
1463 assert!(matches!(err, TrustedRootParseError::WrongMediaType { .. }));
1464 }
1465
1466 #[test]
1467 fn parse_rejects_empty_tlogs() {
1468 let body = serde_json::json!({
1469 "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1",
1470 "tlogs": [],
1471 });
1472 let err = TrustedRoot::parse_bytes(body.to_string().as_bytes()).unwrap_err();
1473 assert_eq!(err, TrustedRootParseError::EmptyTlogs);
1474 }
1475
1476 #[test]
1477 fn parse_rejects_tlog_without_validity_start() {
1478 let body = serde_json::json!({
1479 "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1",
1480 "tlogs": [{
1481 "baseUrl": "https://rekor.sigstore.dev",
1482 "publicKey": {
1483 "rawBytes": "AAAA",
1484 "validFor": {}
1485 }
1486 }]
1487 });
1488 let err = TrustedRoot::parse_bytes(body.to_string().as_bytes()).unwrap_err();
1489 assert!(matches!(
1490 err,
1491 TrustedRootParseError::MissingValidityStart { tlog_index: 0 }
1492 ));
1493 }
1494
1495 #[test]
1496 fn load_cached_returns_none_for_missing_path() {
1497 let tmp = tempdir().unwrap();
1498 let path = tmp.path().join("does-not-exist.json");
1499 let result = TrustedRoot::load_cached(&path).expect("missing path returns Ok(None)");
1500 assert!(result.is_none());
1501 }
1502
1503 #[test]
1504 fn load_cached_returns_some_for_valid_file() {
1505 let tmp = tempdir().unwrap();
1506 let path = tmp.path().join("trusted_root.json");
1507 let root = TrustedRoot::embedded().unwrap();
1508 root.write_atomic(&path).unwrap();
1509 let loaded = TrustedRoot::load_cached(&path).unwrap().unwrap();
1510 assert_eq!(loaded.media_type, root.media_type);
1511 }
1512
1513 #[test]
1514 fn write_atomic_replaces_existing_file() {
1515 let tmp = tempdir().unwrap();
1516 let path = tmp.path().join("trusted_root.json");
1517 std::fs::write(&path, b"old garbage").unwrap();
1518 let root = TrustedRoot::embedded().unwrap();
1519 root.write_atomic(&path).unwrap();
1520 let loaded = TrustedRoot::load_cached(&path).unwrap().unwrap();
1521 assert_eq!(loaded.media_type, root.media_type);
1522 }
1523
1524 #[test]
1525 fn active_trusted_root_prefers_cache_then_embedded() {
1526 let tmp = tempdir().unwrap();
1527 let path = tmp.path().join("trusted_root.json");
1528
1529 let active = active_trusted_root(Some(&path)).expect("missing cache falls back");
1530 assert_eq!(active.status, EMBEDDED_ROOT_STATUS);
1531 assert_eq!(active.cache_path.as_deref(), Some(path.as_path()));
1532
1533 let root = TrustedRoot::embedded().unwrap();
1534 root.write_atomic(&path).unwrap();
1535 let active = active_trusted_root(Some(&path)).unwrap();
1536 assert_eq!(active.status, CACHED_ROOT_STATUS);
1537 }
1538
1539 #[test]
1540 fn missing_embedded_snapshot_date_constant_fails_loud() {
1541 // The anchor-resolution path surfaces a typed error rather than
1542 // silently treating the root as stale when the constant is
1543 // malformed. Build-time guard.
1544 let root = TrustedRoot::embedded().unwrap();
1545 let anchor = TrustRootStalenessAnchor::EmbeddedSnapshotDate {
1546 snapshot_iso_date: "not-a-date",
1547 };
1548 let err = root
1549 .is_stale_at(Utc::now(), DEFAULT_MAX_TRUST_ROOT_AGE, anchor)
1550 .unwrap_err();
1551 assert!(matches!(
1552 err,
1553 TrustRootStalenessError::MalformedEmbeddedSnapshotDate { .. }
1554 ));
1555 }
1556
1557 // ---------------------------------------------------------------
1558 // F2 pin: embedded snapshot date constant must match the embedded
1559 // `include_bytes!` filename.
1560 //
1561 // The constant is now derived at build time by `build.rs` from the
1562 // filename. These tests are the runtime restatement of the
1563 // invariant — they enumerate `src/external_sink/embedded/*.json` at
1564 // test time and assert exactly one matches
1565 // `sigstore_trusted_root_YYYY-MM-DD.json` AND the embedded date
1566 // string equals [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`].
1567 //
1568 // `cargo test` runs with CWD = the crate root, so the relative path
1569 // resolves identically to the build script's `read_dir`.
1570 // ---------------------------------------------------------------
1571
1572 fn discover_embedded_snapshot_filename() -> (String, String) {
1573 // Returns (filename, date).
1574 let dir = std::path::Path::new("src/external_sink/embedded");
1575 let mut matches: Vec<(String, String)> = Vec::new();
1576 for entry in std::fs::read_dir(dir)
1577 .expect("embedded directory readable from crate-root CWD during cargo test")
1578 {
1579 let entry = entry.expect("read_dir entry");
1580 let name_os = entry.file_name();
1581 let name = match name_os.to_str() {
1582 Some(s) => s.to_string(),
1583 None => continue,
1584 };
1585 if let Some(date_and_suffix) = name.strip_prefix("sigstore_trusted_root_") {
1586 if let Some(date) = date_and_suffix.strip_suffix(".json") {
1587 matches.push((name.clone(), date.to_string()));
1588 }
1589 }
1590 }
1591 assert_eq!(
1592 matches.len(),
1593 1,
1594 "embedded directory must contain exactly one sigstore_trusted_root_YYYY-MM-DD.json, found: {matches:?}"
1595 );
1596 matches.into_iter().next().unwrap()
1597 }
1598
1599 #[test]
1600 fn embedded_snapshot_date_constant_matches_embedded_filename() {
1601 // F2 pin: the build-derived constant equals the date encoded in
1602 // the on-disk filename. If the filename is bumped without the
1603 // build rerunning (or vice versa), this assert fails.
1604 let (filename, date) = discover_embedded_snapshot_filename();
1605 assert_eq!(
1606 EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE, date,
1607 "EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE = `{EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE}` but embedded filename `{filename}` encodes date `{date}`"
1608 );
1609 }
1610
1611 #[test]
1612 fn embedded_filename_pin_is_documented() {
1613 // Inert pin: documents that the constant is derived from the
1614 // filename via `build.rs`. If `EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`
1615 // ever stops compiling, this test name is the breadcrumb to the
1616 // build script.
1617 assert!(
1618 EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE.len() == 10
1619 && EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE.as_bytes()[4] == b'-'
1620 && EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE.as_bytes()[7] == b'-',
1621 "EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE must be YYYY-MM-DD; got `{EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE}`"
1622 );
1623 }
1624
1625 // ---------------------------------------------------------------
1626 // BH-3: rekor_verifying_key MUST be log-bound (Cosign
1627 // GHSA-whqx-f9j3-ch6m mitigation). Test the matcher and the
1628 // selector contract directly.
1629 // ---------------------------------------------------------------
1630
1631 /// Synthesize a trust root with a chosen log_id keyId for testing
1632 /// the log-bound selector. Uses a deterministic ECDSA P-256 PEM so
1633 /// the test does not need a fixture file.
1634 fn synth_trust_root_with_log_id(log_id_key_id: &str, signed_at: DateTime<Utc>) -> TrustedRoot {
1635 // A throwaway ECDSA P-256 public key in PEM form. Generated
1636 // offline and pinned here so the test has no live key-gen
1637 // dependency. The bytes do not need to verify any signature —
1638 // we only exercise the selector logic.
1639 const PEM: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE38RLcWzg03IUsJCRqVqTfrIsrZ16\n9knDnAIDtWs7oSxtQ7vlFDJwBMsFiyufFcxRRXotLozXlslNcujXkKAdzQ==\n-----END PUBLIC KEY-----\n";
1640 TrustedRoot::from_fixture_rekor_pem(PEM, signed_at, log_id_key_id)
1641 .expect("fixture key parses")
1642 }
1643
1644 #[test]
1645 fn rekor_verifying_key_returns_key_for_matching_logid() {
1646 let signed_at = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
1647 let root = synth_trust_root_with_log_id("my-fixture-log-id", signed_at);
1648 // Same raw string on both sides: matches via the raw-bytes
1649 // fallback branch.
1650 root.rekor_verifying_key("my-fixture-log-id")
1651 .expect("matching log_id resolves to a verifying key");
1652 }
1653
1654 #[test]
1655 fn rekor_verifying_key_refuses_when_no_tlog_matches_receipt_logid() {
1656 let signed_at = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
1657 let root = synth_trust_root_with_log_id("trust-root-log-id", signed_at);
1658 let err = root
1659 .rekor_verifying_key("a-different-receipt-log-id")
1660 .unwrap_err();
1661 match err {
1662 TrustedRootKeyError::TlogLogIdNoMatch {
1663 invariant,
1664 receipt_log_id,
1665 tlog_log_ids,
1666 } => {
1667 assert_eq!(invariant, REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT);
1668 assert_eq!(receipt_log_id, "a-different-receipt-log-id");
1669 assert_eq!(tlog_log_ids, vec!["trust-root-log-id".to_string()]);
1670 }
1671 other => panic!("expected TlogLogIdNoMatch, got {other:?}"),
1672 }
1673 }
1674
1675 #[test]
1676 fn rekor_verifying_key_does_not_silently_fall_back_to_latest_when_logid_unknown() {
1677 // Construct a multi-tlog trust root: a recent (would-win the
1678 // "latest activation" race) one with a non-matching log_id and
1679 // an older one with the matching log_id. The selector MUST
1680 // pick the matching one — and if the matching one is removed,
1681 // it MUST refuse, NOT silently fall back to "latest".
1682 let old = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap();
1683 let recent = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
1684 let matching_log_id = "matching-log-id";
1685 let _decoy_log_id = "decoy-but-newer-log-id";
1686
1687 // First, baseline: matching log_id present + decoy → matches,
1688 // verifying key returned.
1689 let mut root = synth_trust_root_with_log_id(matching_log_id, old);
1690 // Append the decoy tlog (recent activation, different log_id).
1691 const PEM2: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE38RLcWzg03IUsJCRqVqTfrIsrZ16\n9knDnAIDtWs7oSxtQ7vlFDJwBMsFiyufFcxRRXotLozXlslNcujXkKAdzQ==\n-----END PUBLIC KEY-----\n";
1692 let decoy_root =
1693 TrustedRoot::from_fixture_rekor_pem(PEM2, recent, "decoy-but-newer-log-id")
1694 .expect("decoy tlog parses");
1695 root.tlogs.extend(decoy_root.tlogs);
1696 // Matching path: still resolves.
1697 root.rekor_verifying_key(matching_log_id)
1698 .expect("log-bound selector picks the matching (older) tlog despite a newer decoy");
1699
1700 // Now: drop the matching tlog, leave only the decoy. Receipt
1701 // logId still references the now-absent log_id. The selector
1702 // MUST refuse, NOT fall back to the decoy on activation date.
1703 let only_decoy = TrustedRoot {
1704 media_type: root.media_type.clone(),
1705 tlogs: root
1706 .tlogs
1707 .iter()
1708 .filter(|tlog| tlog_log_id_key_id(&tlog.log_id).as_deref() != Some(matching_log_id))
1709 .cloned()
1710 .collect(),
1711 certificate_authorities: root.certificate_authorities.clone(),
1712 ctlogs: root.ctlogs.clone(),
1713 timestamp_authorities: root.timestamp_authorities.clone(),
1714 };
1715 assert_eq!(
1716 only_decoy.tlogs.len(),
1717 1,
1718 "decoy-only root must have exactly one tlog"
1719 );
1720 let err = only_decoy.rekor_verifying_key(matching_log_id).unwrap_err();
1721 assert!(
1722 matches!(
1723 err,
1724 TrustedRootKeyError::TlogLogIdNoMatch {
1725 invariant: REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT,
1726 ..
1727 }
1728 ),
1729 "selector silently fell back to a non-matching tlog instead of refusing; got {err:?}"
1730 );
1731 }
1732
1733 #[test]
1734 fn log_id_matches_decodes_hex_against_base64() {
1735 // Realistic Sigstore case: the trust root's logId.keyId is
1736 // base64-encoded raw bytes; the Rekor receipt's logID is the
1737 // same bytes in lowercase hex. The matcher must equate them.
1738 use base64::Engine;
1739 let key_id_bytes: [u8; 32] = [
1740 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55,
1741 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x10, 0x20, 0x30, 0x40,
1742 0x50, 0x60, 0x70, 0x80,
1743 ];
1744 let b64 = base64::engine::general_purpose::STANDARD.encode(key_id_bytes);
1745 let hex: String = key_id_bytes.iter().map(|b| format!("{b:02x}")).collect();
1746 assert!(log_id_matches(&b64, &hex));
1747 // And a deliberate mismatch must not match.
1748 let mut tampered = key_id_bytes;
1749 tampered[0] ^= 0x01;
1750 let tampered_hex: String = tampered.iter().map(|b| format!("{b:02x}")).collect();
1751 assert!(!log_id_matches(&b64, &tampered_hex));
1752 }
1753
1754 /// post-fd779d7 LOW closure: proto3-JSON receipts encode binary
1755 /// fields with URL-safe base64 (RFC 7515 §2 — `-_` alphabet),
1756 /// not STANDARD base64 (`+/`). The matcher must accept both
1757 /// alphabets so a tlog `keyId` carried over a protobuf-JSON wire
1758 /// path matches a receipt `logID` carried over either wire path.
1759 /// Bytes containing `0x3e` (`>` in raw) encode to a `+` (STANDARD)
1760 /// or a `-` (URL-safe), which makes them a faithful regression
1761 /// fixture for the difference between the two alphabets.
1762 #[test]
1763 fn log_id_matches_accepts_url_safe_base64_both_sides() {
1764 use base64::Engine;
1765 // Bytes chosen so STANDARD and URL-safe encodings differ
1766 // (the `0x3e`/`0x3f` nibble combos exercise both `+/` and
1767 // `-_` alphabets distinctly).
1768 let key_id_bytes: [u8; 32] = [
1769 0xfb, 0xff, 0xbf, 0xfe, 0x3e, 0x3f, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
1770 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x10, 0x20, 0x30, 0x40, 0x50,
1771 0x60, 0x70, 0x80, 0x90,
1772 ];
1773 let b64_std = base64::engine::general_purpose::STANDARD.encode(key_id_bytes);
1774 let b64_url = base64::engine::general_purpose::URL_SAFE.encode(key_id_bytes);
1775 let b64_url_nopad = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key_id_bytes);
1776 let hex: String = key_id_bytes.iter().map(|b| format!("{b:02x}")).collect();
1777
1778 // Sanity: STANDARD and URL-safe encodings must actually differ
1779 // for these bytes (otherwise this test would be vacuous).
1780 assert_ne!(
1781 b64_std, b64_url,
1782 "fixture bytes were chosen so the two alphabets differ; if they match the test is vacuous",
1783 );
1784
1785 // tlog side STANDARD vs receipt side URL-safe.
1786 assert!(log_id_matches(&b64_std, &b64_url));
1787 // tlog side URL-safe vs receipt side STANDARD.
1788 assert!(log_id_matches(&b64_url, &b64_std));
1789 // tlog side URL-safe (no padding) vs receipt side hex.
1790 assert!(log_id_matches(&b64_url_nopad, &hex));
1791 // tlog side URL-safe vs receipt side URL-safe (no padding).
1792 assert!(log_id_matches(&b64_url, &b64_url_nopad));
1793
1794 // Tampered URL-safe receipt still refuses.
1795 let mut tampered = key_id_bytes;
1796 tampered[0] ^= 0x01;
1797 let tampered_url = base64::engine::general_purpose::URL_SAFE.encode(tampered);
1798 assert!(!log_id_matches(&b64_std, &tampered_url));
1799 }
1800}