1use chio_lineage::anchor::{
40 is_lowercase_hex_signature_payload, CanonicalSource, FrontierDigest, SigningState,
41};
42use chrono::{DateTime, Utc};
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45
46use crate::bundle::VerifiedModelCard;
47use crate::error::WeightsError;
48
49pub const MODEL_CARD_ANCHOR_SCHEMA: &str = "chio.weights.lineage-anchor/v1";
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ModelCardLineageAnchor {
63 pub schema_version: String,
65 pub graph_schema: String,
67 pub canonical_source: CanonicalSource,
70 pub digest: FrontierDigest,
72 pub signing: SigningState,
75 pub card_canonical_sha256: String,
79 pub attestation_subject_sha256: String,
86 pub certificate_identity: String,
88 pub certificate_oidc_issuer: String,
90 pub rekor_log_index: u64,
92 pub rekor_inclusion_verified: bool,
94 pub weights_hash: String,
96 pub card_issuer: String,
98 pub card_expires_at: DateTime<Utc>,
101}
102
103impl ModelCardLineageAnchor {
104 #[must_use]
108 pub fn is_signed(&self) -> bool {
109 false
110 }
111}
112
113pub fn anchor_projection_bytes(
121 card_bytes: &[u8],
122 attestation: &chio_attest_verify::VerifiedAttestation,
123) -> Result<Vec<u8>, WeightsError> {
124 let card_sha = sha256_hex(card_bytes);
125 let subject_sha = hex::encode(attestation.subject_digest_sha256);
126 let payload = serde_json::json!({
131 "card_canonical_sha256": card_sha,
132 "certificate_identity": attestation.certificate_identity,
133 "certificate_oidc_issuer": attestation.certificate_oidc_issuer,
134 "rekor_inclusion_verified": attestation.rekor_inclusion_verified,
135 "rekor_log_index": attestation.rekor_log_index,
136 "schema_version": MODEL_CARD_ANCHOR_SCHEMA,
137 "subject_digest_sha256": subject_sha,
138 });
139 chio_core_types::canonical::canonical_json_bytes(&payload)
140 .map_err(|err| WeightsError::Encoding(format!("anchor projection encode: {err}")))
141}
142
143fn sha256_hex(bytes: &[u8]) -> String {
146 let digest = Sha256::digest(bytes);
147 hex::encode(digest)
148}
149
150pub fn anchor_model_card(
163 verified: &VerifiedModelCard,
164 card_bytes: &[u8],
165 graph_schema: &str,
166 signer_hint: Option<&str>,
167) -> Result<ModelCardLineageAnchor, WeightsError> {
168 let decoded = crate::card::ModelCard::from_canonical_json(card_bytes)?;
169 if decoded != verified.card {
170 return Err(WeightsError::Encoding(
171 "card_bytes do not match verified.card; refusing to anchor stale bytes".to_string(),
172 ));
173 }
174
175 let projection = anchor_projection_bytes(card_bytes, &verified.attestation)?;
176 let digest_hex = sha256_hex(&projection);
177
178 let signing = match signer_hint {
186 Some(algorithm) => SigningState::UnsignedSignerStubbed {
187 algorithm: algorithm.to_string(),
188 },
189 None => SigningState::UnsignedSoftDepAbsent,
190 };
191
192 Ok(ModelCardLineageAnchor {
193 schema_version: MODEL_CARD_ANCHOR_SCHEMA.to_string(),
194 graph_schema: graph_schema.to_string(),
195 canonical_source: CanonicalSource::EquivalenceShim,
196 digest: FrontierDigest {
197 algo: "sha256".to_string(),
198 hex: digest_hex,
199 },
200 signing,
201 card_canonical_sha256: sha256_hex(card_bytes),
202 attestation_subject_sha256: hex::encode(verified.attestation.subject_digest_sha256),
203 certificate_identity: verified.attestation.certificate_identity.clone(),
204 certificate_oidc_issuer: verified.attestation.certificate_oidc_issuer.clone(),
205 rekor_log_index: verified.attestation.rekor_log_index,
206 rekor_inclusion_verified: verified.attestation.rekor_inclusion_verified,
207 weights_hash: verified.card.weights_hash.clone(),
208 card_issuer: verified.card.issuer.clone(),
209 card_expires_at: verified.card.expires_at,
210 })
211}
212
213pub fn verify_model_card_anchor(
218 anchor: &ModelCardLineageAnchor,
219 card_bytes: &[u8],
220 attestation: &chio_attest_verify::VerifiedAttestation,
221) -> Result<(), WeightsError> {
222 if anchor.schema_version != MODEL_CARD_ANCHOR_SCHEMA {
223 return Err(WeightsError::SchemaRejected(format!(
224 "model card anchor schema_version must be {MODEL_CARD_ANCHOR_SCHEMA:?}, got {:?}",
225 anchor.schema_version
226 )));
227 }
228 if anchor.digest.algo != "sha256" {
229 return Err(WeightsError::SchemaRejected(format!(
230 "model card anchor digest algo must be \"sha256\", got {:?}",
231 anchor.digest.algo
232 )));
233 }
234 let expected_hex = sha256_hex(&anchor_projection_bytes(card_bytes, attestation)?);
235 if expected_hex != anchor.digest.hex {
236 return Err(WeightsError::BundleRejected(format!(
237 "model card anchor digest mismatch: expected {expected_hex}, got {}",
238 anchor.digest.hex
239 )));
240 }
241 let card_sha = sha256_hex(card_bytes);
242 if card_sha != anchor.card_canonical_sha256 {
243 return Err(WeightsError::BundleRejected(format!(
244 "model card anchor card_canonical_sha256 mismatch: expected {card_sha}, got {}",
245 anchor.card_canonical_sha256
246 )));
247 }
248 let expected_subject_sha = hex::encode(attestation.subject_digest_sha256);
249 if expected_subject_sha != anchor.attestation_subject_sha256 {
250 return Err(WeightsError::BundleRejected(format!(
251 "model card anchor attestation_subject_sha256 mismatch: expected {expected_subject_sha}, got {}",
252 anchor.attestation_subject_sha256
253 )));
254 }
255 if attestation.certificate_identity != anchor.certificate_identity {
256 return Err(WeightsError::BundleRejected(format!(
257 "model card anchor certificate_identity mismatch: expected {:?}, got {:?}",
258 attestation.certificate_identity, anchor.certificate_identity
259 )));
260 }
261 if attestation.certificate_oidc_issuer != anchor.certificate_oidc_issuer {
262 return Err(WeightsError::BundleRejected(format!(
263 "model card anchor certificate_oidc_issuer mismatch: expected {:?}, got {:?}",
264 attestation.certificate_oidc_issuer, anchor.certificate_oidc_issuer
265 )));
266 }
267 if attestation.rekor_log_index != anchor.rekor_log_index {
268 return Err(WeightsError::BundleRejected(format!(
269 "model card anchor rekor_log_index mismatch: expected {}, got {}",
270 attestation.rekor_log_index, anchor.rekor_log_index
271 )));
272 }
273 if attestation.rekor_inclusion_verified != anchor.rekor_inclusion_verified {
274 return Err(WeightsError::BundleRejected(format!(
275 "model card anchor rekor_inclusion_verified mismatch: expected {}, got {}",
276 attestation.rekor_inclusion_verified, anchor.rekor_inclusion_verified
277 )));
278 }
279
280 if let SigningState::Signed {
284 algorithm,
285 signature_hex,
286 } = &anchor.signing
287 {
288 if !is_lowercase_hex_signature_payload(signature_hex) {
289 return Err(WeightsError::BundleRejected(
290 "model card anchor signing state was Signed but signature_hex was empty or not lower-case hexadecimal"
291 .to_string(),
292 ));
293 }
294 return Err(WeightsError::BundleRejected(format!(
295 "model card anchor signing algorithm {algorithm:?} is not verified by this build"
296 )));
297 }
298
299 let card = crate::card::ModelCard::from_canonical_json(card_bytes)?;
300 if card.weights_hash != anchor.weights_hash {
301 return Err(WeightsError::BundleRejected(format!(
302 "model card anchor weights_hash mismatch: expected {:?}, got {:?}",
303 card.weights_hash, anchor.weights_hash
304 )));
305 }
306 if card.issuer != anchor.card_issuer {
307 return Err(WeightsError::BundleRejected(format!(
308 "model card anchor card_issuer mismatch: expected {:?}, got {:?}",
309 card.issuer, anchor.card_issuer
310 )));
311 }
312 if card.expires_at != anchor.card_expires_at {
313 return Err(WeightsError::BundleRejected(format!(
314 "model card anchor card_expires_at mismatch: expected {}, got {}",
315 card.expires_at, anchor.card_expires_at
316 )));
317 }
318 Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use std::time::SystemTime;
325
326 use chio_attest_verify::VerifiedAttestation;
327 use chrono::TimeZone;
328
329 use crate::card::{ModelCard, StringSet};
330
331 fn fixed_now() -> DateTime<Utc> {
332 match Utc.with_ymd_and_hms(2026, 4, 30, 12, 0, 0) {
333 chrono::LocalResult::Single(t) => t,
334 _ => panic!("fixed_now fixture must construct"),
335 }
336 }
337
338 fn good_card() -> ModelCard {
339 let now = fixed_now();
340 match ModelCard::new(
341 "0000000000000000000000000000000000000000000000000000000000000001",
342 StringSet::new(["tool:read"]),
343 StringSet::default(),
344 "public-internet",
345 "https://example.com/issuer",
346 now,
347 now + chrono::Duration::days(30),
348 ) {
349 Ok(c) => c,
350 Err(e) => panic!("good_card must construct: {e}"),
351 }
352 }
353
354 fn good_attestation(card_sha: [u8; 32]) -> VerifiedAttestation {
355 VerifiedAttestation {
356 subject_digest_sha256: card_sha,
357 certificate_identity: "https://example.com/issuer".into(),
358 certificate_oidc_issuer: "https://token.example.com".into(),
359 rekor_log_index: 42,
360 rekor_inclusion_verified: true,
361 signed_at: SystemTime::UNIX_EPOCH,
362 }
363 }
364
365 #[test]
366 fn anchor_round_trips_unsigned() {
367 let card = good_card();
368 let bytes = match card.to_canonical_json() {
369 Ok(b) => b,
370 Err(e) => panic!("encode: {e}"),
371 };
372 let mut digest = [0u8; 32];
373 digest.copy_from_slice(&Sha256::digest(&bytes));
374 let att = good_attestation(digest);
375 let verified = VerifiedModelCard {
376 card: card.clone(),
377 attestation: att.clone(),
378 };
379 let anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
380 Ok(a) => a,
381 Err(e) => panic!("anchor: {e}"),
382 };
383 assert_eq!(anchor.schema_version, MODEL_CARD_ANCHOR_SCHEMA);
384 assert!(matches!(
385 anchor.signing,
386 SigningState::UnsignedSoftDepAbsent
387 ));
388 match verify_model_card_anchor(&anchor, &bytes, &att) {
389 Ok(()) => {}
390 Err(e) => panic!("verify: {e}"),
391 }
392 }
393
394 #[test]
395 fn anchor_records_signer_hint() {
396 let card = good_card();
397 let bytes = match card.to_canonical_json() {
398 Ok(b) => b,
399 Err(e) => panic!("encode: {e}"),
400 };
401 let mut digest = [0u8; 32];
402 digest.copy_from_slice(&Sha256::digest(&bytes));
403 let att = good_attestation(digest);
404 let verified = VerifiedModelCard {
405 card,
406 attestation: att,
407 };
408 let anchor = match anchor_model_card(
409 &verified,
410 &bytes,
411 "chio.lineage.graph/v1",
412 Some("hybrid:ed25519+ml-dsa-65"),
413 ) {
414 Ok(a) => a,
415 Err(e) => panic!("anchor: {e}"),
416 };
417 assert!(matches!(
422 anchor.signing,
423 SigningState::UnsignedSignerStubbed { ref algorithm }
424 if algorithm == "hybrid:ed25519+ml-dsa-65"
425 ));
426 assert!(!anchor.is_signed());
427 }
428
429 #[test]
430 fn anchor_signed_state_with_malformed_payload_is_unsigned() {
431 let card = good_card();
432 let bytes = match card.to_canonical_json() {
433 Ok(b) => b,
434 Err(e) => panic!("encode: {e}"),
435 };
436 let mut digest = [0u8; 32];
437 digest.copy_from_slice(&Sha256::digest(&bytes));
438 let att = good_attestation(digest);
439 let verified = VerifiedModelCard {
440 card,
441 attestation: att,
442 };
443 let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
444 Ok(a) => a,
445 Err(e) => panic!("anchor: {e}"),
446 };
447
448 for signature_hex in ["DEADBEEF", "dead beef", " deadbeef", "zz", "f"] {
449 anchor.signing = SigningState::Signed {
450 algorithm: "hybrid:ed25519+ml-dsa-65".to_string(),
451 signature_hex: signature_hex.to_string(),
452 };
453 assert!(
454 !anchor.is_signed(),
455 "malformed signature payload {signature_hex:?} must be unsigned"
456 );
457 }
458 }
459
460 #[test]
461 fn anchor_rejects_stale_bytes() {
462 let card = good_card();
463 let bytes = match card.to_canonical_json() {
464 Ok(b) => b,
465 Err(e) => panic!("encode: {e}"),
466 };
467 let now = fixed_now();
469 let other = match ModelCard::new(
470 "0000000000000000000000000000000000000000000000000000000000000002",
471 StringSet::default(),
472 StringSet::default(),
473 "public-internet",
474 "https://example.com/issuer",
475 now,
476 now + chrono::Duration::days(1),
477 ) {
478 Ok(c) => c,
479 Err(e) => panic!("other card: {e}"),
480 };
481 let other_bytes = match other.to_canonical_json() {
482 Ok(b) => b,
483 Err(e) => panic!("encode: {e}"),
484 };
485 let mut digest = [0u8; 32];
486 digest.copy_from_slice(&Sha256::digest(&bytes));
487 let att = good_attestation(digest);
488 let verified = VerifiedModelCard {
489 card,
490 attestation: att,
491 };
492 let res = anchor_model_card(&verified, &other_bytes, "chio.lineage.graph/v1", None);
493 assert!(matches!(res, Err(WeightsError::Encoding(_))));
494 }
495
496 #[test]
497 fn verify_rejects_tampered_digest() {
498 let card = good_card();
499 let bytes = match card.to_canonical_json() {
500 Ok(b) => b,
501 Err(e) => panic!("encode: {e}"),
502 };
503 let mut digest = [0u8; 32];
504 digest.copy_from_slice(&Sha256::digest(&bytes));
505 let att = good_attestation(digest);
506 let verified = VerifiedModelCard {
507 card,
508 attestation: att.clone(),
509 };
510 let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
511 Ok(a) => a,
512 Err(e) => panic!("anchor: {e}"),
513 };
514 anchor.digest.hex = "0".repeat(64);
515 let res = verify_model_card_anchor(&anchor, &bytes, &att);
516 assert!(matches!(res, Err(WeightsError::BundleRejected(_))));
517 }
518
519 #[test]
520 fn verify_rejects_tampered_attestation_metadata() {
521 let card = good_card();
522 let bytes = match card.to_canonical_json() {
523 Ok(b) => b,
524 Err(e) => panic!("encode: {e}"),
525 };
526 let mut digest = [0u8; 32];
527 digest.copy_from_slice(&Sha256::digest(&bytes));
528 let att = good_attestation(digest);
529 let verified = VerifiedModelCard {
530 card,
531 attestation: att.clone(),
532 };
533 let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
534 Ok(a) => a,
535 Err(e) => panic!("anchor: {e}"),
536 };
537 anchor.certificate_identity = "https://example.com/forged".to_string();
538 let res = verify_model_card_anchor(&anchor, &bytes, &att);
539 assert!(matches!(res, Err(WeightsError::BundleRejected(_))));
540 }
541
542 #[test]
543 fn verify_rejects_tampered_card_metadata() {
544 let card = good_card();
545 let bytes = match card.to_canonical_json() {
546 Ok(b) => b,
547 Err(e) => panic!("encode: {e}"),
548 };
549 let mut digest = [0u8; 32];
550 digest.copy_from_slice(&Sha256::digest(&bytes));
551 let att = good_attestation(digest);
552 let verified = VerifiedModelCard {
553 card,
554 attestation: att.clone(),
555 };
556 let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
557 Ok(a) => a,
558 Err(e) => panic!("anchor: {e}"),
559 };
560 anchor.card_issuer = "https://example.com/forged".to_string();
561 let res = verify_model_card_anchor(&anchor, &bytes, &att);
562 assert!(matches!(res, Err(WeightsError::BundleRejected(_))));
563 }
564
565 #[test]
566 fn verify_rejects_wrong_schema_version() {
567 let card = good_card();
568 let bytes = match card.to_canonical_json() {
569 Ok(b) => b,
570 Err(e) => panic!("encode: {e}"),
571 };
572 let mut digest = [0u8; 32];
573 digest.copy_from_slice(&Sha256::digest(&bytes));
574 let att = good_attestation(digest);
575 let verified = VerifiedModelCard {
576 card,
577 attestation: att.clone(),
578 };
579 let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
580 Ok(a) => a,
581 Err(e) => panic!("anchor: {e}"),
582 };
583 anchor.schema_version = "chio.weights.lineage-anchor/v999".to_string();
584 let res = verify_model_card_anchor(&anchor, &bytes, &att);
585 assert!(matches!(res, Err(WeightsError::SchemaRejected(_))));
586 }
587
588 #[test]
589 fn anchor_digest_is_deterministic_across_runs() {
590 let card = good_card();
591 let bytes = match card.to_canonical_json() {
592 Ok(b) => b,
593 Err(e) => panic!("encode: {e}"),
594 };
595 let mut digest = [0u8; 32];
596 digest.copy_from_slice(&Sha256::digest(&bytes));
597 let att = good_attestation(digest);
598 let a = match anchor_projection_bytes(&bytes, &att) {
599 Ok(bytes) => sha256_hex(&bytes),
600 Err(e) => panic!("projection A: {e}"),
601 };
602 let b = match anchor_projection_bytes(&bytes, &att) {
603 Ok(bytes) => sha256_hex(&bytes),
604 Err(e) => panic!("projection B: {e}"),
605 };
606 assert_eq!(a, b);
607 }
608}