Repository navigation
Support independently signed Trust Manifest contributions - #117
jonathanhefner wants to merge 3 commits into
Conversation
| | Existing location | Proposed location | | ||
| | --- | --- | | ||
| | `trustManifest.identity` | Key in `entry.trustManifests` | | ||
| | `trustManifest.identity` | Contributor key in `entry.trustManifests`; endorsement identity in `entry.signatures[].signer` | |
There was a problem hiding this comment.
One gap: a future signer profile could accept the same kind of signer value as did:web.
A did:web:acme.com:... identity and the plain URL https://acme.com/... refer to the same underlying resource. So imagine a second profile gets added later — say, a plain JWKS-over-HTTPS profile that just fetches a key list from a URL, no extra authorization check. If a verifier can't tell the two profiles apart from the signer value alone, it might apply the JWKS profile's rules to a signer that was actually meant to go through did:web. That matters because the JWKS profile would accept any key found at that URL, while did:web only accepts a key the DID document specifically lists under assertionMethod. Applying the JWKS profile by mistake means skipping that check entirely, not just using a different lookup mechanism.
To fix that, add an explicit profile field to the Signature object, signed the same way signer is today. Same paths/issuedAt/jws shape either way — only signer and profile differ:
ex:
{
"signer": "did:web:acme-corp.com:agent:finance",
"profile": "did:web-v1",
"paths": [["identifier"], ["type"], ["digest"], ["trustManifests", "did:web:acme-corp.com:agent:finance"]],
"issuedAt": "2026-09-13T00:00:00Z",
"jws": "..."
}
{
"signer": "https://acme.com/certs/jwks.json",
"profile": "https-jwks-v1",
"paths": [["identifier"], ["type"], ["digest"], ["trustManifests", "https://acme.com/certs/jwks.json"]],
"issuedAt": "2026-09-13T00:00:00Z",
"jws": "..."
}
A verifier picks the profile by reading profile directly, not by guessing from what signer looks like — and since it's part of the signed payload, it can't be changed after the fact. Small addition today, much harder to bolt on safely once a second profile actually exists.
|
I would like to share how I would use this to also include SPIRE/SPIFFE not as workload identity manager but as a signing identity: spiffe-x509-v1 profile could work like this: signer = the SPIFFE ID (spiffe://acme.com/ns/finance/sa/finance-agent). Verification fetches the trust domain's bundle endpoint (live, same as did:web resolves a DID document live) to get the current root/intermediate CAs — this part rotates rarely, same cadence as a DID document. The actual short-lived leaf SVID that did the signing doesn't need re-resolving at all — it rides along in the JWS's own protected header via the standard x5c parameter (RFC 7515's mechanism for embedding a certificate chain directly in a JWS), so no new Signature-object field is needed. Verification checks: |
|
did:web-v1 https-jwks-v1 spiffe-x509-v1 |
|
@muscariello Thank you for the review! I've pushed 51df07b, which adds a |
| ] | ||
| } | ||
| }, | ||
| "signatures": [ |
There was a problem hiding this comment.
The idea of grouping claims by contributor (trustManifests) is great for downstream evaluations. However, moving signatures to a top-level entry.signatures: [...] array with 2D JSON path selectors introduces substantial verification fragility, custom path-walking parsers across all SDKs, and breaks the 'self-contained envelope' principle.
Keeping signature and subject inside each trustManifest keeps manifests transport-portable, and avoids custom JSON path-walking engines."
There was a problem hiding this comment.
Path selectors allow targeted signing. Without that we would need to:
- Duplicate entry data in the
subjectfield - Choose exactly what is allowed in the
subject-- e.g., isextensionsallowed? - Remember to allow new fields in the
subject-- e.g.,versionwas omitted before Bind signed Trust Manifests to release coordinates #108
custom JSON path-walking engines
I feel like that sounds a lot more complex than it actually is. 😅 Here is an example implementation:
function buildPayload(paths: string[][], entry: JsonObject) {
return paths
.toSorted(comparePaths)
.map(path => [path, extractValue(path, entry)] as const);
}
function comparePaths(a: string[], b: string[]): number {
for (let i = 0; i < Math.min(a.length, b.length); i++) {
if (a[i] < b[i]) return -1;
if (a[i] > b[i]) return 1;
}
return a.length - b.length;
}
function extractValue(path: string[], entry: JsonObject): JsonValue {
if (path[0] === "signatures") {
throw new Error("Selecting top-level signatures is forbidden");
}
let value: JsonValue = entry;
for (const key of path) {
if (!isJsonObject(value) || !Object.hasOwn(value, key)) {
throw new Error(`Cannot resolve path: ${JSON.stringify(path)}`);
}
value = value[key];
}
return value;
}
function isJsonObject(value: JsonValue): value is JsonObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}If we want to simplify even further, we can drop the sorting requirement and allow signatures to be included in a path (though no one should want to).
If you're strongly opposed to selector paths, though, I can explore less generic approaches.
There was a problem hiding this comment.
An example of a less general approach would be to replace paths with:
- implied
entry.identifier,entry.type,entry.versionif present,entry.digestif present - optional
signatures[].additionalSubjectFieldsarray of entry fields to include in an ephemeral (generated) subject - optional
signatures[].subjectExtensionsarray of extension keys to include in an ephemeral subject - optional
signatures[].subjectTrustManifestsarray of Trust Manifest contributor identities to include in an ephemeral subject
Of course, names can be tweaked -- e.g., signatures[].additionalEntryFields or signatures[].subjectDefinition.entryFields or whatever.
(By the way, even if we decide to keep paths, we can make the inclusion of entry.identifier et al implied.)
I still favor keeping signatures as a peer of trustManifests though. Putting signatures inside a Trust Manifest makes it a bit more awkward to generate the payload to sign (because signatures would have to be removed from each Trust Manifest before signing).
There was a problem hiding this comment.
I agree with @jonathanhefner that getting signatures in the payload is ugly.
There was a problem hiding this comment.
If we really want all trust-related data contained in a single field, I would recommend to:
- rename
trustManifeststotrustContributionsor justcontributions - make a newly defined Trust Manifest object (
entry.trustManifest) that would containcontributionsandsignatures - possibly move
privacyPolicyUrlandtermsOfServiceUrlfrom directly underentryback into individualcontributionsobjects- this would partly be based on who we expect to contribute these values, but also partly based on how those fields are referenced to build an ephemeral subject
- keep
digestdirectly underentry
Altogether, an entry could look like:
{
"identifier": "urn:air:acme.com:agent:finance-a2a",
"type": "application/a2a-agent-card+json",
"version": "2.1.0",
"digest": "sha256:...",
"url": "..."
"publisher": {
"identifier": "did:web:acme.com",
"displayName": "Acme Financial Corp"
},
"extensions": {
"https://ai-catalog.org/extensions/metadata": {
"foo": "bar",
}
},
"trustManifest": {
"contributions": {
"did:web:acme.com": {
"privacyPolicyUrl": "https://acme.com/legal/privacy",
"termsOfServiceUrl": "https://acme.com/legal/terms",
"attestations": [
{
"type": "SOC2-Type2",
"uri": "https://trust.acme.com/reports/soc2.pdf",
"digest": "sha256:..."
}
]
}
},
"signatures": [
{
"signer": "did:web:acme.com",
"profile": "did-web-v1",
"subjectAdditionalFields": ["url"],
"subjectExtensions": ["https://ai-catalog.org/extensions/metadata"],
"subjectTrustContributions": ["did:web:acme.com"],
"issuedAt": "2026-03-20T14:00:00Z",
"jws": "..."
}
]
}
}There was a problem hiding this comment.
Regarding the latest splitting trustManfiest into trustContribution and signatures:
The good:
- Moving away from arbitrary 2D JSON paths (
paths: string[][]). - Establishing
identifier,type,version, anddigestas non-negotiable core fields. - Consolidating everything under
entry.trustManifestwithcontributionsandsignatures(Comment Welcome to ai-card Discussions! #2). This brings much-needed structural cohesion to the catalog entry.
However, the idea of an ephemeral (dynamically synthesized) subject via subjectAdditionalFields, subjectExtensions, and subjectTrustContributions still introduces serious security and interoperability issues: (similar to the 2D json path)
- The Selective Signing / Truncation Vulnerability (Confused Deputy)
If a signer only binds to an ephemeral subset of fields:
- What prevents an untrusted intermediary or rogue registry from modifying unselected fields (e.g. altering
publisher.displayName, changingurl, or injecting an unauthorized extension)? - The signature over the ephemeral subject would still verify as 100% valid! A consumer checking the signature would see a valid signature from the publisher and falsely assume the entire entry is authentic.
- Verifier Policy and Cross-Language Drift
In this model, every signature insignaturescan have a different recipe for what it chose to include or omit. Verifiers in Python, Go, Rust, and TypeScript would not only have to implement complex plucking-and-reassembly logic, but consuming applications would have to inspect each signature's recipe to determine if critical fields were left unsigned.
There was a problem hiding this comment.
Basically, with path selectors and ephemeral subjects, we are trying to re-invent an ad-hoc JSON path-walking engine.
As discussed earlier, while the TypeScript prototype snippet looks deceptively simple, from a security and standards engineering standpoint, it introduces critical vulnerabilities.
The latest code example is largely moving in the right direction, except that instead of manual field selection, we should sign the whole entry and the whole contribution respectively.
It would look like this:
{
"identifier": "urn:air:acme.com:agent:finance-a2a",
"version": "2.1.0",
"digest": "sha256:7f83b1657ff...",
"url": "https://agents.acme.com/finance",
"publisher": { "identifier": "did:web:acme.com" },
"extensions": { "foo": "bar" },
"signature": {
"signer": "did:web:acme.com",
"jws": "<publisher-signature-over-entry>"
},
"trustManifests": [
{
"contributor": "did:web:acme.com",
"subject": { "digest": "sha256:7f83b1657ff..." },
"privacyPolicyUrl": "https://acme.com/legal/privacy",
"attestations": [ ... ],
"signature": {
"signer": "did:web:acme.com",
"jws": "<signature-over-acme-manifest>"
}
},
{
"contributor": "did:web:cisco.com",
"subject": { "digest": "sha256:7f83b1657ff..." },
"attestations": [
{
"type": "SecurityPostureAudit",
"score": "A+"
}
],
"signature": {
"signer": "did:web:cisco.com",
"jws": "<signature-over-cisco-manifest>"
}
}
]
}There was a problem hiding this comment.
1. Selective Signing / Truncation Vulnerability:
When a signature only commits to specific selected paths (e.g.[["identifier"], ["version"]]), any untrusted registry or MITM can inject arbitrary unselected fields (e.g. maliciousendpointsorpermissions) into the entry, and the signature will still validate cleanly. The verifier has no cryptographic guarantee over what was deliberately omitted versus what was maliciously injected.
1. The Selective Signing / Truncation Vulnerability (Confused Deputy)
If a signer only binds to an ephemeral subset of fields:
- What prevents an untrusted intermediary or rogue registry from modifying unselected fields (e.g. altering
publisher.displayName, changingurl, or injecting an unauthorized extension)?- The signature over the ephemeral subject would still verify as 100% valid! A consumer checking the signature would see a valid signature from the publisher and falsely assume the entire entry is authentic.
That also applies to what we have now, no? Isn't that just the nature of (intentionally) signing a subset of values?
2. Cross-Language Canonicalization & Interop (RFC 8785):
Converting an in-memory tuple list[[path, value], ...]into deterministic, byte-for-byte signing bytes requires strict canonicalization across all SDKs (Python, Go, Rust, TypeScript). Array indexing, missing keys, and path overlap handling will create spec divergence and parsing vulnerabilities.
I'm not sure I understand the point this is trying to make.
The same applies to JSON objects (perhaps even moreso). We also already have arrays that we are signing, such as attestations.
3. The Role of
subject(Cryptographic Digest vs. Data Duplication):
Jonathan raised valid concerns about needing to duplicate entry fields insubjector constantly updating thesubjectschema when new fields (likeversionorextensions) are added.
In established supply-chain standards (such as in-toto Statements and SLSA), thesubjectis never a duplicate copy of entry fields. It is simply an immutable cryptographic digest
Producing a an immutable cryptographic digest requires an ephemeral structure to hash...
If we're going define and materialize an ephemeral structure such that it could be hashed, why not just include the structure directly in the signing payload?
In any case, what is the definition of that structure?
2. Verifier Policy and Cross-Language Drift
In this model, every signature insignaturescan have a different recipe for what it chose to include or omit. Verifiers in Python, Go, Rust, and TypeScript would not only have to implement complex plucking-and-reassembly logic, but consuming applications would have to inspect each signature's recipe to determine if critical fields were left unsigned.
Yes, that is by design. Different signers may sign different things -- e.g., signing the URL vs not, signing different trust manifests, signing different extensions.
The latest code example is largely moving in the right direction, except that instead of manual field selection, we should sign the whole entry and the whole contribution respectively.
It would look like this:
@mindpower Signing the whole entry means extensions cannot be added and url cannot be unsigned / changed for mirrors.
Therefore, I think you're saying "sign a slice of the entry". In which case, what is the slice and do we allow variations from it (such as additional fields)?
There was a problem hiding this comment.
That also applies to what we have now, no? Isn't that just the nature of (intentionally) signing a subset of values?
That's different. The publisher simply signs the entry data (the entry object excluding signature and trustManifests). When a publisher publishes an entry, they stand behind that complete definition. Allowing arbitrary slicing is what creates confused-deputy risks.
Different signers may sign different things
Agreed, but they should sign different self-contained objects (Acme signs the entry; Cisco signs Cisco’s contribution), not arbitrary slices. Also url should not be mutable. If an intermediary or mirror can rewrite url without invalidating the publisher's signature, an attacker can redirect users to a malicious endpoint while keeping Acme’s valid signature.
There was a problem hiding this comment.
That's different. The publisher simply signs the entry data (the entry object excluding
signatureandtrustManifests). When a publisher publishes an entry, they stand behind that complete definition. Allowing arbitrary slicing is what creates confused-deputy risks.
To reiterate my clarification from the meeting: my follow-up proposal in #117 (comment) did not allow completely arbitrary slicing. All signatures would include the same core entry fields that the publisher signs.
Agreed, but they should sign different self-contained objects (Acme signs the entry; Cisco signs Cisco’s contribution), not arbitrary slices. Also
urlshould not be mutable. If an intermediary or mirror can rewriteurlwithout invalidating the publisher's signature, an attacker can redirect users to a malicious endpoint while keeping Acme’s valid signature.
Also reiterating from the meeting: entry.digest protects the artifact while allowing mirror URLs. The spec currently allows that even without this PR. If we want to disallow mirror URLs, it would be an additional behavioral change on top of what this PR is proposing.
However, I understand your preference for self-contained objects. During the meeting, @muscariello also made the point that when there are multiple signers, they often sign the exact same (immutable) object.
Taking all of the above into account, I think the following structure is closer to what you're asking for:
{
"identifier": "urn:air:acme.com:agent:finance-a2a",
"type": "application/a2a-agent-card+json",
"version": "2.1.0",
"digest": "sha256:...",
"url": "...",
"publisher": {
"identifier": "did:web:acme.com",
"displayName": "Acme Financial Corp"
},
"extensions": {
"https://ai-catalog.org/extensions/metadata": {
"foo": "bar",
}
},
"trustManifests": [
{
"contributor": "did:web:acme.com",
"privacyPolicyUrl": "https://acme.com/legal/privacy",
"termsOfServiceUrl": "https://acme.com/legal/terms",
"attestations": [
{
"type": "SOC2-Type2",
"uri": "https://trust.acme.com/reports/soc2.pdf",
"digest": "sha256:..."
}
],
// `canonicalUrl` would be semantically similar to `entry.url` but not
// required to exactly match. It would merely communicate the artifact
// URL that the Trust Manifest contributor endorses.
//
// **THIS FIELD COULD BE DEFINED IN V1.1 OF THE SPEC**
"canonicalUrl": "...",
// `supportedExtensions` lists the extensions (by key) that the Trust
// Manifest contributor supports. If those extensions are present in the
// entry, then their values MUST be included in the signature payload -- i.e.,
// anyone signing this Trust Manifest must also sign exactly those extensions.
//
// **THIS FIELD COULD BE DEFINED IN V1.1 OF THE SPEC**
"supportedExtensions": ["https://ai-catalog.org/extensions/metadata"],
"signatures": [
{
"signer": "did:web:acme.com",
"profile": "did-web-v1",
"issuedAt": "2026-03-20T14:00:00Z",
// The actual JWS signature covers:
// - `entry.identifier`, `entry.type`, `entry.digest`, and `entry.version` if present
// - the present `entry.extensions` values listed in `supportedExtensions`
// - the containing Trust Manifest object minus the `signatures` field
// - this Signature object minus the `jws` field
"jws": "..."
}
]
}
]
}It does put signatures back inside the Trust Manifest, which I think is a bit awkward. If we don't like that, then we can make signatures a peer of trustManifests (or trustManifest.contributions), and each signature object could contain a single Trust Manifest key.
Also note that if we want to leave the canonicalUrl and supportedExtensions fields out of v1 of the spec, then we can do so without painting ourselves into a corner (assuming we think they are at least directionally correct).
muscariello
left a comment
There was a problem hiding this comment.
Thanks @jonathanhefner — the design looks good to me. Four small inline comments below. Proposed path to merge quickly:
- Merge #108 and #109 (both approved).
- Mark #110 ready for review, approve, and merge.
- Rebase this PR onto
mainand fold in the inline comments:- Before merge: duplicate paths, and
signermust exactly equal the manifest key (one sentence each). - Before merge if you agree, otherwise a follow-up issue: root
["entries"]should exclude contributions, so aggregation never requires re-signing. - Follow-up issue is fine: I-JSON requirement for inline
datadigests.
- Before merge: duplicate paths, and
- Approve and merge this PR.
| Resolution of a missing member MUST fail; a present JSON `null` is a value, | ||
| not absence. A path whose first segment is `"signatures"` MUST be rejected, | ||
| preventing selection of the containing signature array. Nested signatures | ||
| inside a selected value MUST be included unchanged; implementations MUST NOT |
There was a problem hiding this comment.
Selecting ["entries"] at the root covers every entry's signatures and trustManifests, so any new contribution breaks the catalog signature. Suggest leaving those two members out of each entry when resolving ["entries"] for a root signature, so aggregation never needs re-signing.
| recursively remove signatures. For example, a root selection of `entries` | ||
| includes entry signatures and any signatures in embedded catalogs. | ||
|
|
||
| For each path, form the pair `[path, resolvedValue]`. Sort the pairs by path, |
There was a problem hiding this comment.
Please forbid duplicate paths. Otherwise a verifier that dedupes builds a different payload than one that doesn't.
|
Reviewing as a third-party assessor: AgentAvow issues signed safety verdicts about other people's MCP servers, so the contributor-keyed trust manifest in this PR is the slot we would publish into. Three things from implementing it against the current text, then six test cases I can contribute as fixtures.
Fixtures I can contribute, each one line: trustManifests key with a case or trailing-dot variant of the signer must fail; kid listed under authentication only must be rejected; signer A with a kid under DID B that verifies cryptographically must be rejected; a valid signature past expiresAt is not an endorsement, and expiresAt before issuedAt is structurally invalid; an operator adding version after an assessor signed an unversioned entry breaks release coverage; a relabelled profile on the same jws fails and must not fall back to did-web-v1. Plus a positive control: reordered paths and reordered manifest members must still verify. One question: only did-web-v1 is reserved and it is ES256 only, while the listed Python reference signer is EdDSA. Is a reserved EdDSA profile in scope, or should EdDSA signers define a URI profile? |
51df07b to
729ab07
Compare
|
On ADR-0033 (policy URLs): an agent acting for a buyer needs to know which terms applied when it relied on an entry. Francesco Marinoni Moretto |
|
The six fixtures from my review are now written against the 10-05 shape and opened against this PR's branch so they can fold in: jonathanhefner#19. Fifteen cases plus a reordered positive control, each carrying the catalog, the DID documents a verifier would resolve, the verification instant and the expected rejection code, with a premise showing the JWS itself verifies wherever the rejection is a rule rather than a bad signature. Standard library only, nothing added to pyproject or the lockfile. Two of my earlier points are resolved by the revision: duplicate paths no longer exist, and signer-to-contributor equality is now stated byte-exact. The EdDSA profile question stands. AI usage disclosure: the fixtures were prepared with AI assistance under my direct supervision; this comment is mine. |
Give publishers, assessors, and registries separate contributor manifests with one signature and a signed artifact subject. Authenticate signer, profile, and timestamps through whole-manifest JWS signatures, and sign complete catalog snapshots separately. Add the entry artifact digest, require matching release coordinates, and reserve additional signatures without assigning them semantics. Update the schema, guides, mappings, examples, and threat model together. Consolidate the superseded selected-field implementation history into this self-contained redesign. Signed-off-by: Jonathan Hefner <jonathan@hefner.pro>
Give privacy and terms links one official entry extension that publishers or catalog operators can populate. Preserve their existing meanings and URL requirements while removing the former core entry fields. Keep contributor authentication of entry extensions as separate work. Whole-catalog signatures cover these values as snapshot contents, with signer authorization evaluated separately. Signed-off-by: Jonathan Hefner <jonathan@hefner.pro>
00f1a05 to
99d8bfd
Compare
Name implementation defaults and local policy as permitted sources of the existing small clock-skew tolerance. This resolves ambiguity about who configures the allowance while preserving the temporal acceptance bounds.
Summary
Allow publishers, assessors, and catalog operators to contribute independently signed trust metadata about the same artifact.
entry.trustManifestwith atrustManifestsarray, each identifying its contributorentry.digest, mirrored by each signed manifest’ssubject.digestThe schema, guides, examples, mappings, and threat model are updated together.
Motivation
A publisher should be able to provide provenance while an assessor supplies independent audit evidence. Either should be able to update its contribution without requiring the other to sign again.
Shared artifact metadata, such as policy URLs, also needs one predictable location rather than competing values inside individual contributors’ manifests.
Rationale
Each manifest carries its own artifact subject and signature, making it independently verifiable and portable between catalogs. Repeating release coordinates introduces matching rules, but gives each signature a fixed scope.
Whole-catalog signatures separately authenticate the complete snapshot, including Host Info and nested manifests. Multiple signers per manifest and inter-manifest references remain deferred.
See Independent Trust Manifests and Whole-Object Signatures and Define the Policy URLs Extension for details.
Follow-up work
The following draft PRs are stacked in order, targeting
jonathanhefner/independent-trust-manifestswith cumulative follow-up diffs:They will be rebased as the preceding PRs merge.