Surfaced by STPA-Sec analysis recorded in `docs/security/stpa-keyless-2026-05-25.md`, section 4 — UCA-4. PR #136 (v0.9.1) closed UCA-2 from that report; this is a remaining gap.
What's wrong
In `KeylessVerifier::verify` at `signer.rs:599–623`:
```rust
let mut cache_hit = false;
if let Some(ref cache) = self.config.proof_cache {
if let Some(_cached_proof) = cache.get(&cache_key) {
log::info!("Using cached Rekor proof for {}", keyless_sig.rekor_entry.uuid);
cache_hit = true;
}
}
if !cache_hit {
log::debug!("Verifying Rekor SET");
let verifier = RekorKeyring::from_embedded_trust_root()?;
verifier.verify_set(&keyless_sig.rekor_entry)?;
// ... and cache
}
```
Two problems:
- The cached `CachedProof` is bound to `_cached_proof` and never read. The cache key (`(SHA256(module_bytes), keyless_sig.rekor_entry.uuid)`, see `signer.rs:594–597`) does not include the SET, body, log_id, or signed_entry_timestamp. So on cache hit, the verifier proceeds with whatever `keyless_sig.rekor_entry` fields the attacker put in the bundle, having only verified that the cache key matches.
- The cache key uses only `uuid`, which is attacker-controllable in the bundle.
Scenario
Combine with any future not-yet-found partial UCA-2 bypass, or with stale-cache reuse across operators. Suppose a legitimate signed module M was cached after successful network verification — the cache holds `(hash(M), uuid_X) -> CachedProof { entry: real_entry_X }`. An attacker constructs a new bundle keeping the same module bytes M (so `hash(M)` is unchanged) but mutates the embedded `keyless_sig.rekor_entry` to e.g. `RekorEntry { uuid: "uuid_X", body: , signed_entry_timestamp: , ... }`. The cache hits; SET re-verification is skipped; the cached `CachedProof` is discarded; the verifier proceeds with the attacker's mutated `rekor_entry` for downstream checks.
Today (post-PR-#136) the downstream is identity/issuer extraction + the new body cross-check. UCA-2's fix means the forged body must still match the bundle, so the bypass is at least one layer deeper. But losing the SET check on cache hit removes a layer of defence in depth that should not be lost — and once UCA-1 fixes are wired through the cache too, the same cache-skip will skip inclusion-proof re-verification.
Suggested fix
Choose one:
A. On cache hit, use the cached `CachedProof.entry` as authoritative for downstream steps (i.e., replace `keyless_sig.rekor_entry` with the cached one) so the cache becomes the source of truth, not the attacker's bundle.
B. On cache hit, assert `cached_proof.entry == keyless_sig.rekor_entry` (or at minimum equality on `body`, `log_id`, `log_index`, `signed_entry_timestamp`) before skipping the network round-trip.
C. Include a hash of `keyless_sig.rekor_entry` (not just its `uuid`) in the cache key.
(A) is the cleanest model: cache stores a known-good proof; verify uses that proof, not whatever's in the bundle.
Acceptance
- Pick fix shape (A / B / C) and document in commit + CHANGELOG.
- Test: pre-populate the cache with a real proof; submit a bundle where the bundle's `rekor_entry` differs from the cached one but has the same `uuid`; must reject (under B/C) or proceed using the cached entry's body (under A).
- Test: existing cache-hit fast-path continues to work for genuine repeat verifications.
Cross-ref
Adjacent to the prior audit's H-5 but a different failure mode: H-5 is on cache population (empty SETs getting cached); UCA-4 is on cache consumption (cached entries not being trusted as authoritative).
Surfaced by STPA-Sec analysis recorded in `docs/security/stpa-keyless-2026-05-25.md`, section 4 — UCA-4. PR #136 (v0.9.1) closed UCA-2 from that report; this is a remaining gap.
What's wrong
In `KeylessVerifier::verify` at `signer.rs:599–623`:
```rust
let mut cache_hit = false;
if let Some(ref cache) = self.config.proof_cache {
if let Some(_cached_proof) = cache.get(&cache_key) {
log::info!("Using cached Rekor proof for {}", keyless_sig.rekor_entry.uuid);
cache_hit = true;
}
}
if !cache_hit {
log::debug!("Verifying Rekor SET");
let verifier = RekorKeyring::from_embedded_trust_root()?;
verifier.verify_set(&keyless_sig.rekor_entry)?;
// ... and cache
}
```
Two problems:
Scenario
Combine with any future not-yet-found partial UCA-2 bypass, or with stale-cache reuse across operators. Suppose a legitimate signed module M was cached after successful network verification — the cache holds `(hash(M), uuid_X) -> CachedProof { entry: real_entry_X }`. An attacker constructs a new bundle keeping the same module bytes M (so `hash(M)` is unchanged) but mutates the embedded `keyless_sig.rekor_entry` to e.g. `RekorEntry { uuid: "uuid_X", body: , signed_entry_timestamp: , ... }`. The cache hits; SET re-verification is skipped; the cached `CachedProof` is discarded; the verifier proceeds with the attacker's mutated `rekor_entry` for downstream checks.
Today (post-PR-#136) the downstream is identity/issuer extraction + the new body cross-check. UCA-2's fix means the forged body must still match the bundle, so the bypass is at least one layer deeper. But losing the SET check on cache hit removes a layer of defence in depth that should not be lost — and once UCA-1 fixes are wired through the cache too, the same cache-skip will skip inclusion-proof re-verification.
Suggested fix
Choose one:
A. On cache hit, use the cached `CachedProof.entry` as authoritative for downstream steps (i.e., replace `keyless_sig.rekor_entry` with the cached one) so the cache becomes the source of truth, not the attacker's bundle.
B. On cache hit, assert `cached_proof.entry == keyless_sig.rekor_entry` (or at minimum equality on `body`, `log_id`, `log_index`, `signed_entry_timestamp`) before skipping the network round-trip.
C. Include a hash of `keyless_sig.rekor_entry` (not just its `uuid`) in the cache key.
(A) is the cleanest model: cache stores a known-good proof; verify uses that proof, not whatever's in the bundle.
Acceptance
Cross-ref
Adjacent to the prior audit's H-5 but a different failure mode: H-5 is on cache population (empty SETs getting cached); UCA-4 is on cache consumption (cached entries not being trusted as authoritative).