diff --git a/README.md b/README.md index 279d6be..68cc22c 100644 --- a/README.md +++ b/README.md @@ -282,8 +282,8 @@ stdout. Omitting `--output` writes to the current directory. `--scope` follows the same name-or-URI rules as `show`. -If the entry's trust manifest has a `subject`, the content is checked against -`subject.digest` first, and a mismatch fails the pull. Signatures are not verified. +If the entry has a `digest`, the content is checked against it first, and a +mismatch fails the pull. Signatures are not verified. When the identifier resolves to a nested catalog entry (`application/ai-catalog+json`), `--media-type` is required: @@ -484,7 +484,7 @@ variants are restricted to OCI-sourced entries. See When `--cosign-key` is supplied to `oci export-layout` or `oci push`, the CLI: -1. Canonicalizes each entry trust manifest (key-sorted JSON, `signature` field stripped) +1. Canonicalizes each entry trust manifest (key-sorted JSON, `signature.jws` and `additionalSignatures` stripped) 2. Signs the canonical blob with `cosign sign-blob` 3. Attaches three OCI referrer artifacts to each signed entry: - the canonical trust manifest (`application/vnd.ai-catalog.trust-manifest.v1+json`) diff --git a/demo/consumer-walkthrough.ps1 b/demo/consumer-walkthrough.ps1 index af8c1c3..773b327 100644 --- a/demo/consumer-walkthrough.ps1 +++ b/demo/consumer-walkthrough.ps1 @@ -127,17 +127,18 @@ try { "tags": ["agent", "chat", "nlp", "a2a"], "url": "https://example.com/agents/conversational-v1.json", "version": "1.0.0", - "trustManifest": { - "identity": "urn:demo:agent:conversational-v1", - "identityType": "urn", - "attestations": [ - { - "type": "SOC2-Type2", - "uri": "https://trust.example.com/reports/soc2-2026.pdf", - "digest": "sha256:a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890" - } - ] - } + "trustManifests": [ + { + "contributor": "urn:demo:agent:conversational-v1", + "attestations": [ + { + "type": "SOC2-Type2", + "uri": "https://trust.example.com/reports/soc2-2026.pdf", + "digest": "sha256:a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890" + } + ] + } + ] }, { "identifier": "urn:demo:model:embeddings-v2", diff --git a/demo/consumer-walkthrough.sh b/demo/consumer-walkthrough.sh index 394e1bd..208d16e 100755 --- a/demo/consumer-walkthrough.sh +++ b/demo/consumer-walkthrough.sh @@ -153,17 +153,18 @@ cat > "$root_catalog_path" < "$catalog_json" <<'EOF' "name": "inline", "version": 1 }, - "trustManifest": { - "identity": "urn:example:inline", - "trustSchema": { - "identifier": "urn:ai-catalog:trust-schema:minimal:v1", - "version": "1.0" + "trustManifests": [ + { + "contributor": "urn:example:inline", + "trustSchema": { + "identifier": "urn:ai-catalog:trust-schema:minimal:v1", + "version": "1.0" + } } - } + ] } ] } diff --git a/demo/trust-walkthrough.sh b/demo/trust-walkthrough.sh index b45d71d..be16eeb 100755 --- a/demo/trust-walkthrough.sh +++ b/demo/trust-walkthrough.sh @@ -133,13 +133,15 @@ cat > "$catalog_json" <<'EOF' "type": "application/a2a-agent-card+json", "description": "An A2A agent whose trust manifest will be signed in this walkthrough", "url": "https://example.com/agents/trusted-v1.json", - "trustManifest": { - "identity": "urn:demo:agent:trusted-v1", - "trustSchema": { - "identifier": "urn:ai-catalog:trust-schema:minimal:v1", - "version": "1.0" + "trustManifests": [ + { + "contributor": "urn:demo:agent:trusted-v1", + "trustSchema": { + "identifier": "urn:ai-catalog:trust-schema:minimal:v1", + "version": "1.0" + } } - } + ] }, { "identifier": "urn:demo:model:unsigned", @@ -213,7 +215,7 @@ tm_config_digest="$(manifest_config_digest "$tm_manifest_json")" payload_file="$tmp_root/canonical-trust-manifest.json" "$ORAS_BIN" blob fetch --oci-layout --output "$payload_file" "${layout_dir}@${tm_config_digest}" -echo "Canonical trust manifest (key-sorted JSON, no 'signature' field):" +echo "Canonical trust manifest (key-sorted JSON, no 'signature.jws' member):" cat "$payload_file" # ── Step 7 ───────────────────────────────────────────────────────────────── @@ -248,7 +250,7 @@ fi # ── Step 9 ──────────────────────────────────────────────────────────────── step "9. Demonstrate tamper detection — modify the payload and re-verify" tampered_file="$tmp_root/tampered-trust-manifest.json" -# Replace the identity value with a different string +# Replace the contributor value with a different string sed 's/urn:demo:agent:trusted-v1/urn:attacker:agent:evil/g' "$payload_file" > "$tampered_file" echo "Tampered payload:" diff --git a/examples/root-catalog.json b/examples/root-catalog.json index 04686e6..56b1707 100644 --- a/examples/root-catalog.json +++ b/examples/root-catalog.json @@ -90,18 +90,19 @@ "identifier": "did:web:acme-corp.com", "displayName": "Acme Corp" }, - "trustManifest": { - "identity": "did:web:acme-corp.com", - "identityType": "did", - "attestations": [ - { - "type": "SOC2-Type2", - "uri": "https://trust.acme-corp.com/reports/soc2-2026.pdf", - "digest": "sha256:a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890", - "size": 245760 - } - ] - }, + "trustManifests": [ + { + "contributor": "did:web:acme-corp.com", + "attestations": [ + { + "type": "SOC2-Type2", + "uri": "https://trust.acme-corp.com/reports/soc2-2026.pdf", + "digest": "sha256:a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890", + "size": 245760 + } + ] + } + ], "data": { "specVersion": "1.0", "entries": [ diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 90241b0..5979034 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -79,6 +79,7 @@ impl CacheManager { }), entries: vec![], signature: None, + additional_signatures: None, extensions: None, extra_fields: Default::default(), }); diff --git a/src/commands/catalog_add.rs b/src/commands/catalog_add.rs index d99b747..7e326e5 100644 --- a/src/commands/catalog_add.rs +++ b/src/commands/catalog_add.rs @@ -54,7 +54,8 @@ fn add(cache: &CacheManager, fetcher: &dyn Fetcher, name: &str, input: &str) -> updated_at: Some(chrono::Utc::now().to_rfc3339()), extensions: Some(extensions), publisher: None, - trust_manifest: None, + digest: None, + trust_manifests: Vec::new(), extra_fields: Default::default(), }; let mut registry = cache.read_registry()?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index aa85959..cec6946 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -41,6 +41,7 @@ pub(crate) fn catalog_of(entries: impl IntoIterator) -> AiC host: None, entries, signature: None, + additional_signatures: None, extensions: None, extra_fields: Default::default(), } diff --git a/src/commands/oci_add.rs b/src/commands/oci_add.rs index 7e35a0c..bb9e7af 100644 --- a/src/commands/oci_add.rs +++ b/src/commands/oci_add.rs @@ -87,7 +87,8 @@ pub fn execute(name: &str, layout_path: &str, ref_name: Option<&str>) -> Result< updated_at: Some(chrono::Utc::now().to_rfc3339()), extensions: Some(extensions), publisher: None, - trust_manifest: None, + digest: None, + trust_manifests: Vec::new(), extra_fields: Default::default(), }; diff --git a/src/commands/pull.rs b/src/commands/pull.rs index e0c05eb..ef88854 100644 --- a/src/commands/pull.rs +++ b/src/commands/pull.rs @@ -156,7 +156,7 @@ fn write_entry( entry.identifier ))); }; - let verified = verify_subject_digest(entry, served.as_deref())?; + let verified = verify_entry_digest(entry, served.as_deref())?; if output_path == Some("-") { std::io::stdout().write_all(&bytes)?; @@ -182,18 +182,14 @@ fn write_entry( Ok(()) } -/// Check the content against `subject.digest` of the entry's trust manifest. +/// Check the content against the entry's `digest`. /// /// For an artifact behind a `url` the digest covers the bytes as served /// (`served`); for inline `data` it covers the JCS form of the value. Returns /// whether there was a digest to check. This says nothing about who published /// the content: signatures are not verified. -fn verify_subject_digest(entry: &CatalogEntry, served: Option<&[u8]>) -> Result { - let Some(subject) = entry - .trust_manifest - .as_ref() - .and_then(|manifest| manifest.subject.as_ref()) - else { +fn verify_entry_digest(entry: &CatalogEntry, served: Option<&[u8]>) -> Result { + let Some(digest) = entry.digest.as_deref() else { return Ok(false); }; let canonical; @@ -207,14 +203,14 @@ fn verify_subject_digest(entry: &CatalogEntry, served: Option<&[u8]>) -> Result< (None, Some(bytes)) => bytes, (None, None) => return Ok(false), }; - match ai_catalog_trust::verify_digest(&subject.digest, content) { + match ai_catalog_trust::verify_digest(digest, content) { Ok(true) => Ok(true), Ok(false) => Err(Error::Other(format!( - "content of \"{}\" does not match its subject.digest {}", - entry.identifier, subject.digest + "content of \"{}\" does not match its digest {}", + entry.identifier, digest ))), Err(e) => Err(Error::Other(format!( - "cannot check subject.digest of \"{}\": {e}", + "cannot check digest of \"{}\": {e}", entry.identifier ))), } diff --git a/src/commands/show.rs b/src/commands/show.rs index 48a058f..e4ca182 100644 --- a/src/commands/show.rs +++ b/src/commands/show.rs @@ -121,10 +121,17 @@ fn print_entry_table(found: &ResolvedEntry, cache: &CacheManager) { publisher.identifier ); } - if let Some(trust) = &entry.trust_manifest { - println!(" {} {}", "Trust identity:".bold(), trust.identity); - if trust.signature.is_some() { - println!(" {} present", "Signature:".bold()); + if let Some(digest) = &entry.digest { + println!(" {} {}", "Digest:".bold(), digest); + } + for trust in &entry.trust_manifests { + println!(" {} {}", "Trust contributor:".bold(), trust.contributor); + if let Some(signature) = &trust.signature { + println!( + " {} present (profile {}, not verified)", + "Signature:".bold(), + signature.profile + ); } if !trust.attestations.is_empty() { println!( diff --git a/src/lib.rs b/src/lib.rs index 0d27641..bbf56bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -677,11 +677,11 @@ fn attach_cosign_material( .entries .iter() .zip(artifacts.index.manifests.iter()) - .filter_map(|(entry, descriptor)| { + .flat_map(|(entry, descriptor)| { entry - .trust_manifest - .clone() - .map(|trust_manifest| (descriptor.digest.clone(), trust_manifest)) + .trust_manifests + .iter() + .map(|trust_manifest| (descriptor.digest.clone(), trust_manifest.clone())) }) .collect::>(); @@ -704,7 +704,7 @@ fn attach_cosign_material( attach_cosign_verification_artifacts( artifacts, &subject_digest, - &trust_manifest.identity, + &trust_manifest.contributor, &payload_digest, &signature, &public_key, @@ -715,8 +715,8 @@ fn attach_cosign_material( Ok(catalog .entries .iter() - .filter(|entry| entry.trust_manifest.is_some()) - .count()) + .map(|entry| entry.trust_manifests.len()) + .sum()) } fn load_cosign_public_key( @@ -1249,9 +1249,13 @@ mod tests { "displayName": "Example Model", "type": "application/json", "data": { "name": "example" }, - "trustManifest": { - "identity": "urn:example:model" - } + "trustManifests": [{ + "contributor": "urn:example:model", + "trustSchema": { + "identifier": "urn:trust:example", + "version": "1.0" + } + }] } ] }"#; diff --git a/tests/cli_binary.rs b/tests/cli_binary.rs index 1981092..07a98f9 100644 --- a/tests/cli_binary.rs +++ b/tests/cli_binary.rs @@ -826,19 +826,12 @@ fn sha256(bytes: &[u8]) -> String { format!("sha256:{hex}") } -/// A catalog with one entry that carries a trust manifest bound to `digest`. +/// A catalog with one entry that declares `digest`. fn write_bound_catalog(dir: &Path, artifact: &str, digest: &str) -> String { let entry = serde_json::json!({ "identifier": "urn:test:bound", "type": "application/json", - "trustManifest": { - "identity": "did:web:example.com", - "subject": { - "identifier": "urn:test:bound", - "type": "application/json", - "digest": digest, - } - } + "digest": digest, }); let mut entry = entry; match artifact.strip_prefix("file://") { @@ -918,5 +911,5 @@ fn pull_refuses_a_weak_digest() { let (ok, _, stderr) = pull_bound(&catalog, &out); assert!(!ok); - assert!(stderr.contains("cannot check subject.digest"), "{stderr}"); + assert!(stderr.contains("cannot check digest"), "{stderr}"); }