Skip to content

Verify a Sill signature

Everything Sill signs can be checked by anyone with standard tools: an ed25519 library, SHA-256, and an RFC 8785 (JCS) canonicalizer. No Sill SDK, code or account is needed. For a quick yes or no on an audit bundle, drop it on the hosted verifier instead.

What is signedPublic keySignature format
Agent card (per site)https://edge.sill.so/.well-known/jwks.jsonRaw ed25519 in signing.envelope_signature
ARD trust manifest (per site)https://edge.sill.so/.well-known/jwks.jsonDetached compact JWS, alg: EdDSA
Audit bundle and its recordshttps://sill.so/.well-known/sill-audit-envelope-v1.pubRaw ed25519

All signatures and keys are base64url-encoded without padding.

The scripts below run on Node.js 20 or later as ES modules (.mjs). They use two small libraries; any equivalent ed25519 library or JCS canonicalizer works the same way.

Terminal window
npm install @noble/ed25519 json-canonicalize

Use ed.verifyAsync. In current versions of @noble/ed25519, the synchronous ed.verify throws unless you configure a SHA-512 implementation first.

The card’s signature covers the whole card except signing.envelope_signature. Remove that one field, canonicalize the rest with JCS, and verify the signature over the UTF-8 bytes with the JWKS key whose kid equals signing.signing_key_id.

// verify-card.mjs — usage: node verify-card.mjs https://example.com/.well-known/agent-card.json
import { canonicalize } from 'json-canonicalize';
import * as ed from '@noble/ed25519';
const b64u = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
const card = await (await fetch(process.argv[2])).json();
const jwks = await (await fetch('https://edge.sill.so/.well-known/jwks.json')).json();
const { envelope_signature, ...signing } = card.signing;
const jwk = jwks.keys.find((k) => k.kid === signing.signing_key_id && k.crv === 'Ed25519');
if (!jwk) throw new Error('signing key not found in the JWKS');
const signedBytes = new TextEncoder().encode(canonicalize({ ...card, signing }));
const ok = await ed.verifyAsync(b64u(envelope_signature), signedBytes, b64u(jwk.x));
console.log(ok ? 'agent card signature valid' : 'agent card signature INVALID');

A card is served at https://edge.sill.so/v1/agent-card/{site_key}.json, and at /.well-known/agent-card.json on stores whose install serves it there. Fetch the card from either and verify it as is: any change, including reformatting values, makes verification fail.

In an ARD ai-catalog.json, the host and each entry carry their own trustManifest, and each has its own signature. There is no single signature over the whole catalog, so verify each manifest you rely on.

trustManifest.signature is a detached compact JWS (RFC 7515, Appendix F): BASE64URL(header)..BASE64URL(signature), with an empty middle part. To verify it:

  1. Split the string on .. There must be three parts and the middle one must be empty.
  2. Decode the header. Check alg is EdDSA and pick the JWKS key whose kid matches. typ is ard-catalog+jcs.
  3. Rebuild the payload: the trust manifest without its signature field, canonicalized with JCS and base64url-encoded.
  4. Verify the signature over the ASCII string BASE64URL(header) + "." + payload.
// verify-ard.mjs — usage: node verify-ard.mjs https://edge.sill.so/v1/catalog/{site_key}.json
import { canonicalize } from 'json-canonicalize';
import * as ed from '@noble/ed25519';
const b64u = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
const toB64u = (bytes) => Buffer.from(bytes).toString('base64url');
async function verifyTrustManifest(trustManifest, jwks) {
const parts = trustManifest.signature.split('.');
if (parts.length !== 3 || parts[1] !== '') return false;
const [headerB64, , sigB64] = parts;
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8'));
if (header.alg !== 'EdDSA') return false;
const jwk = jwks.keys.find((k) => k.kid === header.kid && k.kty === 'OKP' && k.crv === 'Ed25519');
if (!jwk) return false;
const { signature: _omit, ...unsigned } = trustManifest;
const payloadB64 = toB64u(new TextEncoder().encode(canonicalize(unsigned)));
const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
return ed.verifyAsync(b64u(sigB64), signingInput, b64u(jwk.x));
}
const catalog = await (await fetch(process.argv[2])).json();
const jwks = await (await fetch('https://edge.sill.so/.well-known/jwks.json')).json();
console.log('host', catalog.host.identifier, await verifyTrustManifest(catalog.host.trustManifest, jwks));
for (const entry of catalog.entries) {
console.log('entry', entry.trustManifest.identity, await verifyTrustManifest(entry.trustManifest, jwks));
}

A trust manifest’s signature covers only the manifest itself: its identity, identity type and, for entries, provenance. Other catalog fields such as an entry’s url, description or capabilities are not inside the signature; they are protected by HTTPS from edge.sill.so, as in the ARD specification.

This script runs every check described in Audit envelope: the bundle signature, each record’s signature, the chain links, and the day’s Merkle root. It accepts the .html bundle or the JSON export, for a whole day or for one part of a split day.

// verify-bundle.mjs — usage: node verify-bundle.mjs audit-bundle.html [public-key.json]
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { canonicalize } from 'json-canonicalize';
import * as ed from '@noble/ed25519';
const ZERO = 'A'.repeat(43); // 32 zero bytes in base64url: "no root on the record" / "nothing captured"
const b64u = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
const utf8 = (s) => Buffer.from(s, 'utf8');
const sha256 = (...parts) => {
const h = createHash('sha256');
for (const p of parts) h.update(p);
return h.digest();
};
// 1. Read the signed JSON. In the HTML bundle it is embedded in a script element.
const file = readFileSync(process.argv[2], 'utf8');
const m = /<script type="application\/sill\+json" id="sill-bundle">([\s\S]*?)<\/script>/.exec(file);
const bundle = JSON.parse(m ? m[1].replace(/<\\\//g, '</') : file);
// 2. Pick the public key named by signing_key_id (current key or an earlier one in history).
// If the bundle's signing_key_id doesn't match any listed id (e.g. a legacy bundle),
// try each published key in turn and use whichever verifies — the signature only
// matches the correct key material, so the trust anchor is the public key, not the id.
const keyDoc = process.argv[3]
? JSON.parse(readFileSync(process.argv[3], 'utf8'))
: await (await fetch('https://sill.so/.well-known/sill-audit-envelope-v1.pub')).json();
const keys = [
{ signing_key_id: keyDoc.current_signing_key_id, public_key: keyDoc.public_key },
...(keyDoc.history ?? []),
];
const key = keys.find((k) => k.signing_key_id === bundle.signing_key_id) ?? keys[0];
if (!key) throw new Error('no Sill audit key is published');
const pub = b64u(key.public_key);
// 3. Bundle signature: ed25519 over SHA-256(prefix || 0x00 || JCS(body)). A whole day uses the
// prefix "sill-audit-bundle-v1"; a part of a split day uses "sill-audit-bundle-part-v1".
const isPart = bundle.format === 'sill-audit-bundle-part-v1';
const prefix = isPart ? 'sill-audit-bundle-part-v1' : 'sill-audit-bundle-v1';
const { envelope_signature, signing_key_id, ...body } = bundle;
const bundleInput = sha256(utf8(prefix), Buffer.from([0]), utf8(canonicalize(body)));
console.log('bundle signature', await ed.verifyAsync(b64u(envelope_signature), bundleInput, pub));
// Records written before request/response hashing carry neither field; if either is missing, drop both.
const canonicalRecord = (r) => {
const out = { ...r };
if (out.request_hash == null || out.response_hash == null) {
delete out.request_hash;
delete out.response_hash;
}
return out;
};
// Chain link and Merkle leaf: SHA-256(JCS(record with merkle_root set to the zero value)).
const recordHash = (r) => sha256(utf8(canonicalize({ ...canonicalRecord(r), merkle_root: ZERO })));
let prev = null;
for (const r of bundle.records) {
// 4. Record signature: ed25519 over JCS(record without envelope_signature and merkle_root).
const { envelope_signature: sig, merkle_root, ...signed } = canonicalRecord(r);
const sigOk = await ed.verifyAsync(b64u(sig), utf8(canonicalize(signed)), pub);
// 5. Chain link to the previous record. The first record of a chain links to the genesis value;
// the first record of a later day links to the previous day's last record, not in this file.
const expected = prev
? recordHash(prev).toString('base64url')
: sha256(utf8(`sill-audit-genesis-v1|${r.site_id}|${bundle.decision_class}`)).toString('base64url');
const link = r.prev_record_hash === expected ? 'ok' : prev ? 'BROKEN' : isPart ? 'links to the previous part or day' : 'links to the previous day';
console.log(r.record_id, 'signature', sigOk, 'chain', link);
prev = r;
}
// 6. Merkle root: hash pairs up the tree; an odd node at the end of a level is paired with itself.
if (!isPart) {
let level = bundle.records.map(recordHash);
while (level.length > 1) {
const next = [];
for (let i = 0; i < level.length; i += 2) next.push(sha256(level[i], level[i + 1] ?? level[i]));
level = next;
}
const root = bundle.batch_roots[0];
console.log(
'merkle root',
root.merkle_root === ZERO
? 'not computed yet for this day'
: level[0].toString('base64url') === root.merkle_root && root.leaf_count === bundle.records.length,
);
} else {
// A part holds records first_record..last_record of the day, plus range_proof: the digests
// needed to rebuild the root without the other parts, used left to right, each exactly once.
const { index, count, size, first_record, last_record } = bundle.part;
const { merkle_root, leaf_count } = bundle.batch_root;
const placed = count === Math.ceil(leaf_count / size)
&& first_record === (index - 1) * size + 1
&& last_record === Math.min(index * size, leaf_count)
&& bundle.records.length === last_record - first_record + 1;
const proof = bundle.range_proof.map((d) => Buffer.from(d, 'base64url'));
let nodes = bundle.records.map(recordHash);
let lo = first_record - 1, hi = last_record - 1, n = leaf_count, p = 0;
while (n > 1) {
if (lo % 2 === 1) nodes.unshift(proof[p++] ?? Buffer.alloc(0));
if (hi % 2 === 0) nodes.push(hi + 1 < n ? (proof[p++] ?? Buffer.alloc(0)) : nodes[nodes.length - 1]);
const next = [];
for (let i = 0; i < nodes.length; i += 2) next.push(sha256(nodes[i], nodes[i + 1]));
nodes = next;
lo >>= 1; hi >>= 1; n = Math.ceil(n / 2);
}
console.log(`part ${index} of ${count}`, 'placement', placed);
console.log(
'merkle root',
merkle_root === ZERO
? 'not computed yet for this day'
: p === proof.length && nodes[0].toString('base64url') === merkle_root,
);
}

Every line should print true or ok, apart from the first record’s chain result, which normally reads links to the previous day (for a part, links to the previous part or day). Pass a saved copy of the key document as the second argument to verify against a pinned key instead of fetching it.

A verifier that never fails proves nothing. Change one character in a signed value, for example an identity in a trust manifest, a skill name in an agent card, or an amount in an audit record, and run it again. The signature must fail; for an audit record, the next record’s chain link and the bundle signature fail too. If it still passes, your canonicalization step is not using the data you changed.