Skip to main content

cleanlib_client/
attestation_verify.rs

1//! Cosign migration gate 3 (Q6=a, CLEANLIB-379 overnight arc) — Rust
2//! reference implementation of SDK-side attestation signature verification.
3//!
4//! Q14 (capability parity, not policy): this module exposes a capability —
5//! `verify_attestation()` — that an integrator calls when THEY decide a
6//! signature check matters. Nothing in `cleanlib-client` invokes this
7//! automatically on the normal verdict-fetch path; a caller that never calls
8//! it pays zero cost and sees identical behavior to before this module
9//! existed. Matches the CLI/MCP-tool posture (no forced default).
10//!
11//! # Design history — why the trust root is a compiled-in map, not a fetch
12//!
13//! The FIRST cut of this module (PR #536, merged `acab7f44fd8d`) shipped
14//! [`PubkeysEndpointLookup`] as the DEFAULT [`AttestationKeyLookup`]: it
15//! resolved an attestation's `key_id` by fetching `GET /v1/pubkeys` from
16//! `cleanlib-app` — the SAME service whose signatures it was verifying.
17//! BD/PM-seat review (2026-09-13, ratified via Jira CLEANLIB-379 comment
18//! 804236) found this architecturally circular and directed a redesign:
19//!
20//! - `key_id` lives on `SignedAttestation` (the wire envelope), NOT inside
21//!   `Attestation` (the struct `cleanlib-cosign-signer` actually signs via
22//!   JCS `canonical_bytes()`) — confirmed by reading
23//!   `cleanlib-cosign-signer/src/lib.rs` lines 91–140. `key_id` is UNSIGNED.
24//! - A verifier that uses that unsigned field to pick which key to trust,
25//!   fetched from the same service producing the signature, gives no
26//!   protection against a compromised/malicious service: whoever controls
27//!   `/v1/pubkeys` controls what "verifies." This made the business claim
28//!   ("checked... without trusting us and without our service being up")
29//!   false under the PR #536 shape.
30//!
31//! **Ratified shape (this module, post-redesign):**
32//! 1. [`PinnedKeyMap`] — a COMPILED-IN `key_id -> PEM` map for every key ever
33//!    signed with (today: staging + prod), shipped in the SDK binary, not
34//!    fetched at runtime. This is the new DEFAULT trust root.
35//! 2. Unknown `key_id` FAILS CLOSED — [`PinnedKeyMap::lookup_pem`] never
36//!    makes a network call; an unrecognized `key_id` is an immediate,
37//!    permanent `AttestationInvalid`, never a fetch-and-hope.
38//! 3. [`PubkeysEndpointLookup`] is DEMOTED to a convenience-only helper (see
39//!    its doc comment) — it must never be wired as the default
40//!    `AttestationKeyLookup` for `verify_attestation`/`Client::verify_attestation`
41//!    again. Its role now is exactly [`PubkeysEndpointLookup::describe_unknown_key`]:
42//!    producing HUMAN-READABLE advisory text ("unknown key_id, verify its
43//!    fingerprint out of band") — it must never feed a trust decision.
44//! 4. Publishing fingerprints somewhere independent of the service (so an
45//!    operator has something to check `describe_unknown_key`'s advisory
46//!    against) is a process/docs follow-up, tracked separately — not a code
47//!    change in this module.
48//!
49//! Verification recipe (unchanged by the redesign — only key RESOLUTION
50//! changed, not the verify math). Reproduces `cleanlib-cosign-signer`'s
51//! `partner_guide_7_2_method_verifies_signer_signature` test from the
52//! CONSUMER side, independently — this crate does NOT and must NOT depend on
53//! `cleanlib-cosign-signer`, which is `publish = false` / service-internal
54//! per `PUBLISHING.md` §1.2):
55//! 1. Parse the wire envelope `{attestation: {...}, signature_b64, key_id}`
56//!    (the exact shape `Verdict::attestation` carries as a passthrough
57//!    `serde_json::Value` — see `types.rs` CLEANLIB-496 doc comment).
58//! 2. Re-serialize the `attestation` sub-object using RFC-8785 JCS
59//!    (`serde_jcs`) — this is what the signer actually signs
60//!    (`Attestation::canonical_bytes()`), REGARDLESS of the wire's incidental
61//!    key order/whitespace. A natural parse + JCS-reserialize is
62//!    byte-identical to the signer's bytes; no hand-replicated field order.
63//! 3. base64-decode `signature_b64`, DER-decode as a P-256 ECDSA signature.
64//! 4. Resolve `key_id` to a PEM via the caller-supplied [`AttestationKeyLookup`]
65//!    (the default [`PinnedKeyMap`] — compiled-in, fail-closed, no network),
66//!    parse the PEM as a `VerifyingKey`.
67//! 5. `verifying_key.verify(canonical_bytes, &signature)`.
68//!
69//! Errors route through the EXISTING `CleanLibraryError` taxonomy rather than
70//! a bespoke type: malformed envelopes and signature/key/canonical-form
71//! mismatches are `AttestationInvalid` (a variant CLEANLIB-657 already
72//! reserved for exactly this "signed attestation present but failed
73//! verification" case); a transient key-lookup failure (network/5xx while
74//! fetching `/v1/pubkeys` via the now convenience-only
75//! [`PubkeysEndpointLookup`]) is the existing `Transport` variant, so callers
76//! already branching on `CleanLibraryError::is_retryable()` get the right
77//! answer for free.
78
79use std::collections::HashMap;
80use std::sync::Arc;
81use std::time::{Duration, Instant};
82
83use async_trait::async_trait;
84use ecdsa::signature::Verifier;
85use p256::ecdsa::{Signature, VerifyingKey};
86use p256::pkcs8::DecodePublicKey;
87use reqwest::{Client as ReqwestClient, Url};
88use tokio::sync::RwLock;
89
90use crate::errors::{CleanLibraryError, TransportError};
91
92/// Default TTL for a cached `key_id -> PEM` entry (charter default: 1h). KMS
93/// key-version MATERIAL is immutable once created (only `state` changes), so
94/// this TTL exists to notice a NEW key-version being added to `/v1/pubkeys`
95/// (e.g. ahead of gate 5 cutover), not to detect rotation-in-place — there is
96/// no rotation-in-place for a given key_id.
97pub const DEFAULT_PUBKEY_CACHE_TTL: Duration = Duration::from_secs(3600);
98
99/// Resolves an attestation's `key_id` to a PEM-encoded P-256 public key.
100///
101/// Exists as a trait (rather than hardcoding the HTTP fetch inside
102/// `verify_attestation`) so:
103/// - tests / air-gapped verification can substitute a static in-memory
104///   lookup (see `tests` module below) without a network dependency, and
105/// - a future consumer with a different trust distribution mechanism (e.g. a
106///   pre-provisioned key bundle) can implement this trait instead of the
107///   default HTTP one, without `verify_attestation` itself changing.
108#[async_trait]
109pub trait AttestationKeyLookup: Send + Sync {
110    /// Resolve `key_id` (the exact string carried on `SignedAttestation.key_id`
111    /// — the full KMS key-version resource path, e.g.
112    /// `projects/.../cryptoKeys/cleanlib-cosign-staging/cryptoKeyVersions/1`)
113    /// to a PEM-encoded SubjectPublicKeyInfo. Implementations should treat a
114    /// "no such key_id" answer as `AttestationInvalid` (permanent — retrying
115    /// the same key_id against the same catalog will not help) and a
116    /// network/5xx failure as `Transport` (transient — retry may succeed).
117    async fn lookup_pem(&self, key_id: &str) -> Result<String, CleanLibraryError>;
118}
119
120/// Staging KMS key_id/PEM, mirrored VERBATIM from `cleanlib-app`'s
121/// `/v1/pubkeys` handler (`cleanlib-app/src/http.rs::pubkeys()`,
122/// `STAGING_KEY_ID`/`STAGING_PEM` constants — point-in-time
123/// `gcloud kms keys versions get-public-key` fetch, 2026-09-11, keyring
124/// `cleanlibrary-signing`, project `cleanlibrary-prod`). This is the
125/// ACTIVE signing key as of that fetch — every attestation issued in
126/// production today carries this `key_id`.
127///
128/// KMS key-version key MATERIAL is immutable once created (only `state`
129/// changes) — this mirror only goes stale when a genuinely NEW key version
130/// is minted, which per the App-side doc comment is "a BD-direct,
131/// coordinated rotation event," not something either side auto-detects.
132/// **A new key version requires a coordinated SDK release updating this
133/// map** — that coupling is the intended fail-closed behavior, not a defect.
134const STAGING_KEY_ID: &str = "projects/cleanlibrary-prod/locations/us-central1/keyRings/cleanlibrary-signing/cryptoKeys/cleanlib-cosign-staging/cryptoKeyVersions/1";
135const STAGING_PEM: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1yxckriMfZCZHgbnedOO6RHxg+Hq\nV6gRTA5/wIZtTbBLmVSg5//9L6xLvD7CaLEUMg8aH3+8vuytxl4K2wB7uA==\n-----END PUBLIC KEY-----\n";
136
137/// Prod KMS key_id/PEM. **Key MATERIAL mirrored from the same `pubkeys()`
138/// handler** (`PROD_KEY_ID`/`PROD_PEM`), but re-line-wrapped here — the
139/// App-side constant's base64 body is emitted as one 124-char line with no
140/// RFC 7468 64-char wrap. Python's lenient `base64.b64decode` accepts that
141/// (91 bytes, correct P-256 SPKI length either way), but Rust's
142/// `p256::pkcs8::DecodePublicKey` — and any other strict PEM parser — does
143/// not: `pinned_key_map_builtin_entries_are_well_formed` below caught this by
144/// literally trying to parse the App's own constant. Same key bytes, just
145/// correctly wrapped; **the underlying `/v1/pubkeys` defect in
146/// `cleanlib-app/src/http.rs` still needs an App-lane fix** so every OTHER
147/// strict-PEM consumer of that endpoint (not just this SDK) isn't affected —
148/// flagged separately, not silently worked around by mirroring the bug here.
149///
150/// Provisioned + ENABLED, not yet the active signer (cutover is cosign gate
151/// 5, BD-direct) — pinned here now so an SDK built today already recognizes
152/// it the moment gate 5 flips, with no SDK re-release required for that
153/// specific transition.
154const PROD_KEY_ID: &str = "projects/cleanlibrary-prod/locations/us-central1/keyRings/cleanlibrary-signing/cryptoKeys/cleanlib-cosign-prod/cryptoKeyVersions/1";
155const PROD_PEM: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEoHzMRp8uj6k8tv4YXpI8r/5eC3sC\nwoKhnouHz83WkAWGMd2U30R4Wpr6JR1WfI+MH6/Q6o4T8cAnYee91oa98g==\n-----END PUBLIC KEY-----\n";
156
157/// The DEFAULT [`AttestationKeyLookup`] as of the gate-3 redesign
158/// (2026-09-13, BD-ratified per Jira CLEANLIB-379 comment 804236 —
159/// see the module-level "Design history" doc above).
160///
161/// A compiled-in `key_id -> PEM` map. **No network capability exists on this
162/// type at all** — there is no HTTP client field, no URL, nothing to fetch.
163/// [`Self::lookup_pem`] is a pure in-memory match; an unrecognized `key_id`
164/// returns `Err(AttestationInvalid)` immediately, which is what makes this
165/// shape fail-closed rather than fail-open-via-fetch. This is deliberate:
166/// the whole point of the redesign is that a verifier's trust root cannot be
167/// steered by an unsigned wire field talking to the same service being
168/// verified.
169#[derive(Debug, Clone, Default)]
170pub struct PinnedKeyMap {
171    /// Additional out-of-band-verified keys a caller has independently
172    /// confirmed (e.g. against a fingerprint published somewhere
173    /// independent of `cleanlib-app`, per the redesign's point 4) — merged
174    /// with the two built-in entries. Empty by default.
175    extra: HashMap<String, String>,
176}
177
178impl PinnedKeyMap {
179    /// The built-in set: today's staging + prod keys, exactly as
180    /// `/v1/pubkeys` reports them at the time of this SDK release.
181    pub fn new() -> Self {
182        Self::default()
183    }
184
185    /// Pin an ADDITIONAL key the caller has verified out-of-band (e.g. a
186    /// customer-specific signing key, or a new key_id whose fingerprint they
187    /// checked against an independent publication per the redesign's point
188    /// 4). Does not touch the network — the caller supplies the PEM
189    /// directly; this method only extends the in-memory map.
190    pub fn with_extra_key(mut self, key_id: impl Into<String>, pem: impl Into<String>) -> Self {
191        self.extra.insert(key_id.into(), pem.into());
192        self
193    }
194
195    /// The built-in (non-`extra`) entries, for tests/diagnostics — proves
196    /// the compiled-in set actually contains today's known key_ids without
197    /// exposing a way to mutate them.
198    fn builtin() -> [(&'static str, &'static str); 2] {
199        [(STAGING_KEY_ID, STAGING_PEM), (PROD_KEY_ID, PROD_PEM)]
200    }
201}
202
203#[async_trait]
204impl AttestationKeyLookup for PinnedKeyMap {
205    async fn lookup_pem(&self, key_id: &str) -> Result<String, CleanLibraryError> {
206        // Pure in-memory match — no `.await` point that could ever touch a
207        // network driver. Checked first against `extra` (caller's own
208        // out-of-band-verified pins take precedence) then the built-in set.
209        if let Some(pem) = self.extra.get(key_id) {
210            return Ok(pem.clone());
211        }
212        for (id, pem) in Self::builtin() {
213            if id == key_id {
214                return Ok(pem.to_string());
215            }
216        }
217        // FAIL CLOSED: unrecognized key_id is a permanent, immediate error —
218        // never a fetch-and-hope. This is the core safety property the
219        // redesign exists to guarantee; see
220        // `pinned_key_map_unknown_key_fails_closed_with_no_io_driver` below
221        // for the structural (not just behavioral) proof.
222        Err(CleanLibraryError::AttestationInvalid {
223            reason_code: "ATTESTATION_KEY_ID_UNKNOWN".to_string(),
224            message: format!(
225                "key_id {key_id:?} is not in this SDK's pinned key set (staging + \
226                 prod, plus any caller-supplied `with_extra_key` pins). This is a \
227                 PERMANENT fail-closed result, not a transient fetch failure — this \
228                 type never fetches. If you believe this key_id is legitimate (e.g. \
229                 a new key version), verify its fingerprint out of band and either \
230                 pin it via `PinnedKeyMap::with_extra_key` or wait for an SDK release \
231                 that pins it by default. `PubkeysEndpointLookup::describe_unknown_key` \
232                 can surface a human-readable hint, but must never be auto-trusted."
233            ),
234        })
235    }
236}
237
238#[derive(Debug, Clone, serde::Deserialize)]
239struct PubkeyEntry {
240    key_id: String,
241    pubkey_pem: String,
242    algorithm: Option<String>,
243}
244
245#[derive(Debug, Clone, serde::Deserialize)]
246struct PubkeysResponse {
247    keys: Vec<PubkeyEntry>,
248}
249
250/// **DEMOTED to convenience-only as of the gate-3 redesign (2026-09-13) — do
251/// NOT wire this as the default [`AttestationKeyLookup`] for
252/// `verify_attestation`/`Client::verify_attestation`.** [`PinnedKeyMap`] is
253/// the default now. See the module-level "Design history" doc for why:
254/// fetching a verification key from the same service whose signature is
255/// being verified is architecturally circular — whoever controls
256/// `/v1/pubkeys` would control what "verifies."
257///
258/// This type still fetches `{base_url}/v1/pubkeys` (the endpoint
259/// `cleanlib-app` publishes per cosign migration gate 1) and caches each
260/// `key_id -> PEM` pair for `ttl`, unauthenticated + `no-store` (same
261/// posture as `/health`). Its ONLY sanctioned use post-redesign is
262/// [`Self::describe_unknown_key`] — producing a human-readable advisory a
263/// caller can show a user when [`PinnedKeyMap`] doesn't recognize a
264/// `key_id`, so they can go verify a fingerprint out of band. It must never
265/// feed a `verify_attestation` trust decision directly; `AttestationKeyLookup::lookup_pem`
266/// stays implemented here (so this type remains directly testable / usable
267/// standalone, e.g. by a caller building their own advisory UX), but
268/// `verify_attestation`'s DEFAULT path never reaches it.
269#[derive(Debug, Clone)]
270pub struct PubkeysEndpointLookup {
271    http: ReqwestClient,
272    pubkeys_url: Url,
273    ttl: Duration,
274    cache: Arc<RwLock<PubkeyCache>>,
275}
276
277/// `key_id -> (pem, algorithm, fetched_at)`. `algorithm` is carried alongside
278/// the PEM purely so [`PubkeysEndpointLookup::describe_unknown_key`] can
279/// quote it in its advisory text; the verify path never reads it.
280type PubkeyCache = HashMap<String, (String, Option<String>, Instant)>;
281
282impl PubkeysEndpointLookup {
283    /// `base_url` is the App origin (e.g. `https://cleanapp.clnstrt.dev`);
284    /// this joins `/v1/pubkeys` per the gate-1 endpoint. Uses
285    /// [`DEFAULT_PUBKEY_CACHE_TTL`]; see [`Self::with_ttl`] to override.
286    pub fn new(base_url: &Url) -> Result<Self, CleanLibraryError> {
287        let pubkeys_url = base_url
288            .join("/v1/pubkeys")
289            .map_err(|e| TransportError::InvalidUrl(format!("/v1/pubkeys: {e}")))?;
290        let http = ReqwestClient::builder()
291            .timeout(Duration::from_secs(30))
292            .user_agent(concat!("cleanlib-client/", env!("CARGO_PKG_VERSION")))
293            .build()
294            .map_err(TransportError::Network)?;
295        Ok(Self {
296            http,
297            pubkeys_url,
298            ttl: DEFAULT_PUBKEY_CACHE_TTL,
299            cache: Arc::new(RwLock::new(HashMap::new())),
300        })
301    }
302
303    pub fn with_ttl(mut self, ttl: Duration) -> Self {
304        self.ttl = ttl;
305        self
306    }
307
308    async fn fetch_and_cache_all(&self) -> Result<(), CleanLibraryError> {
309        let response = self
310            .http
311            .get(self.pubkeys_url.clone())
312            .send()
313            .await
314            .map_err(TransportError::Network)?;
315        let status = response.status();
316        if !status.is_success() {
317            // Producer-side outage/5xx fetching the KEY CATALOG is a
318            // transport-layer problem, not a verdict about any specific
319            // key_id — surface as retryable Transport per the existing
320            // `is_retryable()` contract (502/503/504 style reasoning).
321            return Err(TransportError::Network(
322                response.error_for_status().unwrap_err(),
323            )
324            .into());
325        }
326        // This crate's reqwest is built with `default-features = false` (no
327        // `json` feature, matching every other call site in `transport.rs`
328        // — see `Client::get_ecosystems`), so parse via `.text()` +
329        // `serde_json::from_str` rather than pulling in a new reqwest
330        // feature for this one call.
331        let text = response.text().await.map_err(TransportError::Network)?;
332        let body: PubkeysResponse = serde_json::from_str(&text)
333            .map_err(|e| CleanLibraryError::Parse(format!("/v1/pubkeys response: {e}")))?;
334        let now = Instant::now();
335        let mut cache = self.cache.write().await;
336        for entry in body.keys {
337            cache.insert(entry.key_id, (entry.pubkey_pem, entry.algorithm, now));
338        }
339        Ok(())
340    }
341
342    /// Convenience-only advisory (see the type doc): when [`PinnedKeyMap`]
343    /// doesn't recognize a `key_id`, a caller MAY use this to look up whether
344    /// `/v1/pubkeys` reports that key_id anyway, and produce a human-readable
345    /// hint — "this key_id exists on the server, go verify its fingerprint
346    /// out of band before deciding whether to pin it." This NEVER returns a
347    /// verified/trusted signal and must NEVER be fed into a trust decision —
348    /// it only ever produces advisory text (`Option<String>`), never a PEM,
349    /// never a bool, precisely so it cannot be mistaken for a verify result.
350    ///
351    /// Returns `None` if the server is unreachable or doesn't recognize the
352    /// key_id either — absence of an advisory is not itself a security
353    /// signal in either direction.
354    pub async fn describe_unknown_key(&self, key_id: &str) -> Option<String> {
355        if self.fetch_and_cache_all().await.is_err() {
356            return None;
357        }
358        let cache = self.cache.read().await;
359        let (_, algorithm, _) = cache.get(key_id)?;
360        Some(format!(
361            "key_id {key_id:?} IS present in this App's /v1/pubkeys (algorithm: {}). \
362             This is advisory only — NOT auto-trusted. If you want to rely on it, \
363             independently verify its fingerprint out of band (never solely against \
364             this same service), then pin it via `PinnedKeyMap::with_extra_key`.",
365            algorithm.as_deref().unwrap_or("unknown")
366        ))
367    }
368}
369
370#[async_trait]
371impl AttestationKeyLookup for PubkeysEndpointLookup {
372    async fn lookup_pem(&self, key_id: &str) -> Result<String, CleanLibraryError> {
373        {
374            let cache = self.cache.read().await;
375            if let Some((pem, _, fetched_at)) = cache.get(key_id) {
376                if fetched_at.elapsed() < self.ttl {
377                    return Ok(pem.clone());
378                }
379            }
380        }
381        // Cache miss or stale — refresh the WHOLE catalog (small, 2-key
382        // response today) rather than a per-key_id endpoint, matching what
383        // `/v1/pubkeys` actually offers.
384        self.fetch_and_cache_all().await?;
385        let cache = self.cache.read().await;
386        cache
387            .get(key_id)
388            .map(|(pem, _, _)| pem.clone())
389            .ok_or_else(|| CleanLibraryError::AttestationInvalid {
390                reason_code: "ATTESTATION_KEY_ID_UNKNOWN".to_string(),
391                message: format!(
392                    "key_id {key_id:?} not present in /v1/pubkeys — cannot verify \
393                     (this is a permanent mismatch for this catalog snapshot, not a \
394                     transient fetch failure; a genuinely new key version requires a \
395                     BD-coordinated rotation event per [EphKeyDesync]). NOTE: this \
396                     impl is convenience-only post-redesign (see type doc) — \
397                     `verify_attestation`'s default path uses `PinnedKeyMap`, not this."
398                ),
399            })
400    }
401}
402
403/// Verify a `SignedAttestation` envelope (`{attestation, signature_b64,
404/// key_id}` — the exact shape `Verdict::attestation` carries) against a
405/// key resolved via `key_lookup`.
406///
407/// `Ok(())` — signature verifies against the JCS-canonical bytes of the
408/// `attestation` sub-object, resolved to a real key_id.
409/// `Err(CleanLibraryError::AttestationInvalid)` — envelope malformed, key_id
410/// unknown, or the signature genuinely does not match (permanent — do not
411/// retry the same envelope).
412/// `Err(CleanLibraryError::Transport)` — transient failure resolving the
413/// key (network/5xx fetching `/v1/pubkeys`); retry may succeed.
414pub async fn verify_attestation(
415    attestation_envelope: &serde_json::Value,
416    key_lookup: &dyn AttestationKeyLookup,
417) -> Result<(), CleanLibraryError> {
418    let malformed = |field: &str| CleanLibraryError::AttestationInvalid {
419        reason_code: "ATTESTATION_ENVELOPE_MALFORMED".to_string(),
420        message: format!("attestation envelope missing or wrong-typed field: {field}"),
421    };
422
423    let predicate = attestation_envelope
424        .get("attestation")
425        .ok_or_else(|| malformed("attestation"))?;
426    let signature_b64 = attestation_envelope
427        .get("signature_b64")
428        .and_then(|v| v.as_str())
429        .ok_or_else(|| malformed("signature_b64"))?;
430    let key_id = attestation_envelope
431        .get("key_id")
432        .and_then(|v| v.as_str())
433        .ok_or_else(|| malformed("key_id"))?;
434
435    // Step 2 of the recipe: JCS-canonicalize the PARSED predicate. This is
436    // deliberately re-derived from the parsed `Value`, not the raw wire
437    // bytes — the whole point of RFC-8785 (CLEANLIB-476 on the signer side)
438    // is that any conformant parse + reserialize reproduces the signer's
439    // exact canonical bytes regardless of incidental wire key order.
440    let canonical = serde_jcs::to_vec(predicate).map_err(|e| CleanLibraryError::AttestationInvalid {
441        reason_code: "ATTESTATION_CANONICALIZATION_FAILED".to_string(),
442        message: format!("JCS canonicalization of attestation predicate failed: {e}"),
443    })?;
444
445    let sig_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, signature_b64)
446        .map_err(|e| CleanLibraryError::AttestationInvalid {
447            reason_code: "ATTESTATION_SIGNATURE_ENCODING_INVALID".to_string(),
448            message: format!("signature_b64 is not valid base64: {e}"),
449        })?;
450    let signature = Signature::from_der(&sig_bytes).map_err(|e| CleanLibraryError::AttestationInvalid {
451        reason_code: "ATTESTATION_SIGNATURE_ENCODING_INVALID".to_string(),
452        message: format!("signature is not valid DER-encoded ECDSA: {e}"),
453    })?;
454
455    let pem = key_lookup.lookup_pem(key_id).await?;
456    let verifying_key = VerifyingKey::from_public_key_pem(&pem).map_err(|e| {
457        CleanLibraryError::AttestationInvalid {
458            reason_code: "ATTESTATION_KEY_ENCODING_INVALID".to_string(),
459            message: format!("pubkey PEM for key_id {key_id:?} is not a valid P-256 SPKI: {e}"),
460        }
461    })?;
462
463    verifying_key
464        .verify(&canonical, &signature)
465        .map_err(|e| CleanLibraryError::AttestationInvalid {
466            reason_code: "ATTESTATION_SIGNATURE_MISMATCH".to_string(),
467            message: format!(
468                "signature does not verify against key_id {key_id:?}'s canonical predicate: {e}"
469            ),
470        })
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use p256::ecdsa::signature::Signer as _;
477    use p256::ecdsa::SigningKey;
478    use p256::pkcs8::EncodePublicKey;
479    use rand_core_compat::OsRng;
480    use std::sync::Mutex;
481
482    // rand_core version alignment: p256's `ecdsa::signature::Signer` for
483    // `SigningKey` needs an `OsRng` from the `rand_core` version p256/ecdsa
484    // pin internally re-export via `elliptic_curve`. Route through the
485    // crate's own re-export path so this test never drifts against a
486    // separately-resolved `rand_core` major version.
487    mod rand_core_compat {
488        pub use p256::ecdsa::signature::rand_core::OsRng;
489    }
490
491    /// A static, in-memory [`AttestationKeyLookup`] for tests — no network.
492    /// Mirrors what a caller wiring `verify_attestation` for an air-gapped /
493    /// pre-provisioned-key deployment would implement.
494    struct StaticKeyLookup {
495        keys: HashMap<String, String>,
496        lookups: Mutex<u32>,
497    }
498
499    #[async_trait]
500    impl AttestationKeyLookup for StaticKeyLookup {
501        async fn lookup_pem(&self, key_id: &str) -> Result<String, CleanLibraryError> {
502            *self.lookups.lock().unwrap() += 1;
503            self.keys.get(key_id).cloned().ok_or_else(|| {
504                CleanLibraryError::AttestationInvalid {
505                    reason_code: "ATTESTATION_KEY_ID_UNKNOWN".to_string(),
506                    message: format!("test lookup has no key_id {key_id:?}"),
507                }
508            })
509        }
510    }
511
512    /// Build a `{attestation, signature_b64, key_id}` envelope shaped like
513    /// the App's real `SignedAttestation` wire object (see
514    /// `cleanlib-cosign-signer::Attestation` — reproduced structurally here,
515    /// NOT via a crate dependency, since that crate is `publish = false` /
516    /// service-internal and cleanlib-client must not depend on it).
517    fn sample_predicate() -> serde_json::Value {
518        serde_json::json!({
519            "artifact_hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
520            "verdict_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
521            "verdict_source": "allowed_no_findings",
522            "verdict_evaluated_at": "1970-01-01T00:00:00Z",
523            "policy_decision": "allow",
524            "policy_rule_id_matched": "rule_acme_001",
525            "risk_acceptance_status": "none",
526            "organization_id": "org_test_acme",
527            "app_version": "0.1.0",
528            "served_at": "1970-01-01T00:00:00Z",
529        })
530    }
531
532    /// Sign `predicate` with a freshly generated P-256 key, reproducing the
533    /// exact `cleanlib-cosign-signer::LocalEcdsaSigner::sign` recipe (JCS
534    /// canonical bytes -> ECDSA sign -> DER -> base64) from first principles,
535    /// so this is a genuine independent-implementation check of the
536    /// verify-side recipe against the signer's documented behavior, not a
537    /// tautology against this module's own canonicalization call.
538    fn sign_predicate(predicate: &serde_json::Value, signing_key: &SigningKey) -> (String, String) {
539        let canonical = serde_jcs::to_vec(predicate).unwrap();
540        let signature: Signature = signing_key.sign(&canonical);
541        let sig_b64 =
542            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, signature.to_der());
543        let pem = VerifyingKey::from(signing_key)
544            .to_public_key_pem(p256::pkcs8::LineEnding::LF)
545            .unwrap();
546        (sig_b64, pem)
547    }
548
549    #[tokio::test]
550    async fn positive_real_shaped_attestation_verifies() {
551        let signing_key = SigningKey::random(&mut OsRng);
552        let predicate = sample_predicate();
553        let (sig_b64, pem) = sign_predicate(&predicate, &signing_key);
554        let key_id = "projects/cleanlibrary-prod/locations/us-central1/keyRings/\
555                       cleanlibrary-signing/cryptoKeys/cleanlib-cosign-staging/cryptoKeyVersions/1";
556
557        let envelope = serde_json::json!({
558            "attestation": predicate,
559            "signature_b64": sig_b64,
560            "key_id": key_id,
561        });
562        let lookup = StaticKeyLookup {
563            keys: HashMap::from([(key_id.to_string(), pem)]),
564            lookups: Mutex::new(0),
565        };
566
567        verify_attestation(&envelope, &lookup)
568            .await
569            .expect("genuine signature over the exact predicate must verify");
570        assert_eq!(*lookup.lookups.lock().unwrap(), 1);
571    }
572
573    /// Counterexample per `[Counterexample]` discipline: this MUST fail, and
574    /// must fail as `AttestationInvalid` (permanent), not panic and not
575    /// silently return `Ok`. This is gate 3's core safety property — a
576    /// negative control catching a "verify that always passes" regression.
577    #[tokio::test]
578    async fn negative_wrong_key_fails_loud_not_silent() {
579        let signing_key = SigningKey::random(&mut OsRng);
580        let wrong_key = SigningKey::random(&mut OsRng);
581        let predicate = sample_predicate();
582        let (sig_b64, _real_pem) = sign_predicate(&predicate, &signing_key);
583        let wrong_pem = VerifyingKey::from(&wrong_key)
584            .to_public_key_pem(p256::pkcs8::LineEnding::LF)
585            .unwrap();
586        let key_id = "local-ecdsa-p256-test";
587
588        let envelope = serde_json::json!({
589            "attestation": predicate,
590            "signature_b64": sig_b64,
591            "key_id": key_id,
592        });
593        // The lookup resolves key_id to the WRONG key — simulating either a
594        // key-catalog mixup or an attacker substituting their own key_id +
595        // signature pair while a naive verifier trusts whatever key_id is
596        // present without cross-checking provenance.
597        let lookup = StaticKeyLookup {
598            keys: HashMap::from([(key_id.to_string(), wrong_pem)]),
599            lookups: Mutex::new(0),
600        };
601
602        let err = verify_attestation(&envelope, &lookup)
603            .await
604            .expect_err("signature signed by a different key must NOT verify");
605        match err {
606            CleanLibraryError::AttestationInvalid { reason_code, .. } => {
607                assert_eq!(reason_code, "ATTESTATION_SIGNATURE_MISMATCH");
608            }
609            other => panic!("expected AttestationInvalid, got {other:?}"),
610        }
611    }
612
613    #[tokio::test]
614    async fn tampered_predicate_after_signing_fails_verification() {
615        // Sibling counterexample to the wrong-key case: same key, but the
616        // predicate the caller hands to verify_attestation was mutated after
617        // signing (e.g. a MITM or a buggy cache flipping policy_decision).
618        let signing_key = SigningKey::random(&mut OsRng);
619        let predicate = sample_predicate();
620        let (sig_b64, pem) = sign_predicate(&predicate, &signing_key);
621        let key_id = "local-ecdsa-p256-test";
622
623        let mut tampered = predicate.clone();
624        tampered["policy_decision"] = serde_json::json!("deny");
625        let envelope = serde_json::json!({
626            "attestation": tampered,
627            "signature_b64": sig_b64,
628            "key_id": key_id,
629        });
630        let lookup = StaticKeyLookup {
631            keys: HashMap::from([(key_id.to_string(), pem)]),
632            lookups: Mutex::new(0),
633        };
634
635        let err = verify_attestation(&envelope, &lookup).await.unwrap_err();
636        assert!(matches!(
637            err,
638            CleanLibraryError::AttestationInvalid { reason_code, .. }
639                if reason_code == "ATTESTATION_SIGNATURE_MISMATCH"
640        ));
641    }
642
643    #[tokio::test]
644    async fn unknown_key_id_is_attestation_invalid_not_transport() {
645        let signing_key = SigningKey::random(&mut OsRng);
646        let predicate = sample_predicate();
647        let (sig_b64, _pem) = sign_predicate(&predicate, &signing_key);
648        let envelope = serde_json::json!({
649            "attestation": predicate,
650            "signature_b64": sig_b64,
651            "key_id": "some-key-id-not-in-the-catalog",
652        });
653        let lookup = StaticKeyLookup { keys: HashMap::new(), lookups: Mutex::new(0) };
654
655        let err = verify_attestation(&envelope, &lookup).await.unwrap_err();
656        // Permanent (catalog doesn't have this key), not a retryable transport
657        // error — callers must not busy-loop retrying an unknown key_id.
658        assert!(!err.is_retryable());
659        assert!(matches!(err, CleanLibraryError::AttestationInvalid { .. }));
660    }
661
662    #[tokio::test]
663    async fn malformed_envelope_missing_signature_field_is_rejected() {
664        let envelope = serde_json::json!({
665            "attestation": sample_predicate(),
666            "key_id": "x",
667            // signature_b64 missing entirely
668        });
669        let lookup = StaticKeyLookup { keys: HashMap::new(), lookups: Mutex::new(0) };
670        let err = verify_attestation(&envelope, &lookup).await.unwrap_err();
671        match err {
672            CleanLibraryError::AttestationInvalid { reason_code, .. } => {
673                assert_eq!(reason_code, "ATTESTATION_ENVELOPE_MALFORMED");
674            }
675            other => panic!("expected AttestationInvalid, got {other:?}"),
676        }
677    }
678
679    #[tokio::test]
680    async fn key_id_lookup_is_cached_across_repeated_verifies() {
681        // Not a network test (that lives in the ignored integration test
682        // below), but proves the CACHING contract at the trait-usage level:
683        // two verifies against the same key_id must not force two lookups
684        // once the first has succeeded — this is what makes repeated
685        // `Client::verify_attestation` calls in a hot loop cheap.
686        let signing_key = SigningKey::random(&mut OsRng);
687        let predicate = sample_predicate();
688        let (sig_b64, pem) = sign_predicate(&predicate, &signing_key);
689        let key_id = "local-ecdsa-p256-test";
690        let envelope = serde_json::json!({
691            "attestation": predicate,
692            "signature_b64": sig_b64,
693            "key_id": key_id,
694        });
695        let lookup = StaticKeyLookup {
696            keys: HashMap::from([(key_id.to_string(), pem)]),
697            lookups: Mutex::new(0),
698        };
699
700        verify_attestation(&envelope, &lookup).await.unwrap();
701        verify_attestation(&envelope, &lookup).await.unwrap();
702        // StaticKeyLookup itself doesn't cache (it's O(1) HashMap lookup by
703        // design, standing in for "no caching needed for a pre-provisioned
704        // bundle"); PubkeysEndpointLookup's OWN cache is exercised by the
705        // `#[ignore]` live test below since it requires a real HTTP fixture.
706        assert_eq!(*lookup.lookups.lock().unwrap(), 2);
707    }
708
709    /// Live integration test against a real `PubkeysEndpointLookup` — requires
710    /// a reachable App instance and is gated behind `--ignored` (network
711    /// dependency; this crate's default `cargo test` must stay hermetic).
712    /// Exercises the TTL-cache path: first call is a real HTTP fetch, second
713    /// (within TTL) must not re-fetch.
714    #[tokio::test]
715    #[ignore = "requires a live cleanlib-app endpoint; run with `cargo test -- --ignored`"]
716    async fn pubkeys_endpoint_lookup_caches_within_ttl() {
717        let base = Url::parse("https://cleanapp.clnstrt.dev").unwrap();
718        let lookup = PubkeysEndpointLookup::new(&base).unwrap();
719        let staging_key_id = "projects/cleanlibrary-prod/locations/us-central1/keyRings/\
720                               cleanlibrary-signing/cryptoKeys/cleanlib-cosign-staging/cryptoKeyVersions/1";
721        let pem1 = lookup.lookup_pem(staging_key_id).await.unwrap();
722        let pem2 = lookup.lookup_pem(staging_key_id).await.unwrap();
723        assert_eq!(pem1, pem2);
724        assert!(pem1.contains("BEGIN PUBLIC KEY"));
725    }
726
727    // ─── Gate-3 redesign (2026-09-13, BD-ratified per CLEANLIB-379 #804236):
728    // PinnedKeyMap is now the default trust root. These tests are the
729    // positive + negative-control pair BD required before Test-mgr co-sign,
730    // PLUS the new load-bearing structural proof that the unknown-key path
731    // is genuinely fail-closed (no fetch), not just behaviorally an error. ──
732
733    /// Positive control: the two built-in key_ids resolve to PEMs that
734    /// parse as valid P-256 SubjectPublicKeyInfo — proves the compiled-in
735    /// map itself is well-formed, independent of any signing/verify test.
736    #[test]
737    fn pinned_key_map_builtin_entries_are_well_formed() {
738        for (key_id, pem) in PinnedKeyMap::builtin() {
739            assert!(key_id.contains("cryptoKeyVersions"), "key_id shape: {key_id}");
740            VerifyingKey::from_public_key_pem(pem)
741                .unwrap_or_else(|e| panic!("built-in PEM for {key_id:?} does not parse: {e}"));
742        }
743    }
744
745    /// Positive: an attestation genuinely signed by a key pinned via
746    /// `with_extra_key` (standing in for a real staging/prod signature,
747    /// since this test can't sign with KMS's actual private key) verifies.
748    #[tokio::test]
749    async fn pinned_key_map_verifies_a_pinned_extra_key() {
750        let signing_key = SigningKey::random(&mut OsRng);
751        let pem = VerifyingKey::from(&signing_key)
752            .to_public_key_pem(p256::pkcs8::LineEnding::LF)
753            .unwrap();
754        let predicate = sample_predicate();
755        let (sig_b64, _) = sign_predicate(&predicate, &signing_key);
756        let key_id = "test-pinned-extra-key";
757        let envelope = serde_json::json!({
758            "attestation": predicate,
759            "signature_b64": sig_b64,
760            "key_id": key_id,
761        });
762        let map = PinnedKeyMap::default().with_extra_key(key_id, pem);
763
764        verify_attestation(&envelope, &map)
765            .await
766            .expect("signature over a genuinely pinned key must verify");
767    }
768
769    /// THE core counterexample BD's redesign exists to guarantee: an unknown
770    /// `key_id` must fail closed WITHOUT ever attempting a network call —
771    /// this is a STRUCTURAL proof, not just a behavioral one. The runtime
772    /// below has NO I/O or time driver enabled
773    /// (`Builder::new_current_thread()` with neither `.enable_io()` nor
774    /// `.enable_time()`); if `PinnedKeyMap::lookup_pem` attempted ANY real
775    /// network operation, this would panic ("there is no reactor running")
776    /// instead of returning cleanly. It returns cleanly — proving no network
777    /// capability exists on this path, not merely that this particular test
778    /// happened not to trigger one.
779    #[test]
780    fn pinned_key_map_unknown_key_fails_closed_with_no_io_driver() {
781        let rt = tokio::runtime::Builder::new_current_thread()
782            .build()
783            .expect("bare runtime with no I/O/time driver");
784        let map = PinnedKeyMap::default();
785        let result = rt.block_on(map.lookup_pem("key-id-not-in-any-pinned-set"));
786
787        let err = result.expect_err("unrecognized key_id must fail, not silently succeed");
788        assert!(!err.is_retryable(), "unknown key_id is permanent, not transient");
789        assert!(matches!(
790            err,
791            CleanLibraryError::AttestationInvalid { reason_code, .. }
792                if reason_code == "ATTESTATION_KEY_ID_UNKNOWN"
793        ));
794    }
795
796    /// Sibling structural proof for the KNOWN-key path: a built-in key_id
797    /// resolves successfully even with NO I/O/time driver available,
798    /// confirming the earlier no-panic result isn't just because the whole
799    /// runtime is inert — the known-key path genuinely never touches the
800    /// network either.
801    #[test]
802    fn pinned_key_map_known_key_resolves_with_no_io_driver() {
803        let rt = tokio::runtime::Builder::new_current_thread()
804            .build()
805            .expect("bare runtime with no I/O/time driver");
806        let map = PinnedKeyMap::default();
807        let (known_key_id, _) = PinnedKeyMap::builtin()[0];
808        let pem = rt
809            .block_on(map.lookup_pem(known_key_id))
810            .expect("built-in key_id must resolve without any network driver");
811        assert!(pem.contains("BEGIN PUBLIC KEY"));
812    }
813
814    /// Full end-to-end counterexample through `verify_attestation` (not just
815    /// the lookup in isolation): a well-formed, validly-signed attestation
816    /// whose `key_id` simply isn't pinned must still fail closed, exactly
817    /// like the PR #536 "wrong key" / "tampered predicate" counterexamples —
818    /// same rigor, new failure axis (unpinned rather than wrong/tampered).
819    #[tokio::test]
820    async fn end_to_end_verify_fails_closed_for_unpinned_key_id() {
821        let signing_key = SigningKey::random(&mut OsRng);
822        let predicate = sample_predicate();
823        let (sig_b64, _pem_unused) = sign_predicate(&predicate, &signing_key);
824        let envelope = serde_json::json!({
825            "attestation": predicate,
826            "signature_b64": sig_b64,
827            "key_id": "totally-unpinned-key-id",
828        });
829        let map = PinnedKeyMap::default(); // deliberately NOT pinning the signing key
830
831        let err = verify_attestation(&envelope, &map).await.unwrap_err();
832        assert!(!err.is_retryable());
833        assert!(matches!(
834            err,
835            CleanLibraryError::AttestationInvalid { reason_code, .. }
836                if reason_code == "ATTESTATION_KEY_ID_UNKNOWN"
837        ));
838    }
839
840    /// `PubkeysEndpointLookup::describe_unknown_key` must never resemble a
841    /// verify result: it returns `Option<String>` (advisory text or
842    /// nothing), never a bool/PEM/Ok(()), so it structurally cannot be
843    /// mistaken for — or misused as — a trust decision. This test only
844    /// exercises the "server unreachable" branch (hermetic, no network); the
845    /// "key found, here's the advisory" branch needs a live App and is
846    /// covered by the existing `#[ignore]`'d live test's spirit (not
847    /// duplicated here to keep default `cargo test` network-free).
848    #[tokio::test]
849    async fn describe_unknown_key_returns_none_not_a_trust_signal_when_unreachable() {
850        let base = Url::parse("https://127.0.0.1.invalid.example").unwrap();
851        let lookup = PubkeysEndpointLookup::new(&base).unwrap();
852        let advisory = lookup.describe_unknown_key("any-key-id").await;
853        assert!(
854            advisory.is_none(),
855            "unreachable server must yield None (no advisory), never fabricate one"
856        );
857    }
858}