From b5b43f5d98bd5d3f41b7b02c818e1e73a2154a3d Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 21 Aug 2026 21:20:11 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(req-27):=20from=5Fsigstore=5Fbundle=20?= =?UTF-8?q?=E2=80=94=20ingest=20cosign/Sigstore=20bundles=20(#260)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit varve (#260) already produces cosign bundles (keyless, GitHub-OIDC) but wsc could only verify artifacts it signed itself — SigstoreBundle can emit wsc signatures but nothing converts an existing cosign bundle back into a KeylessSignature. Adds KeylessSignature::from_sigstore_bundle(json) parsing BOTH wire shapes: - Legacy `rekorBundle` JSON (cosign v2.4.x — what varve's v0.28.0 ships): {base64Signature, cert, rekorBundle:{SignedEntryTimestamp, Payload}}. - Protobuf `bundle.sigstore.dev/v0.3+json` envelope (verificationMaterial + messageSignature + tlogEntries). Faithful extraction (verified against two REAL fixtures committed here): - module_hash is read from the hashedrekord body's spec.data.hash.value, never recomputed; the negative-control test flips one hex char and asserts the extracted hash changes. - integratedTime (unix int) -> RFC3339, the form RekorEntry documents and verify_cert_chain parses (confirmed: cert-chain + body-binding both accept the ingested varve bundle). - v0.3 requires a Fulcio certificate; a raw-public-key (non-keyless) bundle is rejected with a specific error, proven on a real cosign v0.3 bundle. Round-trip fidelity test: from_sigstore_bundle -> from_keyless_signature -> to_json -> from_json preserves signature, module_hash, cert chain and rekor fields (uuid/inclusion_proof intentionally empty for legacy — documented). KNOWN LIMITATION → REQ-28 (#231, the verify half): cosign emits ECDSA signatures in ASN.1 DER (varve's is 71 bytes, 3045…), but the offline verifier's verify_crypto uses P256Signature::from_slice (fixed 64-byte P1363), so it currently rejects an ingested DER signature. Making the verifier accept DER (from_der fallback) and handling the empty Rekor uuid on the offline path is REQ-28's scope — that is where "verify an ingested cosign bundle offline" completes. from_sigstore_bundle here is the faithful ingestion half. Fixtures: legacy = varve v0.28.0 public release; v0.3 = real cosign --new-bundle-format output (see fixtures README). Tests: wsc lib 610 pass/3 ignored; sigstore_bundle 7 pass. Refs: #260, #231 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012aR3Md1h46K9wAUWMQiESH --- src/lib/src/signature/keyless/format.rs | 397 ++++++++++++++++++ .../tests/fixtures/sigstore_bundles/README.md | 19 + .../legacy_rekorbundle_keyless.json | 1 + .../protobuf_v0.3_localkey.json | 1 + src/lib/tests/sigstore_bundle.rs | 261 ++++++++++++ 5 files changed, 679 insertions(+) create mode 100644 src/lib/tests/fixtures/sigstore_bundles/README.md create mode 100644 src/lib/tests/fixtures/sigstore_bundles/legacy_rekorbundle_keyless.json create mode 100644 src/lib/tests/fixtures/sigstore_bundles/protobuf_v0.3_localkey.json create mode 100644 src/lib/tests/sigstore_bundle.rs diff --git a/src/lib/src/signature/keyless/format.rs b/src/lib/src/signature/keyless/format.rs index 87bbb68..f5c9230 100644 --- a/src/lib/src/signature/keyless/format.rs +++ b/src/lib/src/signature/keyless/format.rs @@ -122,6 +122,272 @@ impl KeylessSignature { } } + /// Ingest an existing cosign / Sigstore bundle into a + /// [`KeylessSignature`] the offline verifiers can consume (REQ-27, + /// issue #260). + /// + /// Two on-disk shapes are recognised, in this order: + /// + /// * **Legacy `rekorBundle`** — the + /// `{ base64Signature, cert, rekorBundle: { SignedEntryTimestamp, + /// Payload } }` shape emitted by older cosign / `sigstore` clients. + /// This is varve v0.28.0's shape and the primary ingest target. + /// * **Protobuf v0.3** — + /// `application/vnd.dev.sigstore.bundle.v0.3+json` with a + /// `verificationMaterial` / `messageSignature` envelope. + /// + /// # Faithful extraction + /// + /// Every value is copied straight out of the bundle; nothing is + /// recomputed or normalised away. In particular `module_hash` is read + /// from the Rekor body's `spec.data.hash.value` (legacy) or from + /// `messageSignature.messageDigest.digest` (v0.3) — a corrupted digest + /// in the input is propagated verbatim, never "fixed". + /// + /// # Errors + /// + /// Returns [`WSError::KeylessFormatError`] if the JSON is malformed, the + /// shape is unrecognised, a required field is missing / mis-encoded, or a + /// v0.3 bundle carries a raw public key instead of a Fulcio certificate. + pub fn from_sigstore_bundle(json: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(json).map_err(|e| { + WSError::KeylessFormatError(format!("Sigstore bundle is not valid JSON: {}", e)) + })?; + let obj = value.as_object().ok_or_else(|| { + WSError::KeylessFormatError("Sigstore bundle is not a JSON object".to_string()) + })?; + + // Shape detection, in order: legacy `rekorBundle`, then v0.3. + if obj.contains_key("rekorBundle") { + Self::from_legacy_rekor_bundle(&value) + } else if obj + .get("mediaType") + .and_then(|m| m.as_str()) + .map(|s| s.starts_with("application/vnd.dev.sigstore.bundle")) + .unwrap_or(false) + || obj.contains_key("verificationMaterial") + { + Self::from_v03_bundle(&value) + } else { + Err(WSError::KeylessFormatError( + "unrecognized Sigstore bundle format".to_string(), + )) + } + } + + /// Map the legacy `rekorBundle` shape (varve's, the primary target). + fn from_legacy_rekor_bundle(value: &serde_json::Value) -> Result { + // signature: base64 -> raw bytes. NOTE: cosign emits ECDSA signatures + // in ASN.1 DER form (`30 45 02 21 ...`), NOT the IEEE-P1363 (r||s) + // form `KeylessSignature.signature` is documented to hold. We store + // the DER bytes verbatim so the Rekor-body binding check (which + // compares against the identical base64 in `body.spec.signature`) + // succeeds; `verify_artifact_binding` currently expects P1363 and + // would need a `Signature::from_der` path to accept an ingested + // legacy bundle. See the REQ-27 report. + let base64_sig = value["base64Signature"].as_str().ok_or_else(|| { + WSError::KeylessFormatError("legacy Sigstore bundle missing 'base64Signature'".to_string()) + })?; + let signature = BASE64.decode(base64_sig).map_err(|e| { + WSError::KeylessFormatError(format!("'base64Signature' is not valid base64: {}", e)) + })?; + + // cert: base64 of PEM text -> PEM string(s). Fulcio may concatenate a + // leaf plus intermediate(s); split into one chain entry per block. + let cert_b64 = value["cert"].as_str().ok_or_else(|| { + WSError::KeylessFormatError("legacy Sigstore bundle missing 'cert'".to_string()) + })?; + let cert_pem_bytes = BASE64.decode(cert_b64).map_err(|e| { + WSError::KeylessFormatError(format!("'cert' is not valid base64: {}", e)) + })?; + let cert_pem = String::from_utf8(cert_pem_bytes).map_err(|e| { + WSError::KeylessFormatError(format!("'cert' is not valid UTF-8 PEM: {}", e)) + })?; + let cert_chain = split_pem_certificates(&cert_pem); + + let rekor_bundle = &value["rekorBundle"]; + let payload = &rekor_bundle["Payload"]; + + // module_hash: read from the Rekor body's hashedrekord digest. + let body_b64 = payload["body"].as_str().ok_or_else(|| { + WSError::KeylessFormatError( + "legacy Sigstore bundle missing 'rekorBundle.Payload.body'".to_string(), + ) + })?; + let module_hash = module_hash_from_hashedrekord_body(body_b64)?; + + let log_index = json_u64(&payload["logIndex"]).ok_or_else(|| { + WSError::KeylessFormatError( + "legacy Sigstore bundle 'rekorBundle.Payload.logIndex' is not an integer" + .to_string(), + ) + })?; + let log_id = payload["logID"].as_str().ok_or_else(|| { + WSError::KeylessFormatError( + "legacy Sigstore bundle missing 'rekorBundle.Payload.logID'".to_string(), + ) + })?; + let integrated_time = integrated_time_to_rfc3339(&payload["integratedTime"])?; + let signed_entry_timestamp = rekor_bundle["SignedEntryTimestamp"].as_str().ok_or_else(|| { + WSError::KeylessFormatError( + "legacy Sigstore bundle missing 'rekorBundle.SignedEntryTimestamp'".to_string(), + ) + })?; + + let rekor_entry = RekorEntry { + // Legacy `rekorBundle` bundles carry no entry UUID. NOTE: an empty + // uuid makes `rekor::is_rekor_skipped` return true, which the + // ONLINE `KeylessVerifier::verify` path (signer.rs:601) treats as + // "no transparency proof" and rejects. The offline SET material + // (SignedEntryTimestamp + logID) and the body-binding inputs are + // fully present here; accepting an ingested legacy bundle on the + // online path needs a follow-up (synthesised/derived uuid). See + // the REQ-27 report. + uuid: String::new(), + log_index, + body: body_b64.to_string(), + log_id: log_id.to_string(), + // Legacy bundles carry only the SET, no Merkle inclusion proof. + // Offline SET verification does not need it (inclusion_verified + // stays false); left empty rather than fabricated. + inclusion_proof: Vec::new(), + signed_entry_timestamp: signed_entry_timestamp.to_string(), + integrated_time, + }; + + Ok(Self::new(signature, cert_chain, rekor_entry, module_hash)) + } + + /// Map the protobuf-JSON v0.3 shape (cosign `--new-bundle-format`). + fn from_v03_bundle(value: &serde_json::Value) -> Result { + let vm = &value["verificationMaterial"]; + + // The certificate requirement is checked FIRST — before decoding the + // messageSignature envelope — so a raw-public-key (non-Fulcio) bundle + // fails with the specific cert-requirement error, not an incidental + // decode error further down. (Non-vacuity for the v0.3 fixture test.) + let cert_chain: Vec = if let Some(cert) = vm.get("certificate") { + let raw = cert["rawBytes"].as_str().ok_or_else(|| { + WSError::KeylessFormatError( + "Sigstore v0.3 'certificate' missing 'rawBytes'".to_string(), + ) + })?; + let der = BASE64.decode(raw).map_err(|e| { + WSError::KeylessFormatError(format!( + "Sigstore v0.3 'certificate.rawBytes' is not valid base64: {}", + e + )) + })?; + vec![der_to_pem(&der)] + } else if let Some(chain) = vm.get("x509CertificateChain") { + let certs = chain["certificates"].as_array().ok_or_else(|| { + WSError::KeylessFormatError( + "Sigstore v0.3 'x509CertificateChain.certificates' is not an array".to_string(), + ) + })?; + let mut out = Vec::with_capacity(certs.len()); + for c in certs { + let raw = c["rawBytes"].as_str().ok_or_else(|| { + WSError::KeylessFormatError( + "Sigstore v0.3 certificate missing 'rawBytes'".to_string(), + ) + })?; + let der = BASE64.decode(raw).map_err(|e| { + WSError::KeylessFormatError(format!( + "Sigstore v0.3 certificate 'rawBytes' is not valid base64: {}", + e + )) + })?; + out.push(der_to_pem(&der)); + } + out + } else if vm.get("publicKey").is_some() { + return Err(WSError::KeylessFormatError( + "Sigstore v0.3 bundle carries a raw public key, not a Fulcio certificate; \ + keyless offline verification requires a certificate (Fulcio/keyless) bundle" + .to_string(), + )); + } else { + return Err(WSError::KeylessFormatError( + "Sigstore v0.3 bundle has no certificate in verificationMaterial".to_string(), + )); + }; + + // signature + module_hash from messageSignature. + let msg_sig = &value["messageSignature"]; + let sig_b64 = msg_sig["signature"].as_str().ok_or_else(|| { + WSError::KeylessFormatError( + "Sigstore v0.3 bundle missing 'messageSignature.signature'".to_string(), + ) + })?; + let signature = BASE64.decode(sig_b64).map_err(|e| { + WSError::KeylessFormatError(format!( + "'messageSignature.signature' is not valid base64: {}", + e + )) + })?; + + let digest = msg_sig["messageDigest"]["digest"].as_str().ok_or_else(|| { + WSError::KeylessFormatError( + "Sigstore v0.3 bundle missing 'messageSignature.messageDigest.digest'".to_string(), + ) + })?; + let module_hash = decode_v03_digest(digest)?; + + // Rekor entry from the first transparency-log entry. + let tlog = vm["tlogEntries"] + .as_array() + .and_then(|a| a.first()) + .ok_or_else(|| { + WSError::KeylessFormatError( + "Sigstore v0.3 bundle has no transparency-log entries".to_string(), + ) + })?; + + let log_index = json_u64(&tlog["logIndex"]).ok_or_else(|| { + WSError::KeylessFormatError("Sigstore v0.3 tlogEntry 'logIndex' is not an integer".to_string()) + })?; + // logId.keyId is base64 of the log's key-id bytes; wsc's RekorEntry + // stores the log id hex-encoded (matching the legacy `logID`). + let log_id = match tlog["logId"]["keyId"].as_str() { + Some(key_id_b64) => { + let key_id = BASE64.decode(key_id_b64).map_err(|e| { + WSError::KeylessFormatError(format!( + "Sigstore v0.3 tlogEntry 'logId.keyId' is not valid base64: {}", + e + )) + })?; + hex::encode(key_id) + } + None => String::new(), + }; + let integrated_time = integrated_time_to_rfc3339(&tlog["integratedTime"])?; + let signed_entry_timestamp = tlog["inclusionPromise"]["signedEntryTimestamp"] + .as_str() + .unwrap_or_default() + .to_string(); + let body = tlog["canonicalizedBody"] + .as_str() + .unwrap_or_default() + .to_string(); + + let rekor_entry = RekorEntry { + uuid: String::new(), + log_index, + body, + log_id, + // The v0.3 `inclusionProof` (checkpoint + Merkle hashes) is not + // reserialised: there is no keyless-v0.3 fixture to validate a + // byte format against, and offline SET verification does not need + // it. Left empty rather than fabricated (consistent with legacy). + inclusion_proof: Vec::new(), + signed_entry_timestamp, + integrated_time, + }; + + Ok(Self::new(signature, cert_chain, rekor_entry, module_hash)) + } + /// Serialize to bytes for WASM custom section /// /// # Binary Format @@ -796,10 +1062,141 @@ fn first_certificate_der(pem_bytes: &[u8]) -> Option> { None } +/// Split a PEM string that may contain several concatenated CERTIFICATE +/// blocks into one string per certificate. If no END marker is present the +/// whole (trimmed) input is returned as a single entry. +fn split_pem_certificates(pem: &str) -> Vec { + const END: &str = "-----END CERTIFICATE-----"; + let mut certs = Vec::new(); + let mut rest = pem; + while let Some(idx) = rest.find(END) { + let end = idx + END.len(); + let block = rest[..end].trim_start().to_string(); + certs.push(block); + rest = &rest[end..]; + } + if certs.is_empty() { + certs.push(pem.trim().to_string()); + } + certs +} + +/// Wrap DER certificate bytes in a PEM CERTIFICATE block (64-column base64). +fn der_to_pem(der: &[u8]) -> String { + let b64 = BASE64.encode(der); + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for chunk in b64.as_bytes().chunks(64) { + // chunk is ASCII base64 and therefore always valid UTF-8. + pem.push_str(std::str::from_utf8(chunk).unwrap_or_default()); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + pem +} + +/// Decode a base64 `hashedrekord` body and read `spec.data.hash.value` as the +/// hex artifact digest, returning the 32-byte SHA-256. The digest is read +/// straight from the body — never recomputed — so a corrupted input digest is +/// propagated faithfully. +fn module_hash_from_hashedrekord_body(body_b64: &str) -> Result, WSError> { + let body_bytes = BASE64.decode(body_b64).map_err(|e| { + WSError::KeylessFormatError(format!("Rekor body is not valid base64: {}", e)) + })?; + let body: serde_json::Value = serde_json::from_slice(&body_bytes).map_err(|e| { + WSError::KeylessFormatError(format!("Rekor body is not valid JSON: {}", e)) + })?; + let hash_hex = body["spec"]["data"]["hash"]["value"] + .as_str() + .ok_or_else(|| { + WSError::KeylessFormatError("Rekor body missing 'spec.data.hash.value'".to_string()) + })?; + let module_hash = hex::decode(hash_hex).map_err(|e| { + WSError::KeylessFormatError(format!("Rekor body hash value is not valid hex: {}", e)) + })?; + if module_hash.len() != 32 { + return Err(WSError::KeylessFormatError(format!( + "Rekor body hash is {} bytes, expected 32 (SHA-256)", + module_hash.len() + ))); + } + Ok(module_hash) +} + +/// Decode a v0.3 `messageDigest.digest`. Real cosign v0.3 bundles encode the +/// digest as base64; wsc's own [`SigstoreBundle::from_keyless_signature`] +/// currently emits it as hex, so both encodings are accepted here. +fn decode_v03_digest(digest: &str) -> Result, WSError> { + let bytes = if digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit()) { + // wsc-emitted hex form. + hex::decode(digest).map_err(|e| { + WSError::KeylessFormatError(format!("messageDigest.digest hex decode failed: {}", e)) + })? + } else { + // cosign wire form (base64). + BASE64.decode(digest).map_err(|e| { + WSError::KeylessFormatError(format!( + "messageDigest.digest is neither 64-char hex nor base64: {}", + e + )) + })? + }; + if bytes.len() != 32 { + return Err(WSError::KeylessFormatError(format!( + "messageDigest.digest is {} bytes, expected 32 (SHA-256)", + bytes.len() + ))); + } + Ok(bytes) +} + +/// Read a JSON value holding an unsigned integer encoded either as a number +/// (legacy `logIndex`) or a decimal string (v0.3 `logIndex`). +fn json_u64(v: &serde_json::Value) -> Option { + v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse::().ok())) +} + +/// Read a JSON value holding a signed integer as a number or decimal string. +fn json_i64(v: &serde_json::Value) -> Option { + v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse::().ok())) +} + +/// Convert an `integratedTime` (Unix seconds, given as a JSON number or +/// string) into the RFC3339 form the wsc `RekorEntry.integrated_time` field +/// is documented to hold. [`KeylessSignature::verify_cert_chain`] parses this +/// field with `parse_from_rfc3339`, so storing bare Unix seconds would make +/// every ingested bundle fail cert-chain verification. +fn integrated_time_to_rfc3339(v: &serde_json::Value) -> Result { + let secs = json_i64(v).ok_or_else(|| { + WSError::KeylessFormatError("integratedTime is not an integer".to_string()) + })?; + let dt = chrono::DateTime::::from_timestamp(secs, 0).ok_or_else(|| { + WSError::KeylessFormatError(format!("integratedTime {} is out of range", secs)) + })?; + Ok(dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) +} + #[cfg(test)] mod tests { use super::*; + /// `der_to_pem` (used by the v0.3 cert path, which neither committed + /// fixture exercises) must emit a PEM block that round-trips back to the + /// exact DER via `first_certificate_der`. Guards against a malformed-PEM + /// silent drop of the leaf cert on future keyless v0.3 bundles. + #[test] + fn test_der_to_pem_round_trips_through_first_certificate_der() { + // >64 bytes so the base64 body spans multiple 64-column lines. + let der: Vec = (0..200u32).map(|i| (i % 251) as u8).collect(); + let pem = der_to_pem(&der); + assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n")); + assert!(pem.trim_end().ends_with("-----END CERTIFICATE-----")); + assert_eq!( + first_certificate_der(pem.as_bytes()).as_deref(), + Some(&der[..]), + "der_to_pem output must parse back to the original DER" + ); + } + fn create_test_signature() -> KeylessSignature { let signature = vec![1, 2, 3, 4, 5]; let cert_chain = vec![ diff --git a/src/lib/tests/fixtures/sigstore_bundles/README.md b/src/lib/tests/fixtures/sigstore_bundles/README.md new file mode 100644 index 0000000..b6abcbc --- /dev/null +++ b/src/lib/tests/fixtures/sigstore_bundles/README.md @@ -0,0 +1,19 @@ +# Sigstore bundle fixtures (REQ-27 / #260) + +Real, public bundles used to test `KeylessSignature::from_sigstore_bundle`. + +- **`legacy_rekorbundle_keyless.json`** — the `SHA256SUMS.txt.cosign.bundle` from + pulseengine/varve **v0.28.0** (public release). Legacy cosign `rekorBundle` + shape: `{base64Signature, cert, rekorBundle:{SignedEntryTimestamp, Payload}}`, + keyless (GitHub-OIDC Fulcio cert). Internally consistent: the hashedrekord + body's `spec.data.hash.value` equals `sha256(SHA256SUMS.txt)` and + `base64Signature` equals the body's signature content. This is the shape varve + currently ships and the primary #260 target. + +- **`protobuf_v0.3_localkey.json`** — a real cosign-emitted bundle, + `mediaType: application/vnd.dev.sigstore.bundle.v0.3+json`, produced by + `cosign sign-blob --new-bundle-format` with a local key (so it carries + `verificationMaterial.publicKey`, not a Fulcio `certificate`). Used to test + v0.3 envelope detection/parsing and the explicit rejection of non-keyless + (public-key) bundles. A v0.3 *keyless* (cert) fixture needs Fulcio/OIDC and is + covered by the gated e2e; see follow-up. diff --git a/src/lib/tests/fixtures/sigstore_bundles/legacy_rekorbundle_keyless.json b/src/lib/tests/fixtures/sigstore_bundles/legacy_rekorbundle_keyless.json new file mode 100644 index 0000000..923768a --- /dev/null +++ b/src/lib/tests/fixtures/sigstore_bundles/legacy_rekorbundle_keyless.json @@ -0,0 +1 @@ +{"base64Signature":"MEUCIQCMgFZMinIDxnxgUJD2ZenoZpTbYnmzVmd+COEUkDeEyQIgK+1W0Wt5AFimyB8ivv4bNl3P8t0SaALWqkd8mv1f8QQ=","cert":"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUhCRENDQm91Z0F3SUJBZ0lVQzlsQzQ1RGxLQWZoM0dqZDBEK0x0M1BsWVF3d0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpZd09ESXhNRFkxTWpRd1doY05Nall3T0RJeE1EY3dNalF3V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUV5a3MxRTViNGRXRnFuZndYWHNtVTBGRVp2M202VFhQa1M3aWsKR1FjS0tCdjRocHN6bmFvNE56SzBQMUdieW5IQ0I0cytJalpqc3lIUS9nMVJPWDBySWFPQ0Jhb3dnZ1dtTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVJZ2ZIClNZS29jUElIOXl0OEJPSGUwcnQ3aDVrd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d1lnWURWUjBSQVFIL0JGZ3dWb1pVYUhSMGNITTZMeTluYVhSb2RXSXVZMjl0TDNCMWJITmxaVzVuYVc1bApMM1poY25abEx5NW5hWFJvZFdJdmQyOXlhMlpzYjNkekwzSmxiR1ZoYzJVdWVXMXNRSEpsWm5NdmRHRm5jeTkyCk1DNHlPQzR3TURrR0Npc0dBUVFCZzc4d0FRRUVLMmgwZEhCek9pOHZkRzlyWlc0dVlXTjBhVzl1Y3k1bmFYUm8KZFdKMWMyVnlZMjl1ZEdWdWRDNWpiMjB3RWdZS0t3WUJCQUdEdnpBQkFnUUVjSFZ6YURBMkJnb3JCZ0VFQVlPLwpNQUVEQkNnMFlXSTJZbUkzWldJMk9XWm1ZV1l6TWpFMU5HTTVZamxqWVdGbE9EUmhPR05pWXpaak56YzVNQlVHCkNpc0dBUVFCZzc4d0FRUUVCMUpsYkdWaGMyVXdId1lLS3dZQkJBR0R2ekFCQlFRUmNIVnNjMlZsYm1kcGJtVXYKZG1GeWRtVXdId1lLS3dZQkJBR0R2ekFCQmdRUmNtVm1jeTkwWVdkekwzWXdMakk0TGpBd093WUtLd1lCQkFHRAp2ekFCQ0FRdERDdG9kSFJ3Y3pvdkwzUnZhMlZ1TG1GamRHbHZibk11WjJsMGFIVmlkWE5sY21OdmJuUmxiblF1ClkyOXRNR1FHQ2lzR0FRUUJnNzh3QVFrRVZneFVhSFIwY0hNNkx5OW5hWFJvZFdJdVkyOXRMM0IxYkhObFpXNW4KYVc1bEwzWmhjblpsTHk1bmFYUm9kV0l2ZDI5eWEyWnNiM2R6TDNKbGJHVmhjMlV1ZVcxc1FISmxabk12ZEdGbgpjeTkyTUM0eU9DNHdNRGdHQ2lzR0FRUUJnNzh3QVFvRUtnd29OR0ZpTm1KaU4yVmlOamxtWm1GbU16SXhOVFJqCk9XSTVZMkZoWlRnMFlUaGpZbU0yWXpjM09UQWRCZ29yQmdFRUFZTy9NQUVMQkE4TURXZHBkR2gxWWkxb2IzTjAKWldRd05BWUtLd1lCQkFHRHZ6QUJEQVFtRENSb2RIUndjem92TDJkcGRHaDFZaTVqYjIwdmNIVnNjMlZsYm1kcApibVV2ZG1GeWRtVXdPQVlLS3dZQkJBR0R2ekFCRFFRcURDZzBZV0kyWW1JM1pXSTJPV1ptWVdZek1qRTFOR001CllqbGpZV0ZsT0RSaE9HTmlZelpqTnpjNU1DRUdDaXNHQVFRQmc3OHdBUTRFRXd3UmNtVm1jeTkwWVdkekwzWXcKTGpJNExqQXdHZ1lLS3dZQkJBR0R2ekFCRHdRTURBb3hNekkyTXpZMU5qWTVNQzRHQ2lzR0FRUUJnNzh3QVJBRQpJQXdlYUhSMGNITTZMeTluYVhSb2RXSXVZMjl0TDNCMWJITmxaVzVuYVc1bE1Ca0dDaXNHQVFRQmc3OHdBUkVFCkN3d0pNakV6TVRJME1UZzFNR1FHQ2lzR0FRUUJnNzh3QVJJRVZneFVhSFIwY0hNNkx5OW5hWFJvZFdJdVkyOXQKTDNCMWJITmxaVzVuYVc1bEwzWmhjblpsTHk1bmFYUm9kV0l2ZDI5eWEyWnNiM2R6TDNKbGJHVmhjMlV1ZVcxcwpRSEpsWm5NdmRHRm5jeTkyTUM0eU9DNHdNRGdHQ2lzR0FRUUJnNzh3QVJNRUtnd29OR0ZpTm1KaU4yVmlOamxtClptRm1Nekl4TlRSak9XSTVZMkZoWlRnMFlUaGpZbU0yWXpjM09UQVVCZ29yQmdFRUFZTy9NQUVVQkFZTUJIQjEKYzJnd1dBWUtLd1lCQkFHRHZ6QUJGUVJLREVob2RIUndjem92TDJkcGRHaDFZaTVqYjIwdmNIVnNjMlZsYm1kcApibVV2ZG1GeWRtVXZZV04wYVc5dWN5OXlkVzV6THpNeU5EVTFPRGN6TXpBekwyRjBkR1Z0Y0hSekx6RXdGZ1lLCkt3WUJCQUdEdnpBQkZnUUlEQVp3ZFdKc2FXTXdVUVlLS3dZQkJBR0R2ekFCR0FSRERFRnlaWEJ2T25CMWJITmwKWlc1bmFXNWxRREl4TXpFeU5ERTROUzkyWVhKMlpVQXhNekkyTXpZMU5qWTVPbkpsWmpweVpXWnpMM1JoWjNNdgpkakF1TWpndU1EQ0JpUVlLS3dZQkJBSFdlUUlFQWdSN0JIa0Fkd0IxQU4wOU1Hckd4eEV5WXhrZUhKbG5Od0tpClNsNjQzanl0LzRlS2NvQXZLZTZPQUFBQm9DTVgybDRBQUFRREFFWXdSQUlnWW9wQUtwWEl0QUtjYmxubU5wOHQKcnRhNVhpQ1BxTGpLWlVnU2dzZ1JMeUVDSUJQUG4zbFN3L3paZWFrdmN3UmFEMmsyejlDeVZ5UDhRZ0xyTXFNbwp1dkFlTUFvR0NDcUdTTTQ5QkFNREEyY0FNR1FDTUZtbkFYQ3lTNksySWJmOHFqQVJ4SnZESWx0dDMwaDVsL1QyClNKRkZtcmdyOEFBTGxzcHg2YWtMeEZHd2hXMW5Xd0l3REZmQjhMc05Wdk45R2NXdC9INUVHTmNOVFEwMTRJZ2oKdFcyUE1URGZDUDVaek11aG90dThZSVRrSmphVmNGK0kKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=","rekorBundle":{"SignedEntryTimestamp":"MEUCIFffdO7dv1YogxgWo4Z5zqcl72T/TsscsPR6d+vnFuglAiEA3Bbg7qpN1f/sGnx723hUg99zIVbQb26C7NK+ckufgUY=","Payload":{"body":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiIwZjJlN2Q5MmZmN2UxNTgxZDJhOTY2ZGI4ZGNkZmMwNTA1NGNkZWI1Mzk1YWVjNTY1NjExMjJlMTFiOGE2OTdmIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJUUNNZ0ZaTWluSUR4bnhnVUpEMlplbm9acFRiWW5telZtZCtDT0VVa0RlRXlRSWdLKzFXMFd0NUFGaW15QjhpdnY0Yk5sM1A4dDBTYUFMV3FrZDhtdjFmOFFRPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVaENSRU5EUW05MVowRjNTVUpCWjBsVlF6bHNRelExUkd4TFFXWm9NMGRxWkRCRUsweDBNMUJzV1ZGM2QwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFpkMDlFU1hoTlJGa3hUV3BSZDFkb1kwNU5hbGwzVDBSSmVFMUVZM2ROYWxGM1YycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVY1YTNNeFJUVmlOR1JYUm5GdVpuZFlXSE50VlRCR1JWcDJNMjAyVkZoUWExTTNhV3NLUjFGalMwdENkalJvY0hONmJtRnZORTU2U3pCUU1VZGllVzVJUTBJMGN5dEphbHBxYzNsSVVTOW5NVkpQV0RCeVNXRlBRMEpoYjNkbloxZHRUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZKWjJaSUNsTlpTMjlqVUVsSU9YbDBPRUpQU0dVd2NuUTNhRFZyZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDFsbldVUldVakJTUVZGSUwwSkdaM2RXYjFwVllVaFNNR05JVFRaTWVUbHVZVmhTYjJSWFNYVlpNamwwVEROQ01XSklUbXhhVnpWdVlWYzFiQXBNTTFwb1kyNWFiRXg1Tlc1aFdGSnZaRmRKZG1ReU9YbGhNbHB6WWpOa2Vrd3pTbXhpUjFab1l6SlZkV1ZYTVhOUlNFcHNXbTVOZG1SSFJtNWplVGt5Q2sxRE5IbFBRelIzVFVSclIwTnBjMGRCVVZGQ1p6YzRkMEZSUlVWTE1tZ3daRWhDZWs5cE9IWmtSemx5V2xjMGRWbFhUakJoVnpsMVkzazFibUZZVW04S1pGZEtNV015Vm5sWk1qbDFaRWRXZFdSRE5XcGlNakIzUldkWlMwdDNXVUpDUVVkRWRucEJRa0ZuVVVWalNGWjZZVVJCTWtKbmIzSkNaMFZGUVZsUEx3cE5RVVZFUWtObk1GbFhTVEpaYlVreldsZEpNazlYV20xWlYxbDZUV3BGTVU1SFRUVlphbXhxV1ZkR2JFOUVVbWhQUjA1cFdYcGFhazU2WXpWTlFsVkhDa05wYzBkQlVWRkNaemM0ZDBGUlVVVkNNVXBzWWtkV2FHTXlWWGRJZDFsTFMzZFpRa0pCUjBSMmVrRkNRbEZSVW1OSVZuTmpNbFpzWW0xa2NHSnRWWFlLWkcxR2VXUnRWWGRJZDFsTFMzZFpRa0pCUjBSMmVrRkNRbWRSVW1OdFZtMWplVGt3V1Zka2Vrd3pXWGRNYWtrMFRHcEJkMDkzV1V0TGQxbENRa0ZIUkFwMmVrRkNRMEZSZEVSRGRHOWtTRkozWTNwdmRrd3pVblpoTWxaMVRHMUdhbVJIYkhaaWJrMTFXakpzTUdGSVZtbGtXRTVzWTIxT2RtSnVVbXhpYmxGMUNsa3lPWFJOUjFGSFEybHpSMEZSVVVKbk56aDNRVkZyUlZabmVGVmhTRkl3WTBoTk5reDVPVzVoV0ZKdlpGZEpkVmt5T1hSTU0wSXhZa2hPYkZwWE5XNEtZVmMxYkV3eldtaGpibHBzVEhrMWJtRllVbTlrVjBsMlpESTVlV0V5V25OaU0yUjZURE5LYkdKSFZtaGpNbFYxWlZjeGMxRklTbXhhYmsxMlpFZEdiZ3BqZVRreVRVTTBlVTlETkhkTlJHZEhRMmx6UjBGUlVVSm5OemgzUVZGdlJVdG5kMjlPUjBacFRtMUthVTR5Vm1sT2FteHRXbTFHYlUxNlNYaE9WRkpxQ2s5WFNUVlpNa1pvV2xSbk1GbFVhR3BaYlUweVdYcGpNMDlVUVdSQ1oyOXlRbWRGUlVGWlR5OU5RVVZNUWtFNFRVUlhaSEJrUjJneFdXa3hiMkl6VGpBS1dsZFJkMDVCV1V0TGQxbENRa0ZIUkhaNlFVSkVRVkZ0UkVOU2IyUklVbmRqZW05MlRESmtjR1JIYURGWmFUVnFZakl3ZG1OSVZuTmpNbFpzWW0xa2NBcGliVlYyWkcxR2VXUnRWWGRQUVZsTFMzZFpRa0pCUjBSMmVrRkNSRkZSY1VSRFp6QlpWMGt5V1cxSk0xcFhTVEpQVjFwdFdWZFplazFxUlRGT1IwMDFDbGxxYkdwWlYwWnNUMFJTYUU5SFRtbFplbHBxVG5wak5VMURSVWREYVhOSFFWRlJRbWMzT0hkQlVUUkZSWGQzVW1OdFZtMWplVGt3V1Zka2Vrd3pXWGNLVEdwSk5FeHFRWGRIWjFsTFMzZFpRa0pCUjBSMmVrRkNSSGRSVFVSQmIzaE5la2t5VFhwWk1VNXFXVFZOUXpSSFEybHpSMEZSVVVKbk56aDNRVkpCUlFwSlFYZGxZVWhTTUdOSVRUWk1lVGx1WVZoU2IyUlhTWFZaTWpsMFRETkNNV0pJVG14YVZ6VnVZVmMxYkUxQ2EwZERhWE5IUVZGUlFtYzNPSGRCVWtWRkNrTjNkMHBOYWtWNlRWUkpNRTFVWnpGTlIxRkhRMmx6UjBGUlVVSm5OemgzUVZKSlJWWm5lRlZoU0ZJd1kwaE5Oa3g1T1c1aFdGSnZaRmRKZFZreU9YUUtURE5DTVdKSVRteGFWelZ1WVZjMWJFd3pXbWhqYmxwc1RIazFibUZZVW05a1YwbDJaREk1ZVdFeVduTmlNMlI2VEROS2JHSkhWbWhqTWxWMVpWY3hjd3BSU0Vwc1dtNU5kbVJIUm01amVUa3lUVU0wZVU5RE5IZE5SR2RIUTJselIwRlJVVUpuTnpoM1FWSk5SVXRuZDI5T1IwWnBUbTFLYVU0eVZtbE9hbXh0Q2xwdFJtMU5la2w0VGxSU2FrOVhTVFZaTWtab1dsUm5NRmxVYUdwWmJVMHlXWHBqTTA5VVFWVkNaMjl5UW1kRlJVRlpUeTlOUVVWVlFrRlpUVUpJUWpFS1l6Sm5kMWRCV1V0TGQxbENRa0ZIUkhaNlFVSkdVVkpMUkVWb2IyUklVbmRqZW05MlRESmtjR1JIYURGWmFUVnFZakl3ZG1OSVZuTmpNbFpzWW0xa2NBcGliVlYyWkcxR2VXUnRWWFpaVjA0d1lWYzVkV041T1hsa1Z6VjZUSHBOZVU1RVZURlBSR042VFhwQmVrd3lSakJrUjFaMFkwaFNla3g2UlhkR1oxbExDa3QzV1VKQ1FVZEVkbnBCUWtablVVbEVRVnAzWkZkS2MyRlhUWGRWVVZsTFMzZFpRa0pCUjBSMmVrRkNSMEZTUkVSRlJubGFXRUoyVDI1Q01XSklUbXdLV2xjMWJtRlhOV3hSUkVsNFRYcEZlVTVFUlRST1V6a3lXVmhLTWxwVlFYaE5la2t5VFhwWk1VNXFXVFZQYmtwc1dtcHdlVnBYV25wTU0xSm9Xak5OZGdwa2FrRjFUV3BuZFUxRVEwSnBVVmxMUzNkWlFrSkJTRmRsVVVsRlFXZFNOMEpJYTBGa2QwSXhRVTR3T1UxSGNrZDRlRVY1V1hoclpVaEtiRzVPZDB0cENsTnNOalF6YW5sMEx6UmxTMk52UVhaTFpUWlBRVUZCUW05RFRWZ3liRFJCUVVGUlJFRkZXWGRTUVVsbldXOXdRVXR3V0VsMFFVdGpZbXh1YlU1d09IUUtjblJoTlZocFExQnhUR3BMV2xWblUyZHpaMUpNZVVWRFNVSlFVRzR6YkZOM0wzcGFaV0ZyZG1OM1VtRkVNbXN5ZWpsRGVWWjVVRGhSWjB4eVRYRk5id3AxZGtGbFRVRnZSME5EY1VkVFRUUTVRa0ZOUkVFeVkwRk5SMUZEVFVadGJrRllRM2xUTmtzeVNXSm1PSEZxUVZKNFNuWkVTV3gwZERNd2FEVnNMMVF5Q2xOS1JrWnRjbWR5T0VGQlRHeHpjSGcyWVd0TWVFWkhkMmhYTVc1WGQwbDNSRVptUWpoTWMwNVdkazQ1UjJOWGRDOUlOVVZIVG1OT1ZGRXdNVFJKWjJvS2RGY3lVRTFVUkdaRFVEVmFlazExYUc5MGRUaFpTVlJyU21waFZtTkdLMGtLTFMwdExTMUZUa1FnUTBWU1ZFbEdTVU5CVkVVdExTMHRMUW89In19fX0=","integratedTime":1787295161,"logIndex":2544945534,"logID":"c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d"}}} \ No newline at end of file diff --git a/src/lib/tests/fixtures/sigstore_bundles/protobuf_v0.3_localkey.json b/src/lib/tests/fixtures/sigstore_bundles/protobuf_v0.3_localkey.json new file mode 100644 index 0000000..6892597 --- /dev/null +++ b/src/lib/tests/fixtures/sigstore_bundles/protobuf_v0.3_localkey.json @@ -0,0 +1 @@ +{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"publicKey":{"hint":"wBmZj/dm+rgorT7hrHTVNMHe4ClXy+fHOW/YNmNPp78="},"tlogEntries":[{"logIndex":"2552651648","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"hashedrekord","version":"0.0.1"},"integratedTime":"1787339023","inclusionPromise":{"signedEntryTimestamp":"MEYCIQDza/l92YNdz3tW+gGsJQ+QCgdJKW7n2hrGQYznN1qj8gIhANyGh2BIbMp4gQZK/UoFNXww0lN60v7Le7mv6TPIu1lx"},"inclusionProof":{"logIndex":"2430747386","rootHash":"nvqfXZtC0ZtPBxMsJkc2NCiYWwuH0Im5u3RKHxukLH4=","treeSize":"2430747393","hashes":["vw+C+gjyijPcrrVroddeMN8algkg1/BkMJIn0NxGzg8=","wGWPdEzl09Jx86gWt6dOv6PIcdjwuMhQlw4Ot5q4GZM=","lvNGzjm+MNEclQmWoVPfo1kahHcM+R6vpjnO/ZQ2K6w=","aSeLgqIm3UNFvaTHj7P0pc2caWBt8i6pikgjCluTIaM=","ZdSE7uPZtaCz0vVNDhQAuSDKtWe/c5UnNYP8xmKoh2Q=","Fur9A1oP+1KfYcd/x9ZC+ByZLSb+qi2Mv5qWbVDhSIM=","xzRM+o7PbIUvjx68Kfi/R4PzCxAlcih2qwIFDMICejk=","4L73K4I0hQwSfoCedlLFKJOOkpdCh6A+hXMmsGqsU/U=","ODLA8MO5atyMu7PnOKWg3dZJb8gh8/iYbOQ6dpH0KOE=","YNNPFhpTTAjRTD9xWZNJmp+S1IblgigY7VAvAWpY1sc=","8/UBLOxcD9APGUwsU/BN4WGVs13mTxvEpSkQXQwHk90=","yXlbgWSY6Oud4CVAOaY5AU6+SO7YldEnshZZz8kMDH8=","V26C/ZbgRB037k4IypmNXaS2AKEAq+mYdBpCYzUHZaI=","9573PZDNoVVnalVG+4BpJVrKEFneAwqHLpVdOEfbE0Y=","RhRQJE8gq9XjV+FJY/OHh6ZhCnS6INvqNxL9MyobX54=","SndbMKVtcTenAkwi2JBfGzD+mhexp1qJbRIY+A1JRIU=","xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n2430747393\nnvqfXZtC0ZtPBxMsJkc2NCiYWwuH0Im5u3RKHxukLH4=\n\n— rekor.sigstore.dev wNI9ajBFAiA8YURxTMFLeCM8lE9PYtsfgJKXuvNMRlkKn5zdyFpYzAIhAPPwT4QRFbaLF8SE3MMRc3ykVdD1VUjkRCmZNLnf/ctW\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJkYjA2MDFhOTg5ODE5MjY0ZTg0Yzk4NjQ4NDBmMWU4NThlNDZhYzYxYjY4OTBhNTg1ODQ4OWFhMjA4YWFmNDIzIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJUURZblg2U1lZSEdGaFZmcTM2UndIbzVTRHV5bUZTeTFOclZzL3RJek5ReUxBSWdZdHJEYWZyOUlSVnZMMEw1TENsL1JpSmNUNWUyd1BTLzFIbmc3Y1QwcXBrPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCUVZVSk1TVU1nUzBWWkxTMHRMUzBLVFVacmQwVjNXVWhMYjFwSmVtb3dRMEZSV1VsTGIxcEplbW93UkVGUlkwUlJaMEZGVWxKeU1GUXJLMEpCZVRGRmEyVnRMMlJtSzBWdGNsTXJORFIzVXdwWlQyWjJaSEFyZVV0VVVVTnVibEZpY3psRlpYVmtVM2x3S3pSYVNFNW5TR1JPVm5Wb1pVTjFRMXB2WmxsalkxQlBOemRLVjA1bFExaG5QVDBLTFMwdExTMUZUa1FnVUZWQ1RFbERJRXRGV1MwdExTMHRDZz09In19fX0="}],"timestampVerificationData":{"rfc3161Timestamps":[{"signedTimestamp":"MIICyDADAgEAMIICvwYJKoZIhvcNAQcCoIICsDCCAqwCAQMxDTALBglghkgBZQMEAgEwgbcGCyqGSIb3DQEJEAEEoIGnBIGkMIGhAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgBwPNX68WooEK54vKFlJbVLF1rXhISzKhtvJt0E7rYN4CFFQ134fLnZRTuz7ZxtLENWF3zHEDGA8yMDI2MDgyMTE5MDM0M1owAwIBAaAypDAwLjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MRUwEwYDVQQDEwxzaWdzdG9yZS10c2GgADGCAdowggHWAgEBMFEwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZAIUOhNULwyQYe68wUMvy4qOiyojiwwwCwYJYIZIAWUDBAIBoIH8MBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcNMjYwODIxMTkwMzQzWjAvBgkqhkiG9w0BCQQxIgQgT6rQ6h/h1dFu19qtwFjMz5z1UQykEp4/FGS2v5qAtyMwgY4GCyqGSIb3DQEJEAIvMX8wfTB7MHkEIIX5J7wHq2LKw7RDVsEO/IGyxog/2nq55thw2dE6zQW3MFUwPaQ7MDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQCFDoTVC8MkGHuvMFDL8uKjosqI4sMMAoGCCqGSM49BAMCBGYwZAIwHLgVgZrW2EzpssM3wRZw3iO67VIvieUpxhsNr1C/qtewqDg+rKFiRHonkivB3Ey5AjAxo5sWqWMya3wvD+AhTRx495nMzGIoDfm4ycn8dPWxaxUMmMddljINEeGMxP/VOvs="}]}},"messageSignature":{"messageDigest":{"algorithm":"SHA2_256","digest":"2wYBqYmBkmToTJhkhA8ehY5GrGG2iQpYWEiaogiq9CM="},"signature":"MEUCIQDYnX6SYYHGFhVfq36RwHo5SDuymFSy1NrVs/tIzNQyLAIgYtrDafr9IRVvL0L5LCl/RiJcT5e2wPS/1Hng7cT0qpk="}} \ No newline at end of file diff --git a/src/lib/tests/sigstore_bundle.rs b/src/lib/tests/sigstore_bundle.rs new file mode 100644 index 0000000..069d603 --- /dev/null +++ b/src/lib/tests/sigstore_bundle.rs @@ -0,0 +1,261 @@ +//! Integration tests for `KeylessSignature::from_sigstore_bundle` (REQ-27, +//! issue #260): ingesting an existing cosign / Sigstore bundle into a +//! `KeylessSignature` the offline verifiers accept. +//! +//! Fixtures are real bundles committed under +//! `tests/fixtures/sigstore_bundles/` and loaded with `include_str!`. + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use wsc::WSError; +use wsc::container::SigstoreBundle; +use wsc::keyless::KeylessSignature; + +const LEGACY_BUNDLE: &str = + include_str!("fixtures/sigstore_bundles/legacy_rekorbundle_keyless.json"); +const V03_LOCALKEY_BUNDLE: &str = + include_str!("fixtures/sigstore_bundles/protobuf_v0.3_localkey.json"); + +/// The artifact SHA-256 recorded in the legacy fixture's hashedrekord body. +const LEGACY_MODULE_HASH_HEX: &str = + "0f2e7d92ff7e1581d2a966db8dcdfc05054cdeb5395aec56561122e11b8a697f"; + +/// Test 1 — Legacy positive: the real legacy `rekorBundle` fixture ingests, +/// and every extracted field matches what the bundle actually carries. +#[test] +fn from_sigstore_bundle_legacy_positive() { + let sig = KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE) + .expect("legacy rekorBundle fixture must ingest"); + + assert_eq!( + sig.module_hash, + hex::decode(LEGACY_MODULE_HASH_HEX).unwrap(), + "module_hash must equal the hashedrekord body's data.hash.value" + ); + + assert!(!sig.cert_chain.is_empty(), "cert chain must be non-empty"); + assert!( + sig.cert_chain[0].starts_with("-----BEGIN CERTIFICATE-----"), + "leaf cert must be PEM text, got: {:?}", + &sig.cert_chain[0][..sig.cert_chain[0].len().min(40)] + ); + + assert!(!sig.signature.is_empty(), "signature must be non-empty"); + + assert_eq!(sig.rekor_entry.log_index, 2544945534); + assert!( + sig.rekor_entry.log_id.starts_with("c0d23d6a"), + "log_id was: {}", + sig.rekor_entry.log_id + ); + assert!( + !sig.rekor_entry.signed_entry_timestamp.is_empty(), + "SET must be carried over" + ); + // integrated_time is normalised to RFC3339 (Unix 1787295161). + assert_eq!(sig.rekor_entry.integrated_time, "2026-08-21T06:52:41Z"); +} + +/// Test 1b — the strongest "the offline verifiers accept" proof that needs +/// neither network nor trust roots: `verify_rekor_body_binds_to_bundle` +/// decodes the ingested body and checks the artifact hash, signature bytes, +/// and leaf-cert DER all bind to the bundle. It must return `Ok`. +/// +/// Non-vacuity: this is the positive partner of `..._negative_control` below. +/// If the digest, signature, or leaf cert were extracted inconsistently, this +/// call would return `Err`. +#[test] +fn from_sigstore_bundle_legacy_body_binding_accepts() { + let sig = KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE) + .expect("legacy fixture must ingest"); + sig.verify_rekor_body_binds_to_bundle() + .expect("ingested legacy bundle must pass offline Rekor-body binding"); +} + +/// Test 2 — Legacy negative control (faithful-extraction proof): flip one hex +/// char of the `hash.value` inside the base64 body, re-embed, re-ingest, and +/// assert the extracted `module_hash` DIFFERS from the untampered one. Proves +/// the digest is read faithfully from the body, not fabricated/hardcoded. +/// +/// Non-vacuity: a SUT that hardcoded `module_hash` (e.g. `vec![0u8;32]`) or +/// recomputed it from a fixed source would yield the SAME value for both +/// inputs and this `assert_ne!` would fail. +#[test] +fn from_sigstore_bundle_legacy_negative_control_faithful_digest() { + let genuine = KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE) + .expect("genuine legacy fixture must ingest"); + + // Decode the top-level bundle, reach into rekorBundle.Payload.body, + // decode it, flip the first hex char of the artifact hash, re-encode. + let mut bundle: serde_json::Value = serde_json::from_str(LEGACY_BUNDLE).unwrap(); + let body_b64 = bundle["rekorBundle"]["Payload"]["body"].as_str().unwrap(); + let body_bytes = BASE64.decode(body_b64).unwrap(); + let mut body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + + let orig = body["spec"]["data"]["hash"]["value"].as_str().unwrap(); + let mut chars: Vec = orig.chars().collect(); + // Flip the first hex digit deterministically to a different hex digit. + chars[0] = if chars[0] == '0' { '1' } else { '0' }; + let mutated: String = chars.into_iter().collect(); + assert_ne!(orig, mutated, "mutation must actually change the hash"); + body["spec"]["data"]["hash"]["value"] = serde_json::json!(mutated); + + let new_body_b64 = BASE64.encode(serde_json::to_vec(&body).unwrap()); + bundle["rekorBundle"]["Payload"]["body"] = serde_json::json!(new_body_b64); + let tampered_json = serde_json::to_string(&bundle).unwrap(); + + let tampered = KeylessSignature::from_sigstore_bundle(&tampered_json) + .expect("tampered-but-well-formed bundle still ingests"); + + assert_ne!( + genuine.module_hash, tampered.module_hash, + "corruption of the body hash must be propagated into module_hash" + ); + assert_eq!( + genuine.module_hash, + hex::decode(LEGACY_MODULE_HASH_HEX).unwrap() + ); +} + +/// Test 3 — v0.3 envelope + cert-requirement: the real +/// `protobuf_v0.3_localkey.json` bundle is detected as v0.3, its envelope is +/// parsed, and because it carries a raw public key (not a Fulcio cert) it is +/// rejected with the specific cert-requirement error. +/// +/// Non-vacuity: the cert check runs BEFORE the messageSignature/digest +/// decode, so this error cannot be an incidental decode failure. If the SUT's +/// `publicKey` branch returned `Ok`/empty-chain, or was deleted so control +/// fell to the generic "no certificate in verificationMaterial" error, the +/// `contains("raw public key")` assertion below would fail — so the test +/// pins that the specific cert-requirement message fires. +#[test] +fn from_sigstore_bundle_v03_rejects_raw_public_key() { + let err = KeylessSignature::from_sigstore_bundle(V03_LOCALKEY_BUNDLE) + .expect_err("v0.3 local-key bundle must be rejected"); + + match err { + WSError::KeylessFormatError(msg) => { + assert!( + msg.contains("raw public key") + && msg.contains("requires a certificate"), + "expected the raw-public-key cert-requirement error, got: {msg}" + ); + } + other => panic!("expected KeylessFormatError, got: {other:?}"), + } +} + +/// Test 4 — Round-trip fidelity: ingest the legacy fixture, re-emit it as a +/// v0.3 `SigstoreBundle`, JSON round-trip it, and prove the signature, +/// module_hash, cert chain, and rekor fields all survive. +/// +/// Intentionally-transformed fields are asserted in their re-emitted form and +/// documented inline; nothing is silently dropped. +#[test] +fn from_sigstore_bundle_legacy_round_trip_fidelity() { + let sig = KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE) + .expect("legacy fixture must ingest"); + + let bundle = SigstoreBundle::from_keyless_signature(&sig); + let json = bundle.to_json().expect("bundle to_json"); + let bundle2 = SigstoreBundle::from_json(&json).expect("bundle from_json"); + + // signature: preserved as base64 of the exact bytes. + assert_eq!( + BASE64.decode(&bundle2.message_signature.signature).unwrap(), + sig.signature, + "signature bytes must survive the round-trip" + ); + + // module_hash: emitted as lowercase hex by from_keyless_signature. + assert_eq!( + bundle2.message_signature.message_digest.digest, + hex::encode(&sig.module_hash), + "module_hash must survive as hex digest" + ); + + // cert chain: preserved as base64(DER). Re-derive the DER of the ingested + // leaf PEM (strip headers, base64-decode) and compare byte-for-byte. + let certs = &bundle2.verification_material.x509_certificate_chain.certificates; + assert_eq!( + certs.len(), + sig.cert_chain.len(), + "cert chain length must survive" + ); + let leaf_der_from_bundle = BASE64.decode(&certs[0].raw_bytes).unwrap(); + let leaf_der_from_sig = pem_body_der(&sig.cert_chain[0]); + assert_eq!( + leaf_der_from_bundle, leaf_der_from_sig, + "leaf certificate DER must survive" + ); + + // rekor fields. + let tlog = &bundle2.verification_material.tlog_entries[0]; + assert_eq!(tlog.log_index, sig.rekor_entry.log_index.to_string()); + assert_eq!(tlog.log_id.key_id, sig.rekor_entry.log_id); + assert_eq!( + tlog.canonicalized_body.as_deref(), + Some(sig.rekor_entry.body.as_str()), + "canonicalized body (base64 rekord) must survive" + ); + assert_eq!( + tlog.signed_entry_timestamp.as_deref(), + Some(sig.rekor_entry.signed_entry_timestamp.as_str()), + "SET must survive" + ); + // integrated_time: KeylessSignature holds RFC3339; the bundle re-emits it + // as Unix seconds — the same instant, matching the legacy fixture value. + assert_eq!( + tlog.integrated_time, "1787295161", + "integrated_time must round-trip to the fixture's Unix seconds" + ); + + // Documented intentionally-dropped fields (offline verification does not + // need them and the legacy bundle never carried them): + // - rekor_entry.uuid (legacy omits it; stays empty) + // - rekor_entry.inclusion_proof (legacy carries only the SET) + assert!(sig.rekor_entry.uuid.is_empty()); + assert!(sig.rekor_entry.inclusion_proof.is_empty()); +} + +/// Test 5 — Unrecognised shape: an object with neither `rekorBundle` nor a +/// v0.3 marker yields the specific unrecognised-format error. +#[test] +fn from_sigstore_bundle_unrecognized_format() { + let err = KeylessSignature::from_sigstore_bundle("{}") + .expect_err("empty object must be rejected"); + match err { + WSError::KeylessFormatError(msg) => { + assert!( + msg.contains("unrecognized Sigstore bundle format"), + "got: {msg}" + ); + } + other => panic!("expected KeylessFormatError, got: {other:?}"), + } +} + +/// Strip PEM armor and decode the base64 body to DER bytes (test helper, +/// mirrors the crate's internal `pem_to_der`). +fn pem_body_der(pem: &str) -> Vec { + let b64: String = pem + .lines() + .filter(|l| !l.starts_with("-----BEGIN") && !l.starts_with("-----END") && !l.is_empty()) + .collect(); + BASE64.decode(&b64).expect("valid base64 in PEM body") +} + +/// Test 6 — `verify_cert_chain` (offline: embedded Fulcio trust roots, no +/// network) accepts the ingested legacy bundle. This is the leg that depends +/// on `integrated_time` being RFC3339: `verify_cert_chain` parses that field +/// with `parse_from_rfc3339` and checks the leaf cert's validity window +/// against it. The fixture cert's window is 2026-08-21T06:52:40Z .. +/// 07:02:40Z and integrated_time is 06:52:41Z (inside the window), so this +/// returns `Ok`. A bare Unix-seconds string here would make the parse fail +/// and this test would catch the regression. +#[test] +fn from_sigstore_bundle_legacy_verify_cert_chain_accepts() { + let sig = KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE) + .expect("legacy fixture must ingest"); + sig.verify_cert_chain() + .expect("ingested legacy leaf cert must chain to embedded Fulcio roots at integrated_time"); +} From 3bc220b7991d0896fc20c4cac37b2d64d4362c29 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 27 Aug 2026 06:23:59 +0200 Subject: [PATCH 2/2] fix(req-27): emit spec-conformant bundles + close the v0.3 coverage gap (#260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parts: a real interop DEFECT FIX found by a round-trip test, and the coverage gap that was hiding it. ## The coverage gap (codecov/patch was failing: 164 of 397 new lines uncovered) from_v03_bundle was almost entirely unexercised: the only v0.3 fixture is a LOCAL-KEY bundle, so parsing bailed at the "requires a Fulcio certificate" check and the whole v0.3 happy path never ran. We claimed "supports both wire shapes" while only one shape's happy path was tested — the vacuous-oracle shape REQ-30/#258 exists to kill, and exactly what varve warned about in #260 ("supporting only one will surprise someone"). Fixed by building a genuine cert-bearing v0.3 bundle from REAL material: legacy fixture -> from_sigstore_bundle -> SigstoreBundle::from_keyless_signature -> to_json -> from_sigstore_bundle again, asserting field-by-field fidelity. Plus a spec-shaped keyless positive (the singular certificate.rawBytes branch real cosign keyless bundles use), a v0.3 negative control, and 43 error-path unit tests each asserting a specific message. Added lines uncovered: 164 -> 4, and those 4 are provably unreachable (a map_err closure guarded by an is_ascii_hexdigit + length check, and two test-helper panic arms). ## The defects that round-trip test found (emitter was non-conformant) Ground truth: a real `cosign sign-blob --new-bundle-format` bundle emits `logId.keyId` as BASE64 and places the SET under `inclusionPromise`, with no top-level field. 1. logId.keyId encoding. The emitter wrote RekorEntry::log_id (hex, the Rekor REST form) straight into LogId.key_id, which the Sigstore protobuf spec types as `bytes` — base64 in JSON. A 64-char hex string is ALSO valid base64, so it did not error: it decoded to 48 junk bytes, corrupting the Rekor log identity with no diagnostic (c0d23d6a…801d -> 734776dd…7dce). Fixed: transcode hex -> base64; a non-hex value passes through unchanged rather than emitting mangled base64. 2. SET placement. The emitter wrote the SET at the top-level tlogEntries[].signedEntryTimestamp; every other implementation (and wsc's own ingest) reads inclusionPromise.signedEntryTimestamp. The SET — the only offline transparency proof a legacy bundle carries — was silently dropped on wsc's own round trip. Fixed: emit the spec location via a new InclusionPromise type. Both mean bundle.rs's documented claim that emitted bundles verify with `cosign verify-blob --bundle` was false. Backward compatible on read: the legacy top-level SET is still accepted (deserialize-only field + a signed_entry_timestamp() accessor), with a test proving pre-0.11.0 bundles still round-trip. The two KNOWN-DEFECT assertions that pinned the buggy behaviour are replaced with true losslessness assertions, so the round-trip test now proves fidelity rather than documenting corruption. Tests: wsc lib 655 pass/3 ignored (+45); sigstore_bundle 10 pass (+3). Vacuous-oracle gate clean. Clippy clean. Refs: #260, #231 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012aR3Md1h46K9wAUWMQiESH --- src/lib/src/container/bundle.rs | 125 ++++- src/lib/src/signature/keyless/format.rs | 580 ++++++++++++++++++++++++ src/lib/tests/sigstore_bundle.rs | 363 ++++++++++++++- 3 files changed, 1045 insertions(+), 23 deletions(-) diff --git a/src/lib/src/container/bundle.rs b/src/lib/src/container/bundle.rs index 8a9e4b4..beacfe3 100644 --- a/src/lib/src/container/bundle.rs +++ b/src/lib/src/container/bundle.rs @@ -112,16 +112,57 @@ pub struct TransparencyLogEntry { #[serde(skip_serializing_if = "Option::is_none")] pub inclusion_proof: Option, - /// The Signed Entry Timestamp (SET). + /// The inclusion promise, carrying the Signed Entry Timestamp (SET). + /// + /// Per the Sigstore protobuf spec (and as real cosign output confirms), the + /// SET lives at `inclusionPromise.signedEntryTimestamp` — NOT as a + /// top-level field on the entry. #[serde(skip_serializing_if = "Option::is_none")] - pub signed_entry_timestamp: Option, + pub inclusion_promise: Option, + + /// Legacy top-level SET emitted by wsc <= 0.11.0. + /// + /// Non-conformant: no other Sigstore implementation reads it. Accepted on + /// *deserialize* so bundles wsc emitted before this fix still round-trip, + /// but never serialized. Prefer [`Self::signed_entry_timestamp`]. + #[serde( + rename = "signedEntryTimestamp", + skip_serializing, + default, + alias = "signed_entry_timestamp" + )] + pub legacy_signed_entry_timestamp: Option, +} + +impl TransparencyLogEntry { + /// The Signed Entry Timestamp, from the spec location, falling back to the + /// legacy top-level field for bundles wsc emitted before the fix. + pub fn signed_entry_timestamp(&self) -> Option<&str> { + self.inclusion_promise + .as_ref() + .map(|p| p.signed_entry_timestamp.as_str()) + .or(self.legacy_signed_entry_timestamp.as_deref()) + } +} + +/// The inclusion promise: Rekor's signed statement that an entry was accepted. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InclusionPromise { + /// The Signed Entry Timestamp (SET), base64-encoded. + pub signed_entry_timestamp: String, } /// Log instance identifier. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LogId { - /// The key ID of the log, hex-encoded. + /// The key ID of the log, **base64-encoded**. + /// + /// The Sigstore protobuf spec types this as a `bytes` field, and protobuf's + /// JSON mapping encodes `bytes` as base64 — which is what real cosign + /// emits. wsc's `RekorEntry::log_id` holds the same value hex-encoded (the + /// form the Rekor REST API uses), so the emitter transcodes hex -> base64. pub key_id: String, } @@ -224,7 +265,15 @@ fn build_tlog_entry( TransparencyLogEntry { log_index: rekor.log_index.to_string(), log_id: LogId { - key_id: rekor.log_id.clone(), + // `RekorEntry::log_id` is hex (the Rekor REST form); the bundle + // spec's `LogId.key_id` is a protobuf `bytes` field, so its JSON + // mapping is base64 — what real cosign emits. Transcode. If the + // value is not valid hex it is not a Rekor log id we can transcode, + // so pass it through unchanged rather than silently emitting junk. + key_id: match hex::decode(&rekor.log_id) { + Ok(bytes) => BASE64.encode(&bytes), + Err(_) => rekor.log_id.clone(), + }, }, canonicalized_body: if rekor.body.is_empty() { None @@ -233,11 +282,15 @@ fn build_tlog_entry( }, integrated_time: integrated_time_str, inclusion_proof, - signed_entry_timestamp: if rekor.signed_entry_timestamp.is_empty() { + inclusion_promise: if rekor.signed_entry_timestamp.is_empty() { None } else { - Some(rekor.signed_entry_timestamp.clone()) + Some(InclusionPromise { + signed_entry_timestamp: rekor.signed_entry_timestamp.clone(), + }) }, + // Never emitted; present only to accept pre-fix wsc bundles on read. + legacy_signed_entry_timestamp: None, } } @@ -350,10 +403,19 @@ mod tests { let tlog = build_tlog_entry(&rekor); assert_eq!(tlog.log_index, "42"); - assert_eq!(tlog.log_id.key_id, "c0d23d6ad406973f"); + // The spec's LogId.key_id is a protobuf `bytes` field -> base64 in JSON + // (what real cosign emits). RekorEntry::log_id holds it as hex, so the + // emitter transcodes: hex "c0d23d6ad406973f" -> base64 "wNI9atQGlz8=". + assert_eq!( + tlog.log_id.key_id, + BASE64.encode(hex::decode("c0d23d6ad406973f").unwrap()) + ); assert_eq!(tlog.integrated_time, "1704067200"); assert!(tlog.canonicalized_body.is_some()); - assert!(tlog.signed_entry_timestamp.is_some()); + // SET lives under inclusionPromise per the spec, not at the top level. + assert!(tlog.inclusion_promise.is_some()); + assert!(tlog.signed_entry_timestamp().is_some()); + assert!(tlog.legacy_signed_entry_timestamp.is_none()); // Check inclusion proof was parsed let proof = tlog.inclusion_proof.unwrap(); @@ -376,7 +438,52 @@ mod tests { let mut rekor = create_test_rekor_entry(); rekor.signed_entry_timestamp = String::new(); let tlog = build_tlog_entry(&rekor); - assert!(tlog.signed_entry_timestamp.is_none()); + assert!(tlog.inclusion_promise.is_none()); + assert!(tlog.signed_entry_timestamp().is_none()); + } + + /// A `log_id` that is not valid hex is not a Rekor log id we can transcode + /// to the spec's base64 `bytes` form, so it is passed through unchanged + /// rather than emitting silently-mangled base64. + #[test] + fn test_tlog_entry_non_hex_log_id_passes_through() { + let mut rekor = create_test_rekor_entry(); + rekor.log_id = "not-hex-at-all!!".to_string(); + let tlog = build_tlog_entry(&rekor); + assert_eq!( + tlog.log_id.key_id, "not-hex-at-all!!", + "a non-hex log id must pass through verbatim, not be re-encoded" + ); + } + + /// Bundles wsc emitted at <= 0.11.0 carry the SET at the non-conformant + /// top-level `signedEntryTimestamp`. Those must still be readable. + #[test] + fn test_legacy_top_level_set_is_still_accepted_on_read() { + let json = br#"{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "x509CertificateChain": { "certificates": [] }, + "tlogEntries": [{ + "logIndex": "42", + "logId": { "keyId": "wNI9atQGlz8=" }, + "integratedTime": "1704067200", + "signedEntryTimestamp": "bGVnYWN5U0VU" + }] + }, + "messageSignature": { + "messageDigest": { "algorithm": "SHA2_256", "digest": "00" }, + "signature": "AA==" + } + }"#; + let bundle = SigstoreBundle::from_json(json).expect("legacy shape must deserialize"); + let tlog = &bundle.verification_material.tlog_entries[0]; + assert_eq!( + tlog.signed_entry_timestamp(), + Some("bGVnYWN5U0VU"), + "the legacy top-level SET must still be readable" + ); + assert!(tlog.inclusion_promise.is_none()); } #[test] diff --git a/src/lib/src/signature/keyless/format.rs b/src/lib/src/signature/keyless/format.rs index f5c9230..69dc57f 100644 --- a/src/lib/src/signature/keyless/format.rs +++ b/src/lib/src/signature/keyless/format.rs @@ -1197,6 +1197,586 @@ mod tests { ); } + // --------------------------------------------------------------- + // `from_sigstore_bundle` error paths. + // + // Every test below drives ONE branch with the smallest input that + // reaches it and asserts the SPECIFIC message that branch emits, so + // deleting the branch (or letting control fall through to a + // neighbouring error) fails the test rather than silently passing. + // --------------------------------------------------------------- + + /// Run `from_sigstore_bundle` on `json`, require a `KeylessFormatError`, + /// and return its message for substring assertions. + fn format_err(json: &str) -> String { + match KeylessSignature::from_sigstore_bundle(json) { + Err(WSError::KeylessFormatError(msg)) => msg, + Err(other) => panic!("expected KeylessFormatError, got: {other:?}"), + Ok(_) => panic!("expected an error, but the bundle ingested"), + } + } + + /// Base64 of a minimal, well-formed `hashedrekord` body whose + /// `spec.data.hash.value` is a valid 32-byte SHA-256 in hex. + fn valid_body_b64() -> String { + BASE64.encode( + serde_json::json!({ + "apiVersion": "0.0.1", + "kind": "hashedrekord", + "spec": { "data": { "hash": { "algorithm": "sha256", "value": "aa".repeat(32) } } } + }) + .to_string(), + ) + } + + /// A minimal legacy `rekorBundle` bundle that ingests successfully. + /// Each error test clones this and breaks exactly one field, so the + /// branch under test is the only thing that can fail. + fn valid_legacy() -> serde_json::Value { + serde_json::json!({ + "base64Signature": BASE64.encode([1u8, 2, 3]), + "cert": BASE64.encode("-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----"), + "rekorBundle": { + "SignedEntryTimestamp": BASE64.encode([9u8, 9, 9]), + "Payload": { + "body": valid_body_b64(), + "integratedTime": 1_700_000_000i64, + "logIndex": 42i64, + "logID": "c0d23d6a", + } + } + }) + } + + /// The base fixture must actually ingest — otherwise every "break one + /// field" test below could be passing for the wrong reason. + #[test] + fn legacy_base_is_valid_control() { + let sig = KeylessSignature::from_sigstore_bundle(&valid_legacy().to_string()) + .expect("the base legacy bundle used by the error tests must ingest"); + assert_eq!(sig.module_hash, hex::decode("aa".repeat(32)).unwrap()); + assert_eq!(sig.rekor_entry.log_index, 42); + assert_eq!(sig.rekor_entry.log_id, "c0d23d6a"); + assert_eq!(sig.rekor_entry.integrated_time, "2023-11-14T22:13:20Z"); + } + + // --- dispatch --------------------------------------------------- + + #[test] + fn from_sigstore_bundle_rejects_malformed_json() { + assert!(format_err("{not json").contains("Sigstore bundle is not valid JSON")); + } + + #[test] + fn from_sigstore_bundle_rejects_non_object_json() { + // Valid JSON, but an array — must hit the "not a JSON object" branch, + // not the JSON-parse branch above nor the unrecognised-format branch. + assert!(format_err("[1, 2, 3]").contains("Sigstore bundle is not a JSON object")); + assert!(format_err("\"a string\"").contains("Sigstore bundle is not a JSON object")); + } + + #[test] + fn from_sigstore_bundle_dispatches_v03_on_media_type_alone() { + // A `mediaType` starting with the sigstore bundle prefix routes to the + // v0.3 parser even with no `verificationMaterial` key — proving the + // mediaType arm of the dispatch, not the verificationMaterial arm. + let msg = format_err( + &serde_json::json!({ "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json" }) + .to_string(), + ); + assert!( + msg.contains("no certificate in verificationMaterial"), + "got: {msg}" + ); + } + + // --- legacy error paths ----------------------------------------- + + #[test] + fn legacy_missing_base64_signature() { + let mut b = valid_legacy(); + b.as_object_mut().unwrap().remove("base64Signature"); + assert!(format_err(&b.to_string()).contains("missing 'base64Signature'")); + } + + #[test] + fn legacy_base64_signature_not_base64() { + let mut b = valid_legacy(); + b["base64Signature"] = serde_json::json!("!!!not base64!!!"); + assert!( + format_err(&b.to_string()).contains("'base64Signature' is not valid base64"), + "expected the base64 decode error for the signature" + ); + } + + #[test] + fn legacy_missing_cert() { + let mut b = valid_legacy(); + b.as_object_mut().unwrap().remove("cert"); + assert!(format_err(&b.to_string()).contains("legacy Sigstore bundle missing 'cert'")); + } + + #[test] + fn legacy_cert_not_base64() { + let mut b = valid_legacy(); + b["cert"] = serde_json::json!("!!!not base64!!!"); + assert!(format_err(&b.to_string()).contains("'cert' is not valid base64")); + } + + #[test] + fn legacy_cert_not_utf8() { + let mut b = valid_legacy(); + // Valid base64 that decodes to bytes which are not valid UTF-8, so the + // decode succeeds and the FROM_UTF8 branch is the one that fires. + b["cert"] = serde_json::json!(BASE64.encode([0xffu8, 0xfe, 0xfd])); + let msg = format_err(&b.to_string()); + assert!( + msg.contains("'cert' is not valid UTF-8 PEM"), + "expected the UTF-8 branch, got: {msg}" + ); + } + + #[test] + fn legacy_missing_body() { + let mut b = valid_legacy(); + b["rekorBundle"]["Payload"] + .as_object_mut() + .unwrap() + .remove("body"); + assert!( + format_err(&b.to_string()).contains("missing 'rekorBundle.Payload.body'"), + "expected the missing-body branch" + ); + } + + #[test] + fn legacy_log_index_missing_or_not_a_number() { + // Absent. + let mut b = valid_legacy(); + b["rekorBundle"]["Payload"] + .as_object_mut() + .unwrap() + .remove("logIndex"); + assert!( + format_err(&b.to_string()).contains("'rekorBundle.Payload.logIndex' is not an integer") + ); + + // Present but neither a number nor a decimal string. + let mut b = valid_legacy(); + b["rekorBundle"]["Payload"]["logIndex"] = serde_json::json!("not-a-number"); + assert!( + format_err(&b.to_string()).contains("'rekorBundle.Payload.logIndex' is not an integer") + ); + } + + #[test] + fn legacy_log_index_accepts_decimal_string() { + // Non-vacuity partner of the test above: `json_u64` must still accept + // the string form, otherwise the "not an integer" assertion could be + // passing because the parser rejects every string. + let mut b = valid_legacy(); + b["rekorBundle"]["Payload"]["logIndex"] = serde_json::json!("2544945534"); + let sig = KeylessSignature::from_sigstore_bundle(&b.to_string()).expect("string logIndex"); + assert_eq!(sig.rekor_entry.log_index, 2544945534); + } + + #[test] + fn legacy_missing_log_id() { + let mut b = valid_legacy(); + b["rekorBundle"]["Payload"] + .as_object_mut() + .unwrap() + .remove("logID"); + assert!(format_err(&b.to_string()).contains("missing 'rekorBundle.Payload.logID'")); + } + + #[test] + fn legacy_integrated_time_not_a_number() { + let mut b = valid_legacy(); + b["rekorBundle"]["Payload"]["integratedTime"] = serde_json::json!({ "not": "a number" }); + assert!(format_err(&b.to_string()).contains("integratedTime is not an integer")); + } + + #[test] + fn legacy_missing_signed_entry_timestamp() { + let mut b = valid_legacy(); + b["rekorBundle"] + .as_object_mut() + .unwrap() + .remove("SignedEntryTimestamp"); + assert!( + format_err(&b.to_string()).contains("missing 'rekorBundle.SignedEntryTimestamp'"), + "the SET is the only offline transparency proof a legacy bundle \ + carries; its absence must be an error, never a silent empty" + ); + } + + // --- v0.3 error paths ------------------------------------------- + + /// A minimal v0.3 bundle that ingests successfully. Structural only: the + /// v0.3 parser performs no cryptographic checks, so synthetic bytes are + /// sufficient for these branch tests (the real-material happy paths live + /// in `tests/sigstore_bundle.rs`). + fn valid_v03() -> serde_json::Value { + serde_json::json!({ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "certificate": { "rawBytes": BASE64.encode([1u8, 2, 3]) }, + "tlogEntries": [{ + "logIndex": "42", + "logId": { "keyId": BASE64.encode([0xc0u8, 0xd2]) }, + "integratedTime": "1700000000", + }], + }, + "messageSignature": { + "messageDigest": { "algorithm": "SHA2_256", "digest": BASE64.encode([7u8; 32]) }, + "signature": BASE64.encode([4u8, 5, 6]), + }, + }) + } + + #[test] + fn v03_base_is_valid_control() { + let sig = KeylessSignature::from_sigstore_bundle(&valid_v03().to_string()) + .expect("the base v0.3 bundle used by the error tests must ingest"); + assert_eq!(sig.signature, vec![4, 5, 6]); + assert_eq!(sig.module_hash, vec![7u8; 32]); + assert_eq!(sig.rekor_entry.log_index, 42); + // base64 keyId is re-encoded as hex. + assert_eq!(sig.rekor_entry.log_id, "c0d2"); + assert_eq!(sig.cert_chain.len(), 1); + assert_eq!( + first_certificate_der(sig.cert_chain[0].as_bytes()), + Some(vec![1, 2, 3]), + "the singular `certificate.rawBytes` DER must survive der_to_pem" + ); + } + + #[test] + fn v03_certificate_missing_raw_bytes() { + let mut b = valid_v03(); + b["verificationMaterial"]["certificate"] = serde_json::json!({}); + assert!(format_err(&b.to_string()).contains("'certificate' missing 'rawBytes'")); + } + + #[test] + fn v03_certificate_raw_bytes_not_base64() { + let mut b = valid_v03(); + b["verificationMaterial"]["certificate"]["rawBytes"] = serde_json::json!("!!!nope!!!"); + assert!(format_err(&b.to_string()).contains("'certificate.rawBytes' is not valid base64")); + } + + #[test] + fn v03_x509_chain_certificates_not_an_array() { + let mut b = valid_v03(); + let vm = b["verificationMaterial"].as_object_mut().unwrap(); + vm.remove("certificate"); + vm.insert( + "x509CertificateChain".to_string(), + serde_json::json!({ "certificates": "not an array" }), + ); + assert!( + format_err(&b.to_string()) + .contains("'x509CertificateChain.certificates' is not an array") + ); + } + + #[test] + fn v03_x509_chain_entry_missing_raw_bytes() { + let mut b = valid_v03(); + let vm = b["verificationMaterial"].as_object_mut().unwrap(); + vm.remove("certificate"); + vm.insert( + "x509CertificateChain".to_string(), + serde_json::json!({ "certificates": [{ "notRawBytes": "x" }] }), + ); + let msg = format_err(&b.to_string()); + assert!( + msg.contains("v0.3 certificate missing 'rawBytes'"), + "expected the per-entry missing-rawBytes error, got: {msg}" + ); + } + + #[test] + fn v03_x509_chain_entry_raw_bytes_not_base64() { + let mut b = valid_v03(); + let vm = b["verificationMaterial"].as_object_mut().unwrap(); + vm.remove("certificate"); + vm.insert( + "x509CertificateChain".to_string(), + serde_json::json!({ "certificates": [ + { "rawBytes": BASE64.encode([1u8, 2, 3]) }, + { "rawBytes": "!!!nope!!!" }, + ]}), + ); + let msg = format_err(&b.to_string()); + assert!( + msg.contains("v0.3 certificate 'rawBytes' is not valid base64"), + "a bad cert ANYWHERE in the chain must fail, not just the leaf; got: {msg}" + ); + } + + #[test] + fn v03_no_certificate_at_all() { + let mut b = valid_v03(); + b["verificationMaterial"] + .as_object_mut() + .unwrap() + .remove("certificate"); + assert!( + format_err(&b.to_string()).contains("no certificate in verificationMaterial"), + "with neither certificate, x509CertificateChain, nor publicKey" + ); + } + + #[test] + fn v03_missing_message_signature() { + let mut b = valid_v03(); + b["messageSignature"] + .as_object_mut() + .unwrap() + .remove("signature"); + assert!(format_err(&b.to_string()).contains("missing 'messageSignature.signature'")); + } + + #[test] + fn v03_message_signature_not_base64() { + let mut b = valid_v03(); + b["messageSignature"]["signature"] = serde_json::json!("!!!nope!!!"); + assert!( + format_err(&b.to_string()).contains("'messageSignature.signature' is not valid base64") + ); + } + + #[test] + fn v03_missing_message_digest() { + let mut b = valid_v03(); + b["messageSignature"]["messageDigest"] + .as_object_mut() + .unwrap() + .remove("digest"); + assert!( + format_err(&b.to_string()).contains("missing 'messageSignature.messageDigest.digest'") + ); + } + + #[test] + fn v03_digest_neither_hex_nor_base64() { + let mut b = valid_v03(); + b["messageSignature"]["messageDigest"]["digest"] = serde_json::json!("!!!nope!!!"); + assert!( + format_err(&b.to_string()) + .contains("messageDigest.digest is neither 64-char hex nor base64") + ); + } + + #[test] + fn v03_digest_wrong_length() { + let mut b = valid_v03(); + // Valid base64, decodes to 3 bytes — not a SHA-256. + b["messageSignature"]["messageDigest"]["digest"] = + serde_json::json!(BASE64.encode([1u8, 2, 3])); + assert!( + format_err(&b.to_string()).contains("messageDigest.digest is 3 bytes, expected 32") + ); + } + + #[test] + fn v03_missing_or_empty_tlog_entries() { + // Key absent entirely. + let mut b = valid_v03(); + b["verificationMaterial"] + .as_object_mut() + .unwrap() + .remove("tlogEntries"); + assert!(format_err(&b.to_string()).contains("no transparency-log entries")); + + // Present but empty — `.first()` yields None, same branch. A bundle + // with zero log entries must be rejected, not accepted with a blank + // Rekor entry. + let mut b = valid_v03(); + b["verificationMaterial"]["tlogEntries"] = serde_json::json!([]); + assert!(format_err(&b.to_string()).contains("no transparency-log entries")); + } + + #[test] + fn v03_tlog_log_index_not_an_integer() { + let mut b = valid_v03(); + b["verificationMaterial"]["tlogEntries"][0]["logIndex"] = serde_json::json!(false); + assert!(format_err(&b.to_string()).contains("v0.3 tlogEntry 'logIndex' is not an integer")); + } + + #[test] + fn v03_tlog_key_id_not_base64() { + let mut b = valid_v03(); + b["verificationMaterial"]["tlogEntries"][0]["logId"]["keyId"] = + serde_json::json!("!!!nope!!!"); + assert!(format_err(&b.to_string()).contains("tlogEntry 'logId.keyId' is not valid base64")); + } + + #[test] + fn v03_tlog_log_id_absent_yields_empty_log_id() { + // Documented fallback (format.rs `None => String::new()`): an absent + // `logId` is tolerated and produces an EMPTY log id rather than an + // error. Asserted explicitly so the tolerance is visible: an empty + // log_id cannot select a Rekor log key, so SET verification downstream + // has nothing to check against. + let mut b = valid_v03(); + b["verificationMaterial"]["tlogEntries"][0] + .as_object_mut() + .unwrap() + .remove("logId"); + let sig = KeylessSignature::from_sigstore_bundle(&b.to_string()) + .expect("absent logId is currently tolerated"); + assert_eq!(sig.rekor_entry.log_id, ""); + } + + #[test] + fn v03_tlog_integrated_time_not_a_number() { + let mut b = valid_v03(); + b["verificationMaterial"]["tlogEntries"][0]["integratedTime"] = serde_json::json!([1, 2]); + assert!(format_err(&b.to_string()).contains("integratedTime is not an integer")); + } + + // --- helper functions ------------------------------------------- + + #[test] + fn module_hash_from_body_rejects_non_base64() { + let err = module_hash_from_hashedrekord_body("!!!not base64!!!").unwrap_err(); + assert!(format!("{err:?}").contains("Rekor body is not valid base64")); + } + + #[test] + fn module_hash_from_body_rejects_non_json() { + let err = + module_hash_from_hashedrekord_body(&BASE64.encode("not json at all")).unwrap_err(); + assert!(format!("{err:?}").contains("Rekor body is not valid JSON")); + } + + #[test] + fn module_hash_from_body_rejects_missing_hash_value() { + let body = BASE64.encode(serde_json::json!({ "spec": { "data": {} } }).to_string()); + let err = module_hash_from_hashedrekord_body(&body).unwrap_err(); + assert!(format!("{err:?}").contains("Rekor body missing 'spec.data.hash.value'")); + } + + #[test] + fn module_hash_from_body_rejects_non_hex_value() { + let body = BASE64.encode( + serde_json::json!({ "spec": { "data": { "hash": { "value": "zz".repeat(32) } } } }) + .to_string(), + ); + let err = module_hash_from_hashedrekord_body(&body).unwrap_err(); + assert!(format!("{err:?}").contains("Rekor body hash value is not valid hex")); + } + + #[test] + fn module_hash_from_body_rejects_wrong_length_hash() { + // Valid hex, but 16 bytes rather than a SHA-256's 32. A short digest + // must be rejected outright: it can never match a recomputed module + // hash, and accepting it would let a truncated digest through. + let body = BASE64.encode( + serde_json::json!({ "spec": { "data": { "hash": { "value": "ab".repeat(16) } } } }) + .to_string(), + ); + let err = module_hash_from_hashedrekord_body(&body).unwrap_err(); + assert!( + format!("{err:?}").contains("Rekor body hash is 16 bytes, expected 32"), + "got: {err:?}" + ); + } + + #[test] + fn decode_v03_digest_accepts_both_wire_encodings() { + let raw: Vec = (0..32u8).collect(); + // wsc-emitted hex form. + assert_eq!(decode_v03_digest(&hex::encode(&raw)).unwrap(), raw); + // cosign wire form (base64) — 44 chars, so it takes the other arm. + assert_eq!(decode_v03_digest(&BASE64.encode(&raw)).unwrap(), raw); + } + + #[test] + fn decode_v03_digest_rejects_garbage_and_wrong_length() { + assert!( + format!("{:?}", decode_v03_digest("!!!").unwrap_err()) + .contains("neither 64-char hex nor base64") + ); + // Valid base64 of 31 bytes: decodes fine, wrong size. + let short = BASE64.encode(vec![0u8; 31]); + assert!( + format!("{:?}", decode_v03_digest(&short).unwrap_err()) + .contains("messageDigest.digest is 31 bytes, expected 32") + ); + // A 64-char string that is valid base64 but NOT hex (48 bytes encode + // to exactly 64 unpadded base64 chars) must take the base64 arm and + // decode to 48 bytes — proving the hex arm is gated on + // `is_ascii_hexdigit`, not on length alone. + let sixty_four_non_hex = BASE64.encode(vec![0xffu8; 48]); + assert_eq!(sixty_four_non_hex.len(), 64); + assert!(!sixty_four_non_hex.bytes().all(|b| b.is_ascii_hexdigit())); + let err = decode_v03_digest(&sixty_four_non_hex).unwrap_err(); + assert!( + format!("{err:?}").contains("messageDigest.digest is 48 bytes, expected 32"), + "got: {err:?}" + ); + } + + #[test] + fn integrated_time_rejects_non_integer() { + let err = integrated_time_to_rfc3339(&serde_json::json!("not a time")).unwrap_err(); + assert!(format!("{err:?}").contains("integratedTime is not an integer")); + let err = integrated_time_to_rfc3339(&serde_json::json!(null)).unwrap_err(); + assert!(format!("{err:?}").contains("integratedTime is not an integer")); + } + + #[test] + fn integrated_time_rejects_out_of_range() { + let err = integrated_time_to_rfc3339(&serde_json::json!(i64::MAX)).unwrap_err(); + assert!( + format!("{err:?}").contains("is out of range"), + "got: {err:?}" + ); + } + + #[test] + fn integrated_time_accepts_number_and_decimal_string() { + // Both wire forms map to the SAME RFC3339 instant. `verify_cert_chain` + // parses this field with `parse_from_rfc3339`, so a bare Unix-seconds + // string here would break every ingested bundle. + assert_eq!( + integrated_time_to_rfc3339(&serde_json::json!(1_787_295_161i64)).unwrap(), + "2026-08-21T06:52:41Z" + ); + assert_eq!( + integrated_time_to_rfc3339(&serde_json::json!("1787295161")).unwrap(), + "2026-08-21T06:52:41Z" + ); + } + + #[test] + fn split_pem_certificates_handles_one_many_and_unterminated() { + let one = "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n"; + assert_eq!(split_pem_certificates(one).len(), 1); + + // Two concatenated blocks -> two chain entries (Fulcio leaf + + // intermediate arrive this way in the legacy `cert` field). + let two = format!("{one}{one}"); + let split = split_pem_certificates(&two); + assert_eq!(split.len(), 2, "concatenated PEM blocks must be split"); + assert!( + split + .iter() + .all(|c| c.ends_with("-----END CERTIFICATE-----")) + ); + + // No END marker at all: the whole trimmed input is returned as a + // single entry rather than yielding an empty chain, which would make + // an unterminated PEM silently look like "no certificate". + let unterminated = " -----BEGIN CERTIFICATE-----\nAQID "; + let split = split_pem_certificates(unterminated); + assert_eq!(split, vec![unterminated.trim().to_string()]); + } + fn create_test_signature() -> KeylessSignature { let signature = vec![1, 2, 3, 4, 5]; let cert_chain = vec![ diff --git a/src/lib/tests/sigstore_bundle.rs b/src/lib/tests/sigstore_bundle.rs index 069d603..6ca3171 100644 --- a/src/lib/tests/sigstore_bundle.rs +++ b/src/lib/tests/sigstore_bundle.rs @@ -65,8 +65,8 @@ fn from_sigstore_bundle_legacy_positive() { /// call would return `Err`. #[test] fn from_sigstore_bundle_legacy_body_binding_accepts() { - let sig = KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE) - .expect("legacy fixture must ingest"); + let sig = + KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE).expect("legacy fixture must ingest"); sig.verify_rekor_body_binds_to_bundle() .expect("ingested legacy bundle must pass offline Rekor-body binding"); } @@ -135,8 +135,7 @@ fn from_sigstore_bundle_v03_rejects_raw_public_key() { match err { WSError::KeylessFormatError(msg) => { assert!( - msg.contains("raw public key") - && msg.contains("requires a certificate"), + msg.contains("raw public key") && msg.contains("requires a certificate"), "expected the raw-public-key cert-requirement error, got: {msg}" ); } @@ -152,8 +151,8 @@ fn from_sigstore_bundle_v03_rejects_raw_public_key() { /// documented inline; nothing is silently dropped. #[test] fn from_sigstore_bundle_legacy_round_trip_fidelity() { - let sig = KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE) - .expect("legacy fixture must ingest"); + let sig = + KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE).expect("legacy fixture must ingest"); let bundle = SigstoreBundle::from_keyless_signature(&sig); let json = bundle.to_json().expect("bundle to_json"); @@ -175,7 +174,10 @@ fn from_sigstore_bundle_legacy_round_trip_fidelity() { // cert chain: preserved as base64(DER). Re-derive the DER of the ingested // leaf PEM (strip headers, base64-decode) and compare byte-for-byte. - let certs = &bundle2.verification_material.x509_certificate_chain.certificates; + let certs = &bundle2 + .verification_material + .x509_certificate_chain + .certificates; assert_eq!( certs.len(), sig.cert_chain.len(), @@ -191,16 +193,34 @@ fn from_sigstore_bundle_legacy_round_trip_fidelity() { // rekor fields. let tlog = &bundle2.verification_material.tlog_entries[0]; assert_eq!(tlog.log_index, sig.rekor_entry.log_index.to_string()); - assert_eq!(tlog.log_id.key_id, sig.rekor_entry.log_id); + // LogId.key_id is a protobuf `bytes` field, so its JSON form is base64 — + // what real cosign emits. RekorEntry::log_id holds the hex (Rekor REST) + // form, so the emitter transcodes hex -> base64. + assert_eq!( + tlog.log_id.key_id, + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + hex::decode(&sig.rekor_entry.log_id).expect("fixture log_id is hex") + ), + "log id must be emitted as base64 of the key-id bytes" + ); assert_eq!( tlog.canonicalized_body.as_deref(), Some(sig.rekor_entry.body.as_str()), "canonicalized body (base64 rekord) must survive" ); + // The SET must be emitted at the spec location (inclusionPromise), not as + // the non-conformant top-level field wsc <= 0.11.0 used. + assert!( + tlog.legacy_signed_entry_timestamp.is_none(), + "must not emit the legacy top-level SET" + ); assert_eq!( - tlog.signed_entry_timestamp.as_deref(), + tlog.inclusion_promise + .as_ref() + .map(|p| p.signed_entry_timestamp.as_str()), Some(sig.rekor_entry.signed_entry_timestamp.as_str()), - "SET must survive" + "SET must survive at inclusionPromise.signedEntryTimestamp" ); // integrated_time: KeylessSignature holds RFC3339; the bundle re-emits it // as Unix seconds — the same instant, matching the legacy fixture value. @@ -221,8 +241,8 @@ fn from_sigstore_bundle_legacy_round_trip_fidelity() { /// v0.3 marker yields the specific unrecognised-format error. #[test] fn from_sigstore_bundle_unrecognized_format() { - let err = KeylessSignature::from_sigstore_bundle("{}") - .expect_err("empty object must be rejected"); + let err = + KeylessSignature::from_sigstore_bundle("{}").expect_err("empty object must be rejected"); match err { WSError::KeylessFormatError(msg) => { assert!( @@ -244,6 +264,321 @@ fn pem_body_der(pem: &str) -> Vec { BASE64.decode(&b64).expect("valid base64 in PEM body") } +/// The Rekor log ID recorded in the legacy fixture (`rekorBundle.Payload.logID`), +/// hex-encoded — 64 hex chars = the log's 32-byte key id. +const LEGACY_LOG_ID_HEX: &str = "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d"; + +/// Pull a string field out of the legacy fixture so the v0.3 tests below are +/// built from the SAME real material the legacy fixture carries (real Fulcio +/// leaf, real ECDSA signature, real hashedrekord body, real SET) rather than +/// from synthetic bytes that could never fail a binding check. +fn legacy_field(path: &[&str]) -> String { + let v: serde_json::Value = serde_json::from_str(LEGACY_BUNDLE).unwrap(); + let mut cur = &v; + for p in path { + cur = &cur[*p]; + } + cur.as_str() + .unwrap_or_else(|| panic!("legacy fixture field {path:?} must be a string")) + .to_string() +} + +/// The real Fulcio leaf certificate's DER bytes, recovered from the legacy +/// fixture's base64(PEM) `cert` field. +fn legacy_leaf_der() -> Vec { + let pem = String::from_utf8(BASE64.decode(legacy_field(&["cert"])).unwrap()).unwrap(); + pem_body_der(&pem) +} + +/// Test 7 — **Cross-format round trip through the v0.3 wire shape.** +/// +/// Ingest the real legacy fixture, re-emit it as a v0.3 bundle with +/// `SigstoreBundle::from_keyless_signature`, then feed that JSON back through +/// `from_sigstore_bundle`. This is the only test that drives the v0.3 +/// *cert-bearing* happy path (`verificationMaterial.x509CertificateChain` → +/// signature → digest → tlog mapping) end-to-end with real Fulcio material; +/// the committed v0.3 fixture is a local-key bundle that bails at the +/// cert-requirement check, so without this test the whole v0.3 mapping was +/// claimed but never executed. +/// +/// Non-vacuity: the two offline oracles at the end +/// (`verify_rekor_body_binds_to_bundle`, `verify_cert_chain`) recompute the +/// digest/signature/leaf-DER binding and chain the leaf to the embedded Fulcio +/// roots at the extracted `integrated_time`. If the v0.3 path extracted any of +/// those four values inconsistently, they would return `Err`. +/// +/// **This test documents two KNOWN DEFECTS it discovered** — see the inline +/// `KNOWN DEFECT` comments on `log_id` and `signed_entry_timestamp`. They are +/// characterized here, not fixed, because the fix belongs on the emitter +/// (`src/lib/src/container/bundle.rs`) and would change wsc's on-disk bundle +/// format. When either is fixed, the corresponding assertion below fails and +/// must be flipped to the equality it should always have had. +#[test] +fn from_sigstore_bundle_v03_cert_bearing_round_trip() { + let sig_a = + KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE).expect("legacy fixture must ingest"); + + let json_bytes = SigstoreBundle::from_keyless_signature(&sig_a) + .to_json() + .expect("emit v0.3 bundle"); + let json = String::from_utf8(json_bytes).expect("emitted bundle is UTF-8"); + + // Sanity: the emitted document really is the v0.3 wire shape, so the + // re-ingest below exercises `from_v03_bundle` and not the legacy path. + assert!( + json.contains("application/vnd.dev.sigstore.bundle.v0.3+json") + && json.contains("x509CertificateChain") + && !json.contains("rekorBundle"), + "emitted bundle must be the v0.3 shape" + ); + + let sig_b = + KeylessSignature::from_sigstore_bundle(&json).expect("re-ingest of emitted v0.3 bundle"); + + // --- fields that survive intact ------------------------------------- + assert_eq!( + sig_b.signature, sig_a.signature, + "signature bytes must survive the legacy -> v0.3 -> KeylessSignature round trip" + ); + assert_eq!( + sig_b.module_hash, sig_a.module_hash, + "module_hash must survive (emitted as hex, re-read by decode_v03_digest's hex arm)" + ); + assert_eq!( + sig_b.module_hash, + hex::decode(LEGACY_MODULE_HASH_HEX).unwrap(), + "and it is still the fixture's real artifact digest" + ); + + // cert_chain: NOT string-equal, and legitimately so. The emit path does + // PEM -> DER -> base64 and the ingest path does base64 -> DER -> PEM via + // `der_to_pem`, which re-wraps at 64 columns and appends a trailing + // newline. The certificate ITSELF must be byte-identical, so compare the + // DER, which is the only representation that carries meaning. + assert_eq!( + sig_b.cert_chain.len(), + sig_a.cert_chain.len(), + "chain depth must survive" + ); + assert_eq!( + pem_body_der(&sig_b.cert_chain[0]), + pem_body_der(&sig_a.cert_chain[0]), + "leaf certificate DER must be byte-identical across the round trip" + ); + assert_eq!( + pem_body_der(&sig_b.cert_chain[0]), + legacy_leaf_der(), + "and it is still the fixture's real Fulcio leaf" + ); + + assert_eq!( + sig_b.rekor_entry.log_index, sig_a.rekor_entry.log_index, + "logIndex must survive (emitted as a decimal string, re-read by json_u64)" + ); + assert_eq!(sig_b.rekor_entry.log_index, 2544945534); + assert_eq!( + sig_b.rekor_entry.body, sig_a.rekor_entry.body, + "canonicalized hashedrekord body must survive verbatim" + ); + assert_eq!( + sig_b.rekor_entry.integrated_time, sig_a.rekor_entry.integrated_time, + "integrated_time must survive (RFC3339 -> Unix seconds -> RFC3339)" + ); + assert_eq!(sig_b.rekor_entry.integrated_time, "2026-08-21T06:52:41Z"); + + // --- the two fields that used to be corrupted (now lossless) --------- + // + // Both were REAL emitter defects this round-trip test found, and both are + // fixed in this PR (see container/bundle.rs). Ground truth came from a real + // cosign `--new-bundle-format` bundle, which emits `logId.keyId` as base64 + // and puts the SET under `inclusionPromise` with no top-level field. + + // Was DEFECT #1: the emitter wrote `rekor.log_id` (hex) straight into + // `LogId.key_id`, which the spec types as protobuf `bytes` (base64 in + // JSON). A 64-char hex string is *also* valid base64, so the ingest + // silently decoded it to 48 junk bytes — corrupting the Rekor log identity + // with no diagnostic. The emitter now transcodes hex -> base64, so the log + // id survives the round trip exactly. + assert_eq!( + sig_b.rekor_entry.log_id, sig_a.rekor_entry.log_id, + "log id must survive the v0.3 round trip exactly" + ); + assert_eq!(sig_b.rekor_entry.log_id, LEGACY_LOG_ID_HEX); + + // Was DEFECT #2: the emitter wrote the SET at the non-conformant top-level + // `tlogEntries[0].signedEntryTimestamp`, while the ingest reads the spec + // location `inclusionPromise.signedEntryTimestamp` — so the SET, the only + // offline transparency proof a legacy bundle carries, was silently dropped + // on wsc's own round trip. The emitter now writes the spec location. + assert!( + !sig_a.rekor_entry.signed_entry_timestamp.is_empty(), + "the legacy fixture does carry a SET" + ); + assert_eq!( + sig_b.rekor_entry.signed_entry_timestamp, sig_a.rekor_entry.signed_entry_timestamp, + "SET must survive the v0.3 round trip (was silently dropped before the fix)" + ); + + // Documented-empty by construction on BOTH sides (never silently skipped): + // - uuid: neither the legacy shape nor the v0.3 shape carries an entry + // UUID, so both are empty. See format.rs:238-246 / :375. + // - inclusion_proof: the legacy bundle carries only a SET, so there is + // nothing to emit and nothing to re-read. + assert_eq!(sig_a.rekor_entry.uuid, ""); + assert_eq!(sig_b.rekor_entry.uuid, ""); + assert!(sig_a.rekor_entry.inclusion_proof.is_empty()); + assert!(sig_b.rekor_entry.inclusion_proof.is_empty()); + + // --- offline oracles on the RE-INGESTED signature --------------------- + // These are what make the field assertions above non-vacuous: they + // recompute the relationships between the extracted values. + sig_b + .verify_rekor_body_binds_to_bundle() + .expect("v0.3 re-ingested bundle must still pass offline Rekor-body binding"); + sig_b + .verify_cert_chain() + .expect("v0.3 re-ingested leaf must still chain to embedded Fulcio roots"); +} + +/// Test 8 — **Spec-shaped v0.3 keyless bundle (what real cosign emits).** +/// +/// Test 7 goes through wsc's own emitter, which uses the plural +/// `x509CertificateChain` container, hex digests, and a top-level SET. Real +/// cosign `--new-bundle-format` keyless bundles instead use the singular +/// `verificationMaterial.certificate.rawBytes`, a base64 digest, a base64 +/// `logId.keyId`, and the SET under `inclusionPromise` — a completely +/// different set of branches that no fixture reaches. +/// +/// This test hand-builds that shape from the SAME real material the legacy +/// fixture carries (real Fulcio leaf DER, real signature, real hashedrekord +/// body, real SET, real log id) and asserts every field comes back exactly. +/// +/// It is also the control for the two KNOWN DEFECTS pinned in test 7: here +/// `log_id` and `signed_entry_timestamp` DO come back intact, which proves the +/// ingest side is spec-correct and locates both defects on the emitter. +#[test] +fn from_sigstore_bundle_v03_spec_shaped_keyless_positive() { + let leaf_der = legacy_leaf_der(); + let sig_b64 = legacy_field(&["base64Signature"]); + let body_b64 = legacy_field(&["rekorBundle", "Payload", "body"]); + let set_b64 = legacy_field(&["rekorBundle", "SignedEntryTimestamp"]); + + let bundle = serde_json::json!({ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + // Singular `certificate`, as real cosign keyless bundles use. + "certificate": { "rawBytes": BASE64.encode(&leaf_der) }, + "tlogEntries": [{ + // v0.3 encodes int64 as a decimal STRING. + "logIndex": "2544945534", + // Spec form: base64 of the log's raw key-id bytes. + "logId": { "keyId": BASE64.encode(hex::decode(LEGACY_LOG_ID_HEX).unwrap()) }, + "canonicalizedBody": body_b64, + "integratedTime": "1787295161", + // Spec form: SET nested under inclusionPromise. + "inclusionPromise": { "signedEntryTimestamp": set_b64 }, + }], + }, + "messageSignature": { + "messageDigest": { + "algorithm": "SHA2_256", + // Spec form: base64 digest (44 chars) — exercises the base64 + // arm of `decode_v03_digest`, which test 7's hex form does not. + "digest": BASE64.encode(hex::decode(LEGACY_MODULE_HASH_HEX).unwrap()), + }, + "signature": sig_b64, + }, + }); + + let sig = KeylessSignature::from_sigstore_bundle(&bundle.to_string()) + .expect("spec-shaped v0.3 keyless bundle must ingest"); + + assert_eq!( + sig.cert_chain.len(), + 1, + "the singular `certificate` yields a one-entry chain" + ); + assert_eq!( + pem_body_der(&sig.cert_chain[0]), + leaf_der, + "leaf DER must round-trip through der_to_pem byte-for-byte" + ); + assert_eq!( + sig.signature, + BASE64.decode(&sig_b64).unwrap(), + "signature bytes must come from messageSignature.signature" + ); + assert_eq!( + sig.module_hash, + hex::decode(LEGACY_MODULE_HASH_HEX).unwrap(), + "base64 digest must decode to the fixture's artifact hash" + ); + assert_eq!(sig.rekor_entry.log_index, 2544945534); + assert_eq!( + sig.rekor_entry.log_id, LEGACY_LOG_ID_HEX, + "base64 keyId must be re-encoded to the real hex log id (control for DEFECT #1)" + ); + assert_eq!( + sig.rekor_entry.signed_entry_timestamp, set_b64, + "SET under inclusionPromise must be carried over (control for DEFECT #2)" + ); + assert_eq!(sig.rekor_entry.body, body_b64); + assert_eq!(sig.rekor_entry.integrated_time, "2026-08-21T06:52:41Z"); + + // Offline oracles: the extracted digest / signature / leaf-DER must be + // mutually consistent, and the leaf must chain to the embedded Fulcio + // roots at the extracted integrated_time. + sig.verify_rekor_body_binds_to_bundle() + .expect("spec-shaped v0.3 bundle must pass offline Rekor-body binding"); + sig.verify_cert_chain() + .expect("spec-shaped v0.3 leaf must chain to embedded Fulcio roots"); +} + +/// Test 9 — v0.3 negative control (faithful extraction, mirroring test 2 for +/// the v0.3 path): flip one byte of the base64 `messageDigest.digest` and +/// assert the extracted `module_hash` changes. Proves the v0.3 digest is read +/// from the bundle, not recomputed or hardcoded. +#[test] +fn from_sigstore_bundle_v03_negative_control_faithful_digest() { + let leaf_der = legacy_leaf_der(); + let mut digest = hex::decode(LEGACY_MODULE_HASH_HEX).unwrap(); + digest[0] ^= 0xff; + + let bundle = serde_json::json!({ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "certificate": { "rawBytes": BASE64.encode(&leaf_der) }, + "tlogEntries": [{ + "logIndex": "2544945534", + "logId": { "keyId": BASE64.encode(hex::decode(LEGACY_LOG_ID_HEX).unwrap()) }, + "integratedTime": "1787295161", + }], + }, + "messageSignature": { + "messageDigest": { "algorithm": "SHA2_256", "digest": BASE64.encode(&digest) }, + "signature": legacy_field(&["base64Signature"]), + }, + }); + + let sig = KeylessSignature::from_sigstore_bundle(&bundle.to_string()) + .expect("well-formed bundle with a corrupted digest still ingests"); + assert_eq!( + sig.module_hash, digest, + "the corrupted digest must be propagated verbatim, never 'fixed'" + ); + assert_ne!( + sig.module_hash, + hex::decode(LEGACY_MODULE_HASH_HEX).unwrap() + ); + + // The absent `canonicalizedBody` / `inclusionPromise` are read as empty + // (format.rs:365-372 `unwrap_or_default`) rather than erroring — asserted + // rather than left unexamined, because a silently-empty transparency proof + // is exactly what a verifier must not accept unnoticed. + assert_eq!(sig.rekor_entry.body, ""); + assert_eq!(sig.rekor_entry.signed_entry_timestamp, ""); +} + /// Test 6 — `verify_cert_chain` (offline: embedded Fulcio trust roots, no /// network) accepts the ingested legacy bundle. This is the leg that depends /// on `integrated_time` being RFC3339: `verify_cert_chain` parses that field @@ -254,8 +589,8 @@ fn pem_body_der(pem: &str) -> Vec { /// and this test would catch the regression. #[test] fn from_sigstore_bundle_legacy_verify_cert_chain_accepts() { - let sig = KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE) - .expect("legacy fixture must ingest"); + let sig = + KeylessSignature::from_sigstore_bundle(LEGACY_BUNDLE).expect("legacy fixture must ingest"); sig.verify_cert_chain() .expect("ingested legacy leaf cert must chain to embedded Fulcio roots at integrated_time"); }