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}