1use chio_attest_verify::{AttestVerifier, ExpectedIdentity, VerifiedAttestation};
38use chrono::{DateTime, Utc};
39
40use crate::card::ModelCard;
41use crate::error::WeightsError;
42
43#[derive(Debug, Clone)]
48pub struct VerifiedModelCard {
49 pub card: ModelCard,
52 pub attestation: VerifiedAttestation,
55}
56
57pub 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 #[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 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 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}