DEV Community

Cover image for A missing version broke verification of signed ARD search results
Alex
Alex

Posted on Originally published at modelmarket.dev

A missing version broke verification of signed ARD search results

The weather result on modelmarket.dev looked complete until the verifier compared it with its signed subject. The subject named a version; the search result did not.

The full catalog entry had version. A serializer had dropped it from the search representation while keeping the trustManifest that signed it. We caught that on November 4, during an ARD rollout to six AIMarket hubs, and deployed a second build.

The useful part of this bug is where it failed. Checking a signature and checking that the signature belongs to the result in front of you are separate operations. You can reproduce the second failure with a public search result and a few lines of Python.

From a domain to a capability

ARD is Agent Resource Discovery. This implementation follows parts of Initial Contact revision 00 and Manifest and Lookup revision 00, individual Internet-Drafts dated October 30. They remain work in progress, with no IETF endorsement or approved-standard status.

Start at modelmarket.dev/.well-known/ard.json. It lists four resources: the registry, the A2A card, the MCP card, and the AIMarket discovery document. The capabilities for sale sit behind the registry entry.

Four discovery steps lead from an HTTPS origin through an ARD manifest and registry to a capability descriptor. A separate boundary leads to the AIMarket v2 invoke endpoint.

The commands below stop at the descriptor. The POST used for search reads the catalog; execution and payment begin with a separate invoke request.

With curl and jq, fetch the manifest and extract its registry address:

manifest=$(curl -fsS \
  https://modelmarket.dev/.well-known/ard.json)

printf '%s\n' "$manifest" |
  jq '.entries[] | {displayName, type, url}'

registry=$(printf '%s\n' "$manifest" |
  jq -r '.entries[]
    | select(.type == "application/ai-registry+json")
    | .url')
Enter fullscreen mode Exit fullscreen mode

Then ask that registry for weather in Paris:

results=$(curl -fsS "$registry/search" \
  -H 'Content-Type: application/json' \
  --data '{"query":{"text":"weather in Paris"},"pageSize":3}')

printf '%s\n' "$results" |
  jq '.results[] |
    {displayName, identifier, version, source, score, url}'
Enter fullscreen mode Exit fullscreen mode

Follow the returned url instead of copying an address from an old example. This fetches the descriptor, which contains the schemas and calling terms:

descriptor_url=$(printf '%s\n' "$results" |
  jq -r '.results[0].url // empty')

test -n "$descriptor_url" &&
  curl -fsS "$descriptor_url" |
  jq '{input_schema, output_schema, price_per_call_usd, invoke}'
Enter fullscreen mode Exit fullscreen mode

For a listing page instead of a text search:

curl -fsS "$registry/agents?pageSize=1" | jq .
Enter fullscreen mode Exit fullscreen mode

The descriptor's invoke object supplies a method, URL and request body. The body already names product_id, capability_id and source_hub, with an empty input for the buyer to fill in. The actual call still goes to POST /ai-market/v2/invoke; payments, receipts and execution retain their AIMarket v2 behavior.

The comparison that failed

In this implementation, trustManifest.signature is a detached JWS. Its signing input is the protected header followed by the JCS-canonicalized trustManifest with signature removed, both base64url-encoded and joined by .. The signed subject contains the resource's identifier, type, version and digest.

Here are the three checks involved:

  1. Resolve the publisher's did:web identity, select its authorized verification key, and verify the JWS.
  2. Compare the entire signed subject with the corresponding coordinates on the returned result.
  3. Fetch the descriptor bytes and compare their SHA-256 digest with the signed digest.

The missing version breaks step 2. The JWS signs the inner manifest, so deleting a field from the outer result need not alter the signature at all.

The signed subject includes version v1, while the outer result omits version. The JWS signature and descriptor digest can still pass, but equality between the subject and result coordinates fails.

Controlled reproduction: only the outer result's version is removed. The signed manifest and descriptor bytes stay unchanged.

This snippet requests a local publisher's weather result and isolates the subject comparison. It deletes version only from the parsed object in memory:

curl -fsS "$registry/search" \
  -H 'Content-Type: application/json' \
  --data '{"query":{"text":"weather","filter":{"publisher":"modelmarket.dev"}},"pageSize":10}' |
python3 -c '
import json, sys
fields = ("identifier", "type", "version", "digest")
row = next(r for r in json.load(sys.stdin)["results"]
           if r.get("trustManifest"))

def bound(result):
    coordinates = {k: result[k] for k in fields if k in result}
    return result["trustManifest"]["subject"] == coordinates

print("current:", bound(row))
del row["version"]
print("without version:", bound(row))
'
Enter fullscreen mode Exit fullscreen mode
current: True
without version: False
Enter fullscreen mode Exit fullscreen mode

A November 8 check also verified the live result's DID binding, Ed25519 signature and descriptor digest. After that same in-memory deletion, the signature and digest still passed while the subject comparison failed. The regression test now compares the whole subject, including field presence, rather than selected coordinates.

The original rollout checks had a coverage gap: weather searches on the other hubs returned unsigned peer listings. They exercised lookup and descriptor retrieval without ever entering the signed local-result path. The main hub's own weather result reached it.

A result's source changes what you can verify

Five of the six deployments sit at a domain root. Each signs its own entries with its existing Ed25519 key under a did:web identity.

An indexed peer listing keeps the peer's publisher identity in its identifier and names the peer as source. The indexing hub leaves that entry unsigned: it is a discovery hint, with publisher authority still to establish. The sixth deployment, at independentai.network/hub, also serves unsigned entries because that installation has no did:web identity configured in this implementation. Its origin-level well-known address redirects to the manifest under /hub.

This is why the walkthrough prints both source and score. Source tells you whose listing you are examining; score only ranks search relevance. Establishing who signed a descriptor says nothing by itself about the correctness of a weather reading or whether the price is good.

Running the draft's schema

The published CDDL grammar did not compile unchanged with cddl 0.12.14. It redefined prelude names, and the tool stopped on the content-delivery group choice inside an entry map.

The conformance folder retains that original and a separate, documented checkable copy. The latter removes the conflicting definitions and uses two map alternatives for delivery. Manifest, listing-page, search-request and search-response samples then validated; an entry missing its URL and a search score of 101 were rejected.

The open extension map still let the grammar accept both url and data on one entry. That exclusion needed an application test. These are checks of sample shapes and specific rules, not evidence of complete conformance or cross-vendor client interoperability.

There is a concrete client compatibility issue too: these hubs sign with EdDSA, while the discovery draft makes ES256 mandatory to implement. An ES256-only verifier cannot check these signatures. Check the advertised algorithm before choosing a verifier for this catalog.

We operate AIMarket. AI assistance was used for the analysis, this article, and its diagrams.

Top comments (0)