Skip to content

UCA-4: keyless proof cache hit skips SET re-verify with no bundle-equality check #139

@avrabe

Description

@avrabe

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:

  1. 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.
  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions