Skip to main content

chio_weights/
bundle.rs

1//! Cosign bundle helper for model cards.
2//!
3//! Wraps [`chio_attest_verify::SigstoreVerifier::verify_bundle`] so that
4//! callers presenting a `(card_bytes, bundle_json)` pair can verify the
5//! bundle without reaching into `sigstore-rs` directly. The model-card
6//! binding does not introduce a new trust root or a new signature path;
7//! the existing cosign bundle verifier (and the PQ-hybrid surface) is
8//! consumed verbatim.
9//!
10//! # Trust contract
11//!
12//! - `card_bytes` MUST be the RFC 8785 canonical-JSON encoding of the
13//!   [`crate::card::ModelCard`]. The cosign bundle's signature is taken
14//!   over those bytes, so re-encoding through any non-canonical serializer
15//!   produces a different digest and the bundle rejects fail-closed.
16//! - `expected` is an [`ExpectedIdentity`] supplied by the caller. The
17//!   helper does not synthesise a default identity; deployments that flip
18//!   `policy.weights_card_required = required_with_pin` provide the SAN
19//!   regex from the provider matrix.
20//! - `now` is the verifier-side current time used to enforce the card's
21//!   `expires_at`. The cosign verifier's certificate-window check is
22//!   independent of this and runs against the cert's `notBefore` /
23//!   `notAfter`.
24//!
25//! # Trust boundary
26//!
27//! Every public method that returns `Ok(_)` MUST mean:
28//!
29//! 1. the cosign bundle verified against the supplied card bytes,
30//! 2. the bundle's certificate identity matched [`ExpectedIdentity`] and
31//!    its OIDC issuer matched [`ExpectedIdentity::certificate_oidc_issuer`],
32//! 3. the card decoded cleanly from the canonical bytes, and
33//! 4. the card's `expires_at` is strictly after `now`.
34//!
35//! Any failing precondition surfaces as a [`crate::error::WeightsError`].
36
37use chio_attest_verify::{AttestVerifier, ExpectedIdentity, VerifiedAttestation};
38use chrono::{DateTime, Utc};
39
40use crate::card::ModelCard;
41use crate::error::WeightsError;
42
43/// Successful verification result. Pairs the parsed [`ModelCard`] with the
44/// upstream [`VerifiedAttestation`] so receipt consumers can correlate the
45/// Rekor log index, the Fulcio identity, and the model card's logical
46/// fields without re-parsing the bundle.
47#[derive(Debug, Clone)]
48pub struct VerifiedModelCard {
49    /// Parsed model card decoded from the canonical-JSON byte slice the
50    /// cosign bundle was signed over.
51    pub card: ModelCard,
52    /// Upstream attestation metadata (cert identity, OIDC issuer, Rekor
53    /// log index, signing time).
54    pub attestation: VerifiedAttestation,
55}
56
57/// Verify a cosign-signed model card. Delegates the cryptographic verify
58/// path to [`AttestVerifier::verify_bundle`]; this helper does not fork
59/// it.
60///
61/// On success, returns the parsed [`ModelCard`] alongside the upstream
62/// [`VerifiedAttestation`]. Fail-closed: any failing precondition returns
63/// a [`WeightsError`] whose [`WeightsError::urn`] gives the stable
64/// `urn:chio:error:weights:*` code.
65///
66/// # Liveness
67///
68/// The card's `expires_at` field is enforced against `now`. A card whose
69/// cosign signature would otherwise verify but whose `expires_at` has
70/// already passed surfaces as [`WeightsError::Expired`].
71pub fn verify_model_card_bundle<V: AttestVerifier + ?Sized>(
72    verifier: &V,
73    card_bytes: &[u8],
74    bundle_json: &[u8],
75    expected: &ExpectedIdentity,
76    now: DateTime<Utc>,
77) -> Result<VerifiedModelCard, WeightsError> {
78    let attestation = verifier
79        .verify_bundle(card_bytes, bundle_json, expected)
80        .map_err(|err| WeightsError::BundleRejected(format!("{err}")))?;
81
82    let card = ModelCard::from_canonical_json(card_bytes)?;
83    if card.issuer != attestation.certificate_identity {
84        return Err(WeightsError::BundleRejected(format!(
85            "model card issuer {:?} does not match verified certificate identity {:?}",
86            card.issuer, attestation.certificate_identity
87        )));
88    }
89    card.require_live(now)?;
90
91    Ok(VerifiedModelCard { card, attestation })
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::path::Path;
98    use std::sync::Mutex;
99    use std::time::SystemTime;
100
101    use chio_attest_verify::AttestError;
102    use chrono::TimeZone;
103
104    use crate::card::StringSet;
105
106    /// Fake verifier that records the inputs it was called with and emits
107    /// a configurable response. Used in unit tests to lock the helper's
108    /// behaviour without standing up the real Sigstore TUF root.
109    #[derive(Default)]
110    struct FakeVerifier {
111        last_artifact: Mutex<Option<Vec<u8>>>,
112        last_bundle: Mutex<Option<Vec<u8>>>,
113        last_expected: Mutex<Option<ExpectedIdentity>>,
114        outcome: Mutex<Outcome>,
115    }
116
117    enum Outcome {
118        Ok(VerifiedAttestation),
119        Reject(AttestError),
120    }
121
122    impl Default for Outcome {
123        fn default() -> Self {
124            Self::Reject(AttestError::SignatureMismatch)
125        }
126    }
127
128    impl AttestVerifier for FakeVerifier {
129        fn verify_blob(
130            &self,
131            _artifact: &Path,
132            _signature: &Path,
133            _certificate: &Path,
134            _expected: &ExpectedIdentity,
135        ) -> Result<VerifiedAttestation, AttestError> {
136            Err(AttestError::Malformed(
137                "verify_blob not used in tests".into(),
138            ))
139        }
140        fn verify_bytes(
141            &self,
142            _artifact: &[u8],
143            _signature: &[u8],
144            _certificate_pem: &[u8],
145            _expected: &ExpectedIdentity,
146        ) -> Result<VerifiedAttestation, AttestError> {
147            Err(AttestError::Malformed(
148                "verify_bytes not used in tests".into(),
149            ))
150        }
151        fn verify_bundle(
152            &self,
153            artifact: &[u8],
154            bundle_json: &[u8],
155            expected: &ExpectedIdentity,
156        ) -> Result<VerifiedAttestation, AttestError> {
157            if let Ok(mut slot) = self.last_artifact.lock() {
158                *slot = Some(artifact.to_vec());
159            }
160            if let Ok(mut slot) = self.last_bundle.lock() {
161                *slot = Some(bundle_json.to_vec());
162            }
163            if let Ok(mut slot) = self.last_expected.lock() {
164                *slot = Some(expected.clone());
165            }
166            let outcome = match self.outcome.lock() {
167                Ok(g) => g,
168                Err(_) => return Err(AttestError::SignatureMismatch),
169            };
170            match &*outcome {
171                Outcome::Ok(att) => Ok(att.clone()),
172                Outcome::Reject(err) => Err(match err {
173                    AttestError::SignatureMismatch => AttestError::SignatureMismatch,
174                    AttestError::IdentityMismatch => AttestError::IdentityMismatch,
175                    AttestError::IssuerMismatch => AttestError::IssuerMismatch,
176                    AttestError::RekorInclusion => AttestError::RekorInclusion,
177                    AttestError::CertificateExpired => AttestError::CertificateExpired,
178                    AttestError::TrustRoot => AttestError::TrustRoot,
179                    AttestError::Malformed(s) => AttestError::Malformed(s.clone()),
180                    AttestError::ReportDataMismatch => AttestError::ReportDataMismatch,
181                    AttestError::QuoteRejected(s) => AttestError::QuoteRejected(s.clone()),
182                    AttestError::Io(_) => AttestError::Malformed("io error stand-in".into()),
183                    _ => AttestError::SignatureMismatch,
184                }),
185            }
186        }
187    }
188
189    fn fixed_now() -> DateTime<Utc> {
190        match Utc.with_ymd_and_hms(2026, 4, 30, 12, 0, 0) {
191            chrono::LocalResult::Single(t) => t,
192            _ => panic!("fixed_now fixture must construct"),
193        }
194    }
195
196    fn card_bytes_with_issuer(issuer: &str) -> Vec<u8> {
197        let now = fixed_now();
198        let card = match ModelCard::new(
199            "0000000000000000000000000000000000000000000000000000000000000001",
200            StringSet::new(["tool:read"]),
201            StringSet::default(),
202            "public-internet",
203            issuer,
204            now,
205            now + chrono::Duration::days(30),
206        ) {
207            Ok(c) => c,
208            Err(e) => panic!("good card must construct: {e}"),
209        };
210        match card.to_canonical_json() {
211            Ok(b) => b,
212            Err(e) => panic!("canonical-json encode must succeed: {e}"),
213        }
214    }
215
216    fn good_card_bytes() -> Vec<u8> {
217        card_bytes_with_issuer("https://example.com/issuer")
218    }
219
220    fn ok_outcome() -> Outcome {
221        Outcome::Ok(VerifiedAttestation {
222            subject_digest_sha256: [0u8; 32],
223            certificate_identity: "https://example.com/issuer".into(),
224            certificate_oidc_issuer: "https://token.example.com".into(),
225            rekor_log_index: 0,
226            rekor_inclusion_verified: true,
227            signed_at: SystemTime::UNIX_EPOCH,
228        })
229    }
230
231    fn expected_identity() -> ExpectedIdentity {
232        ExpectedIdentity {
233            certificate_identity_regexp: "https://example\\.com/.*".into(),
234            certificate_oidc_issuer: "https://token.example.com".into(),
235        }
236    }
237
238    #[test]
239    fn verify_passes_through_to_underlying_verifier() {
240        let verifier = FakeVerifier::default();
241        if let Ok(mut slot) = verifier.outcome.lock() {
242            *slot = ok_outcome();
243        }
244        let bytes = good_card_bytes();
245        let bundle = b"{\"fake-bundle\":true}";
246        let expected = expected_identity();
247
248        let res = match verify_model_card_bundle(&verifier, &bytes, bundle, &expected, fixed_now())
249        {
250            Ok(v) => v,
251            Err(e) => panic!("ok outcome must verify: {e}"),
252        };
253        assert_eq!(
254            res.card.weights_hash,
255            "0000000000000000000000000000000000000000000000000000000000000001"
256        );
257
258        let last_artifact = match verifier.last_artifact.lock() {
259            Ok(g) => match g.clone() {
260                Some(b) => b,
261                None => panic!("artifact slot must be populated"),
262            },
263            Err(_) => panic!("artifact mutex must lock"),
264        };
265        assert_eq!(
266            last_artifact, bytes,
267            "helper must forward exact card bytes to verify_bundle"
268        );
269        let last_bundle = match verifier.last_bundle.lock() {
270            Ok(g) => match g.clone() {
271                Some(b) => b,
272                None => panic!("bundle slot must be populated"),
273            },
274            Err(_) => panic!("bundle mutex must lock"),
275        };
276        assert_eq!(last_bundle, bundle);
277    }
278
279    #[test]
280    fn verify_rejects_when_underlying_verifier_rejects() {
281        let verifier = FakeVerifier::default();
282        if let Ok(mut slot) = verifier.outcome.lock() {
283            *slot = Outcome::Reject(AttestError::SignatureMismatch);
284        }
285        let bytes = good_card_bytes();
286        let bundle = b"{\"fake-bundle\":true}";
287        let expected = expected_identity();
288
289        let res = verify_model_card_bundle(&verifier, &bytes, bundle, &expected, fixed_now());
290        assert!(matches!(res, Err(WeightsError::BundleRejected(_))));
291    }
292
293    #[test]
294    fn verify_rejects_expired_card_even_if_bundle_verifies() {
295        let verifier = FakeVerifier::default();
296        if let Ok(mut slot) = verifier.outcome.lock() {
297            *slot = ok_outcome();
298        }
299        let bytes = good_card_bytes();
300        let bundle = b"{\"fake-bundle\":true}";
301        let expected = expected_identity();
302
303        // Far past expiry.
304        let later = fixed_now() + chrono::Duration::days(365);
305        let res = verify_model_card_bundle(&verifier, &bytes, bundle, &expected, later);
306        assert!(matches!(res, Err(WeightsError::Expired { .. })));
307    }
308
309    #[test]
310    fn verify_rejects_card_issuer_mismatch() {
311        let verifier = FakeVerifier::default();
312        if let Ok(mut slot) = verifier.outcome.lock() {
313            *slot = ok_outcome();
314        }
315        let bytes = card_bytes_with_issuer("https://example.com/other-issuer");
316        let bundle = b"{\"fake-bundle\":true}";
317        let expected = expected_identity();
318
319        let res = verify_model_card_bundle(&verifier, &bytes, bundle, &expected, fixed_now());
320        assert!(matches!(res, Err(WeightsError::BundleRejected(_))));
321    }
322
323    #[test]
324    fn verify_rejects_malformed_card_bytes() {
325        let verifier = FakeVerifier::default();
326        if let Ok(mut slot) = verifier.outcome.lock() {
327            *slot = ok_outcome();
328        }
329        let bundle = b"{\"fake-bundle\":true}";
330        let expected = expected_identity();
331
332        // Non-JSON byte slice. Bundle verifier fakes success, but the
333        // helper still must reject because the canonical-JSON decoder
334        // rejects malformed input.
335        let res = verify_model_card_bundle(
336            &verifier,
337            b"not json at all",
338            bundle,
339            &expected,
340            fixed_now(),
341        );
342        assert!(matches!(res, Err(WeightsError::Encoding(_))));
343    }
344}