Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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