Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`)
Expand Down
23 changes: 12 additions & 11 deletions demo/consumer-walkthrough.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 12 additions & 11 deletions demo/consumer-walkthrough.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,17 +153,18 @@ cat > "$root_catalog_path" <<EOF
"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",
Expand Down
14 changes: 8 additions & 6 deletions demo/oci-layout-walkthrough.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,15 @@ try {
"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"
}
}
}
]
}
]
}
Expand Down
14 changes: 8 additions & 6 deletions demo/oci-layout-walkthrough.sh
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,15 @@ cat > "$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"
}
}
}
]
}
]
}
Expand Down
18 changes: 10 additions & 8 deletions demo/trust-walkthrough.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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:"
Expand Down
25 changes: 13 additions & 12 deletions examples/root-catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
1 change: 1 addition & 0 deletions src/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
}),
entries: vec![],
signature: None,
additional_signatures: None,

Check failure on line 82 in src/cache/mod.rs

View workflow job for this annotation

GitHub Actions / build-lint-test

struct `AiCatalog` has no field named `additional_signatures`

Check failure on line 82 in src/cache/mod.rs

View workflow job for this annotation

GitHub Actions / coverage

struct `AiCatalog` has no field named `additional_signatures`
extensions: None,
extra_fields: Default::default(),
});
Expand Down
3 changes: 2 additions & 1 deletion src/commands/catalog_add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
updated_at: Some(chrono::Utc::now().to_rfc3339()),
extensions: Some(extensions),
publisher: None,
trust_manifest: None,
digest: None,

Check failure on line 57 in src/commands/catalog_add.rs

View workflow job for this annotation

GitHub Actions / build-lint-test

struct `CatalogEntry` has no field named `digest`

Check failure on line 57 in src/commands/catalog_add.rs

View workflow job for this annotation

GitHub Actions / coverage

struct `CatalogEntry` has no field named `digest`
trust_manifests: Vec::new(),

Check failure on line 58 in src/commands/catalog_add.rs

View workflow job for this annotation

GitHub Actions / build-lint-test

struct `CatalogEntry` has no field named `trust_manifests`

Check failure on line 58 in src/commands/catalog_add.rs

View workflow job for this annotation

GitHub Actions / coverage

struct `CatalogEntry` has no field named `trust_manifests`
extra_fields: Default::default(),
};
let mut registry = cache.read_registry()?;
Expand Down
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
host: None,
entries,
signature: None,
additional_signatures: None,

Check failure on line 44 in src/commands/mod.rs

View workflow job for this annotation

GitHub Actions / build-lint-test

struct `AiCatalog` has no field named `additional_signatures`

Check failure on line 44 in src/commands/mod.rs

View workflow job for this annotation

GitHub Actions / coverage

struct `AiCatalog` has no field named `additional_signatures`
extensions: None,
extra_fields: Default::default(),
}
Expand Down
3 changes: 2 additions & 1 deletion src/commands/oci_add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@
updated_at: Some(chrono::Utc::now().to_rfc3339()),
extensions: Some(extensions),
publisher: None,
trust_manifest: None,
digest: None,

Check failure on line 90 in src/commands/oci_add.rs

View workflow job for this annotation

GitHub Actions / build-lint-test

struct `CatalogEntry` has no field named `digest`

Check failure on line 90 in src/commands/oci_add.rs

View workflow job for this annotation

GitHub Actions / coverage

struct `CatalogEntry` has no field named `digest`
trust_manifests: Vec::new(),

Check failure on line 91 in src/commands/oci_add.rs

View workflow job for this annotation

GitHub Actions / build-lint-test

struct `CatalogEntry` has no field named `trust_manifests`

Check failure on line 91 in src/commands/oci_add.rs

View workflow job for this annotation

GitHub Actions / coverage

struct `CatalogEntry` has no field named `trust_manifests`
extra_fields: Default::default(),
};

Expand Down
20 changes: 8 additions & 12 deletions src/commands/pull.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@
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)?;
Expand All @@ -182,18 +182,14 @@
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<bool> {
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<bool> {
let Some(digest) = entry.digest.as_deref() else {

Check failure on line 192 in src/commands/pull.rs

View workflow job for this annotation

GitHub Actions / build-lint-test

no field `digest` on type `&CatalogEntry`

Check failure on line 192 in src/commands/pull.rs

View workflow job for this annotation

GitHub Actions / coverage

no field `digest` on type `&CatalogEntry`
return Ok(false);
};
let canonical;
Expand All @@ -207,14 +203,14 @@
(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
))),
}
Expand Down
15 changes: 11 additions & 4 deletions src/commands/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,17 @@
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 {

Check failure on line 124 in src/commands/show.rs

View workflow job for this annotation

GitHub Actions / build-lint-test

no field `digest` on type `&CatalogEntry`

Check failure on line 124 in src/commands/show.rs

View workflow job for this annotation

GitHub Actions / coverage

no field `digest` on type `&CatalogEntry`
println!(" {} {}", "Digest:".bold(), digest);
}
for trust in &entry.trust_manifests {

Check failure on line 127 in src/commands/show.rs

View workflow job for this annotation

GitHub Actions / build-lint-test

no field `trust_manifests` on type `&CatalogEntry`

Check failure on line 127 in src/commands/show.rs

View workflow job for this annotation

GitHub Actions / coverage

no field `trust_manifests` on type `&CatalogEntry`
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!(
Expand Down
24 changes: 14 additions & 10 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -677,11 +677,11 @@
.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

Check failure on line 682 in src/lib.rs

View workflow job for this annotation

GitHub Actions / build-lint-test

no field `trust_manifests` on type `&CatalogEntry`

Check failure on line 682 in src/lib.rs

View workflow job for this annotation

GitHub Actions / coverage

no field `trust_manifests` on type `&CatalogEntry`
.iter()
.map(|trust_manifest| (descriptor.digest.clone(), trust_manifest.clone()))
})
.collect::<Vec<_>>();

Expand All @@ -704,7 +704,7 @@
attach_cosign_verification_artifacts(
artifacts,
&subject_digest,
&trust_manifest.identity,
&trust_manifest.contributor,
&payload_digest,
&signature,
&public_key,
Expand All @@ -715,8 +715,8 @@
Ok(catalog
.entries
.iter()
.filter(|entry| entry.trust_manifest.is_some())
.count())
.map(|entry| entry.trust_manifests.len())
.sum())
}

fn load_cosign_public_key(
Expand Down Expand Up @@ -1249,9 +1249,13 @@
"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"
}
}]
}
]
}"#;
Expand Down
13 changes: 3 additions & 10 deletions tests/cli_binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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://") {
Expand Down Expand Up @@ -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}");
}
Loading