use chio_lineage::anchor::{
is_lowercase_hex_signature_payload, CanonicalSource, FrontierDigest, SigningState,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::bundle::VerifiedModelCard;
use crate::error::WeightsError;
pub const MODEL_CARD_ANCHOR_SCHEMA: &str = "chio.weights.lineage-anchor/v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelCardLineageAnchor {
pub schema_version: String,
pub graph_schema: String,
pub canonical_source: CanonicalSource,
pub digest: FrontierDigest,
pub signing: SigningState,
pub card_canonical_sha256: String,
pub attestation_subject_sha256: String,
pub certificate_identity: String,
pub certificate_oidc_issuer: String,
pub rekor_log_index: u64,
pub rekor_inclusion_verified: bool,
pub weights_hash: String,
pub card_issuer: String,
pub card_expires_at: DateTime<Utc>,
}
impl ModelCardLineageAnchor {
#[must_use]
pub fn is_signed(&self) -> bool {
false
}
}
pub fn anchor_projection_bytes(
card_bytes: &[u8],
attestation: &chio_attest_verify::VerifiedAttestation,
) -> Result<Vec<u8>, WeightsError> {
let card_sha = sha256_hex(card_bytes);
let subject_sha = hex::encode(attestation.subject_digest_sha256);
let payload = serde_json::json!({
"card_canonical_sha256": card_sha,
"certificate_identity": attestation.certificate_identity,
"certificate_oidc_issuer": attestation.certificate_oidc_issuer,
"rekor_inclusion_verified": attestation.rekor_inclusion_verified,
"rekor_log_index": attestation.rekor_log_index,
"schema_version": MODEL_CARD_ANCHOR_SCHEMA,
"subject_digest_sha256": subject_sha,
});
chio_core_types::canonical::canonical_json_bytes(&payload)
.map_err(|err| WeightsError::Encoding(format!("anchor projection encode: {err}")))
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
hex::encode(digest)
}
pub fn anchor_model_card(
verified: &VerifiedModelCard,
card_bytes: &[u8],
graph_schema: &str,
signer_hint: Option<&str>,
) -> Result<ModelCardLineageAnchor, WeightsError> {
let decoded = crate::card::ModelCard::from_canonical_json(card_bytes)?;
if decoded != verified.card {
return Err(WeightsError::Encoding(
"card_bytes do not match verified.card; refusing to anchor stale bytes".to_string(),
));
}
let projection = anchor_projection_bytes(card_bytes, &verified.attestation)?;
let digest_hex = sha256_hex(&projection);
let signing = match signer_hint {
Some(algorithm) => SigningState::UnsignedSignerStubbed {
algorithm: algorithm.to_string(),
},
None => SigningState::UnsignedSoftDepAbsent,
};
Ok(ModelCardLineageAnchor {
schema_version: MODEL_CARD_ANCHOR_SCHEMA.to_string(),
graph_schema: graph_schema.to_string(),
canonical_source: CanonicalSource::EquivalenceShim,
digest: FrontierDigest {
algo: "sha256".to_string(),
hex: digest_hex,
},
signing,
card_canonical_sha256: sha256_hex(card_bytes),
attestation_subject_sha256: hex::encode(verified.attestation.subject_digest_sha256),
certificate_identity: verified.attestation.certificate_identity.clone(),
certificate_oidc_issuer: verified.attestation.certificate_oidc_issuer.clone(),
rekor_log_index: verified.attestation.rekor_log_index,
rekor_inclusion_verified: verified.attestation.rekor_inclusion_verified,
weights_hash: verified.card.weights_hash.clone(),
card_issuer: verified.card.issuer.clone(),
card_expires_at: verified.card.expires_at,
})
}
pub fn verify_model_card_anchor(
anchor: &ModelCardLineageAnchor,
card_bytes: &[u8],
attestation: &chio_attest_verify::VerifiedAttestation,
) -> Result<(), WeightsError> {
if anchor.schema_version != MODEL_CARD_ANCHOR_SCHEMA {
return Err(WeightsError::SchemaRejected(format!(
"model card anchor schema_version must be {MODEL_CARD_ANCHOR_SCHEMA:?}, got {:?}",
anchor.schema_version
)));
}
if anchor.digest.algo != "sha256" {
return Err(WeightsError::SchemaRejected(format!(
"model card anchor digest algo must be \"sha256\", got {:?}",
anchor.digest.algo
)));
}
let expected_hex = sha256_hex(&anchor_projection_bytes(card_bytes, attestation)?);
if expected_hex != anchor.digest.hex {
return Err(WeightsError::BundleRejected(format!(
"model card anchor digest mismatch: expected {expected_hex}, got {}",
anchor.digest.hex
)));
}
let card_sha = sha256_hex(card_bytes);
if card_sha != anchor.card_canonical_sha256 {
return Err(WeightsError::BundleRejected(format!(
"model card anchor card_canonical_sha256 mismatch: expected {card_sha}, got {}",
anchor.card_canonical_sha256
)));
}
let expected_subject_sha = hex::encode(attestation.subject_digest_sha256);
if expected_subject_sha != anchor.attestation_subject_sha256 {
return Err(WeightsError::BundleRejected(format!(
"model card anchor attestation_subject_sha256 mismatch: expected {expected_subject_sha}, got {}",
anchor.attestation_subject_sha256
)));
}
if attestation.certificate_identity != anchor.certificate_identity {
return Err(WeightsError::BundleRejected(format!(
"model card anchor certificate_identity mismatch: expected {:?}, got {:?}",
attestation.certificate_identity, anchor.certificate_identity
)));
}
if attestation.certificate_oidc_issuer != anchor.certificate_oidc_issuer {
return Err(WeightsError::BundleRejected(format!(
"model card anchor certificate_oidc_issuer mismatch: expected {:?}, got {:?}",
attestation.certificate_oidc_issuer, anchor.certificate_oidc_issuer
)));
}
if attestation.rekor_log_index != anchor.rekor_log_index {
return Err(WeightsError::BundleRejected(format!(
"model card anchor rekor_log_index mismatch: expected {}, got {}",
attestation.rekor_log_index, anchor.rekor_log_index
)));
}
if attestation.rekor_inclusion_verified != anchor.rekor_inclusion_verified {
return Err(WeightsError::BundleRejected(format!(
"model card anchor rekor_inclusion_verified mismatch: expected {}, got {}",
attestation.rekor_inclusion_verified, anchor.rekor_inclusion_verified
)));
}
if let SigningState::Signed {
algorithm,
signature_hex,
} = &anchor.signing
{
if !is_lowercase_hex_signature_payload(signature_hex) {
return Err(WeightsError::BundleRejected(
"model card anchor signing state was Signed but signature_hex was empty or not lower-case hexadecimal"
.to_string(),
));
}
return Err(WeightsError::BundleRejected(format!(
"model card anchor signing algorithm {algorithm:?} is not verified by this build"
)));
}
let card = crate::card::ModelCard::from_canonical_json(card_bytes)?;
if card.weights_hash != anchor.weights_hash {
return Err(WeightsError::BundleRejected(format!(
"model card anchor weights_hash mismatch: expected {:?}, got {:?}",
card.weights_hash, anchor.weights_hash
)));
}
if card.issuer != anchor.card_issuer {
return Err(WeightsError::BundleRejected(format!(
"model card anchor card_issuer mismatch: expected {:?}, got {:?}",
card.issuer, anchor.card_issuer
)));
}
if card.expires_at != anchor.card_expires_at {
return Err(WeightsError::BundleRejected(format!(
"model card anchor card_expires_at mismatch: expected {}, got {}",
card.expires_at, anchor.card_expires_at
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::SystemTime;
use chio_attest_verify::VerifiedAttestation;
use chrono::TimeZone;
use crate::card::{ModelCard, StringSet};
fn fixed_now() -> DateTime<Utc> {
match Utc.with_ymd_and_hms(2026, 4, 30, 12, 0, 0) {
chrono::LocalResult::Single(t) => t,
_ => panic!("fixed_now fixture must construct"),
}
}
fn good_card() -> ModelCard {
let now = fixed_now();
match ModelCard::new(
"0000000000000000000000000000000000000000000000000000000000000001",
StringSet::new(["tool:read"]),
StringSet::default(),
"public-internet",
"https://example.com/issuer",
now,
now + chrono::Duration::days(30),
) {
Ok(c) => c,
Err(e) => panic!("good_card must construct: {e}"),
}
}
fn good_attestation(card_sha: [u8; 32]) -> VerifiedAttestation {
VerifiedAttestation {
subject_digest_sha256: card_sha,
certificate_identity: "https://example.com/issuer".into(),
certificate_oidc_issuer: "https://token.example.com".into(),
rekor_log_index: 42,
rekor_inclusion_verified: true,
signed_at: SystemTime::UNIX_EPOCH,
}
}
#[test]
fn anchor_round_trips_unsigned() {
let card = good_card();
let bytes = match card.to_canonical_json() {
Ok(b) => b,
Err(e) => panic!("encode: {e}"),
};
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(&bytes));
let att = good_attestation(digest);
let verified = VerifiedModelCard {
card: card.clone(),
attestation: att.clone(),
};
let anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
Ok(a) => a,
Err(e) => panic!("anchor: {e}"),
};
assert_eq!(anchor.schema_version, MODEL_CARD_ANCHOR_SCHEMA);
assert!(matches!(
anchor.signing,
SigningState::UnsignedSoftDepAbsent
));
match verify_model_card_anchor(&anchor, &bytes, &att) {
Ok(()) => {}
Err(e) => panic!("verify: {e}"),
}
}
#[test]
fn anchor_records_signer_hint() {
let card = good_card();
let bytes = match card.to_canonical_json() {
Ok(b) => b,
Err(e) => panic!("encode: {e}"),
};
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(&bytes));
let att = good_attestation(digest);
let verified = VerifiedModelCard {
card,
attestation: att,
};
let anchor = match anchor_model_card(
&verified,
&bytes,
"chio.lineage.graph/v1",
Some("hybrid:ed25519+ml-dsa-65"),
) {
Ok(a) => a,
Err(e) => panic!("anchor: {e}"),
};
assert!(matches!(
anchor.signing,
SigningState::UnsignedSignerStubbed { ref algorithm }
if algorithm == "hybrid:ed25519+ml-dsa-65"
));
assert!(!anchor.is_signed());
}
#[test]
fn anchor_signed_state_with_malformed_payload_is_unsigned() {
let card = good_card();
let bytes = match card.to_canonical_json() {
Ok(b) => b,
Err(e) => panic!("encode: {e}"),
};
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(&bytes));
let att = good_attestation(digest);
let verified = VerifiedModelCard {
card,
attestation: att,
};
let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
Ok(a) => a,
Err(e) => panic!("anchor: {e}"),
};
for signature_hex in ["DEADBEEF", "dead beef", " deadbeef", "zz", "f"] {
anchor.signing = SigningState::Signed {
algorithm: "hybrid:ed25519+ml-dsa-65".to_string(),
signature_hex: signature_hex.to_string(),
};
assert!(
!anchor.is_signed(),
"malformed signature payload {signature_hex:?} must be unsigned"
);
}
}
#[test]
fn anchor_rejects_stale_bytes() {
let card = good_card();
let bytes = match card.to_canonical_json() {
Ok(b) => b,
Err(e) => panic!("encode: {e}"),
};
let now = fixed_now();
let other = match ModelCard::new(
"0000000000000000000000000000000000000000000000000000000000000002",
StringSet::default(),
StringSet::default(),
"public-internet",
"https://example.com/issuer",
now,
now + chrono::Duration::days(1),
) {
Ok(c) => c,
Err(e) => panic!("other card: {e}"),
};
let other_bytes = match other.to_canonical_json() {
Ok(b) => b,
Err(e) => panic!("encode: {e}"),
};
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(&bytes));
let att = good_attestation(digest);
let verified = VerifiedModelCard {
card,
attestation: att,
};
let res = anchor_model_card(&verified, &other_bytes, "chio.lineage.graph/v1", None);
assert!(matches!(res, Err(WeightsError::Encoding(_))));
}
#[test]
fn verify_rejects_tampered_digest() {
let card = good_card();
let bytes = match card.to_canonical_json() {
Ok(b) => b,
Err(e) => panic!("encode: {e}"),
};
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(&bytes));
let att = good_attestation(digest);
let verified = VerifiedModelCard {
card,
attestation: att.clone(),
};
let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
Ok(a) => a,
Err(e) => panic!("anchor: {e}"),
};
anchor.digest.hex = "0".repeat(64);
let res = verify_model_card_anchor(&anchor, &bytes, &att);
assert!(matches!(res, Err(WeightsError::BundleRejected(_))));
}
#[test]
fn verify_rejects_tampered_attestation_metadata() {
let card = good_card();
let bytes = match card.to_canonical_json() {
Ok(b) => b,
Err(e) => panic!("encode: {e}"),
};
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(&bytes));
let att = good_attestation(digest);
let verified = VerifiedModelCard {
card,
attestation: att.clone(),
};
let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
Ok(a) => a,
Err(e) => panic!("anchor: {e}"),
};
anchor.certificate_identity = "https://example.com/forged".to_string();
let res = verify_model_card_anchor(&anchor, &bytes, &att);
assert!(matches!(res, Err(WeightsError::BundleRejected(_))));
}
#[test]
fn verify_rejects_tampered_card_metadata() {
let card = good_card();
let bytes = match card.to_canonical_json() {
Ok(b) => b,
Err(e) => panic!("encode: {e}"),
};
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(&bytes));
let att = good_attestation(digest);
let verified = VerifiedModelCard {
card,
attestation: att.clone(),
};
let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
Ok(a) => a,
Err(e) => panic!("anchor: {e}"),
};
anchor.card_issuer = "https://example.com/forged".to_string();
let res = verify_model_card_anchor(&anchor, &bytes, &att);
assert!(matches!(res, Err(WeightsError::BundleRejected(_))));
}
#[test]
fn verify_rejects_wrong_schema_version() {
let card = good_card();
let bytes = match card.to_canonical_json() {
Ok(b) => b,
Err(e) => panic!("encode: {e}"),
};
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(&bytes));
let att = good_attestation(digest);
let verified = VerifiedModelCard {
card,
attestation: att.clone(),
};
let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
Ok(a) => a,
Err(e) => panic!("anchor: {e}"),
};
anchor.schema_version = "chio.weights.lineage-anchor/v999".to_string();
let res = verify_model_card_anchor(&anchor, &bytes, &att);
assert!(matches!(res, Err(WeightsError::SchemaRejected(_))));
}
#[test]
fn anchor_digest_is_deterministic_across_runs() {
let card = good_card();
let bytes = match card.to_canonical_json() {
Ok(b) => b,
Err(e) => panic!("encode: {e}"),
};
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(&bytes));
let att = good_attestation(digest);
let a = match anchor_projection_bytes(&bytes, &att) {
Ok(bytes) => sha256_hex(&bytes),
Err(e) => panic!("projection A: {e}"),
};
let b = match anchor_projection_bytes(&bytes, &att) {
Ok(bytes) => sha256_hex(&bytes),
Err(e) => panic!("projection B: {e}"),
};
assert_eq!(a, b);
}
}