feat(ans): implement @logicsrc/ans M1 — resolver + offline verifier

M1 of the ANS SDK (docs/ans-sdk.md):

- name: parse/format ans://v<semver>.<agent>.<domain>
- cbor: minimal RFC 8949 codec for the COSE_Sign1 subset
- verify/merkle: RFC 6962 leaf/node hashing, tree build, inclusion-proof
  generation + verification
- verify/es256 + cose: COSE_Sign1 build/parse, Sig_structure, ES256 (WebCrypto)
- verify/rootkeys: kid -> verifier key (JWKS entries; sumdb-note is M2)
- verify: verifyReceipt() + verifyResolution() (signature + inclusion proof +
  name binding), pure and offline
- client: AnsClient resolve/rootKeys/register/status over injectable fetch,
  with a DnsApplier hook for verify-dns

Tests (24) cover name parsing, CBOR round-trips/vectors, RFC 6962 proofs, a full
ES256+Merkle receipt round-trip with positive/negative cases, and the client
against mocked fetch. Wired into the root build script.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-24 14:48:24 +00:00
parent 0ae24719e3
commit 29975b2df7
20 changed files with 1009 additions and 28 deletions

View file

@ -0,0 +1,114 @@
// COSE_Sign1 (RFC 8152) construction + parsing, plus the M1 ANS receipt
// envelope model.
//
// A receipt is a COSE_Sign1 (CBOR tag 18) over the agent identity statement
// (the payload). The transparency-log inclusion proof is carried in an
// unprotected header under the label "ans-proof":
//
// { root: bstr, index: uint, size: uint, path: [bstr] }
//
// NOTE: this envelope shape is the LogicSRC M1 model. Upstream ANS may place the
// proof in a different (registered SCITT) header; M2 pins this against fixtures
// captured from the upstream Go server and adjusts `parseReceipt` accordingly.
import { CborTag, decodeCbor, encodeCbor, type CborValue } from '../cbor.js';
import { toHex } from '../bytes.js';
export const COSE_SIGN1_TAG = 18;
export const COSE_ALG_ES256 = -7;
const HEADER_ALG = 1;
const HEADER_KID = 4;
const HEADER_ANS_PROOF = 'ans-proof';
export interface InclusionProof {
root: Uint8Array;
index: number;
size: number;
path: Uint8Array[];
}
export interface ReceiptEnvelope {
/** lowercase hex of the 4-byte kid header. */
kidHex: string;
alg: number;
payload: Uint8Array;
signature: Uint8Array;
proof: InclusionProof;
/** Raw CBOR of the protected header map (signed). */
protectedBytes: Uint8Array;
}
/** Canonical COSE Sig_structure bytes the signature covers. */
export function sigStructure(protectedBytes: Uint8Array, payload: Uint8Array): Uint8Array {
return encodeCbor(['Signature1', protectedBytes, new Uint8Array(0), payload]);
}
/** Build a COSE_Sign1 receipt. Used by tests/tools; the signer provides ES256(r||s). */
export async function buildReceipt(opts: {
kid: Uint8Array;
payload: Uint8Array;
proof: InclusionProof;
sign: (message: Uint8Array) => Promise<Uint8Array>;
}): Promise<Uint8Array> {
const protectedBytes = encodeCbor(new Map<CborValue, CborValue>([[HEADER_ALG, COSE_ALG_ES256]]));
const proofMap = new Map<CborValue, CborValue>([
['root', opts.proof.root],
['index', opts.proof.index],
['size', opts.proof.size],
['path', opts.proof.path as CborValue[]],
]);
const unprotected = new Map<CborValue, CborValue>([
[HEADER_KID, opts.kid],
[HEADER_ANS_PROOF, proofMap],
]);
const signature = await opts.sign(sigStructure(protectedBytes, opts.payload));
const sign1 = new CborTag(COSE_SIGN1_TAG, [protectedBytes, unprotected, opts.payload, signature]);
return encodeCbor(sign1);
}
/** Parse COSE_Sign1 receipt bytes into a structured envelope. */
export function parseReceipt(cbor: Uint8Array): ReceiptEnvelope {
let decoded = decodeCbor(cbor);
if (decoded instanceof CborTag) {
if (decoded.tag !== COSE_SIGN1_TAG) throw new Error(`receipt: unexpected CBOR tag ${decoded.tag}`);
decoded = decoded.value;
}
if (!Array.isArray(decoded) || decoded.length !== 4) throw new Error('receipt: not a COSE_Sign1 array');
const [protectedBytes, unprotected, payload, signature] = decoded;
if (!(protectedBytes instanceof Uint8Array)) throw new Error('receipt: bad protected header');
if (!(unprotected instanceof Map)) throw new Error('receipt: bad unprotected header');
if (!(payload instanceof Uint8Array)) throw new Error('receipt: detached/empty payload unsupported in M1');
if (!(signature instanceof Uint8Array)) throw new Error('receipt: bad signature');
const protectedMap = decodeCbor(protectedBytes);
const alg = protectedMap instanceof Map ? protectedMap.get(HEADER_ALG) : undefined;
const kid = unprotected.get(HEADER_KID) ?? (protectedMap instanceof Map ? protectedMap.get(HEADER_KID) : undefined);
if (!(kid instanceof Uint8Array)) throw new Error('receipt: missing kid header');
const proofRaw = unprotected.get(HEADER_ANS_PROOF);
if (!(proofRaw instanceof Map)) throw new Error('receipt: missing ans-proof header');
const proof = parseProof(proofRaw);
return {
kidHex: toHex(kid),
alg: typeof alg === 'number' ? alg : COSE_ALG_ES256,
payload,
signature,
proof,
protectedBytes,
};
}
function parseProof(map: Map<CborValue, CborValue>): InclusionProof {
const root = map.get('root');
const index = map.get('index');
const size = map.get('size');
const path = map.get('path');
if (!(root instanceof Uint8Array)) throw new Error('receipt: proof.root missing');
if (typeof index !== 'number') throw new Error('receipt: proof.index missing');
if (typeof size !== 'number') throw new Error('receipt: proof.size missing');
if (!Array.isArray(path) || path.some((p) => !(p instanceof Uint8Array))) throw new Error('receipt: proof.path malformed');
return { root, index, size, path: path as Uint8Array[] };
}

View file

@ -0,0 +1,15 @@
// ES256 (ECDSA P-256 / SHA-256) verification via WebCrypto. COSE signatures are
// raw IEEE-P1363 (r || s, 64 bytes), which is exactly what WebCrypto expects.
export function importEs256VerifyKey(jwk: JsonWebKey): Promise<CryptoKey> {
return crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
}
export function verifyEs256(key: CryptoKey, signature: Uint8Array, message: Uint8Array): Promise<boolean> {
return crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
signature as unknown as ArrayBuffer,
message as unknown as ArrayBuffer,
);
}

View file

@ -0,0 +1,61 @@
// Offline ANS verifier. No network: given root keys (fetched once or pinned),
// it cryptographically verifies a receipt against the transparency log without
// trusting the registry operator.
import { parseReceipt, sigStructure } from './cose.js';
import { verifyEs256 } from './es256.js';
import { leafHash, verifyInclusion } from './merkle.js';
import { toHex, fromUtf8 } from '../bytes.js';
import { toAnsName } from '../name.js';
import type { AnsIdentity, Receipt, VerifyOptions, VerifyResult } from '../types.js';
/**
* Verify a SCITT COSE_Sign1 inclusion receipt:
* 1. map kid -> root verifier key
* 2. ES256-verify the signature over the COSE Sig_structure
* 3. recompute the leaf hash (RFC 6962) and walk the inclusion proof to the root
*/
export async function verifyReceipt(receipt: Receipt, opts: VerifyOptions): Promise<VerifyResult> {
let env;
try {
env = parseReceipt(receipt.cbor);
} catch (error) {
return { ok: false, reason: `parse: ${(error as Error).message}` };
}
const key = opts.rootKeys.keys.get(env.kidHex);
if (!key) return { ok: false, reason: `unknown kid ${env.kidHex}` };
const message = sigStructure(env.protectedBytes, env.payload);
const signatureOk = await verifyEs256(key, env.signature, message);
if (!signatureOk) return { ok: false, reason: 'invalid signature' };
const leaf = await leafHash(env.payload);
const inclusionOk = await verifyInclusion({
index: env.proof.index,
size: env.proof.size,
leafHash: leaf,
auditPath: env.proof.path,
root: env.proof.root,
});
if (!inclusionOk) return { ok: false, reason: 'inclusion proof failed' };
return { ok: true, rootHashHex: toHex(env.proof.root) };
}
/**
* Verify a resolved identity: the receipt must verify AND its signed payload
* must bind the resolved ans:// name.
*/
export async function verifyResolution(identity: AnsIdentity, opts: VerifyOptions): Promise<VerifyResult> {
const result = await verifyReceipt(identity.receipt, opts);
if (!result.ok) return result;
const env = parseReceipt(identity.receipt.cbor);
const signedName = fromUtf8(env.payload).trim();
const expected = toAnsName(identity.name).raw;
if (signedName !== expected) {
return { ok: false, reason: `name binding mismatch: receipt signs ${signedName}, resolved ${expected}` };
}
return result;
}

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { inclusionProof, leafHash, merkleRoot, nodeHash, verifyInclusion } from './merkle.js';
import { utf8, toHex } from '../bytes.js';
const leaves = ['a', 'b', 'c', 'd'].map(utf8);
describe('RFC 6962 merkle tree', () => {
it('computes the root as node(node(la,lb), node(lc,ld)) for 4 leaves', async () => {
const [la, lb, lc, ld] = await Promise.all(leaves.map(leafHash));
const expected = await nodeHash(await nodeHash(la, lb), await nodeHash(lc, ld));
expect(toHex(await merkleRoot(leaves))).toBe(toHex(expected));
});
it('verifies inclusion proofs for every leaf', async () => {
const root = await merkleRoot(leaves);
for (let i = 0; i < leaves.length; i++) {
const auditPath = await inclusionProof(leaves, i);
const ok = await verifyInclusion({ index: i, size: leaves.length, leafHash: await leafHash(leaves[i]), auditPath, root });
expect(ok).toBe(true);
}
});
it('verifies inclusion for an odd-sized (5-leaf) tree', async () => {
const five = ['a', 'b', 'c', 'd', 'e'].map(utf8);
const root = await merkleRoot(five);
const auditPath = await inclusionProof(five, 4);
expect(await verifyInclusion({ index: 4, size: 5, leafHash: await leafHash(five[4]), auditPath, root })).toBe(true);
});
it('rejects a tampered proof, wrong index, and out-of-range index', async () => {
const root = await merkleRoot(leaves);
const auditPath = await inclusionProof(leaves, 1);
const lb = await leafHash(leaves[1]);
expect(await verifyInclusion({ index: 1, size: 4, leafHash: await leafHash(utf8('x')), auditPath, root })).toBe(false);
expect(await verifyInclusion({ index: 2, size: 4, leafHash: lb, auditPath, root })).toBe(false);
expect(await verifyInclusion({ index: 9, size: 4, leafHash: lb, auditPath, root })).toBe(false);
});
});

View file

@ -0,0 +1,85 @@
// RFC 6962 Merkle tree hashing + inclusion-proof verification.
//
// Leaf hash: SHA-256(0x00 || data)
// Node hash: SHA-256(0x01 || left || right)
//
// The tree builder + proof generator are included so the verifier can be tested
// against self-consistent vectors (and against hand-derived small trees) before
// upstream fixtures are available.
import { sha256, concat, bytesEqual } from '../bytes.js';
const LEAF_PREFIX = new Uint8Array([0x00]);
const NODE_PREFIX = new Uint8Array([0x01]);
export function leafHash(data: Uint8Array): Promise<Uint8Array> {
return sha256(concat(LEAF_PREFIX, data));
}
export function nodeHash(left: Uint8Array, right: Uint8Array): Promise<Uint8Array> {
return sha256(concat(NODE_PREFIX, left, right));
}
/** Largest power of two strictly less than n (n >= 2). */
function splitPoint(n: number): number {
let k = 1;
while (k * 2 < n) k *= 2;
return k;
}
/** Merkle Tree Hash (MTH) over a list of leaf payloads. */
export async function merkleRoot(leaves: Uint8Array[]): Promise<Uint8Array> {
if (leaves.length === 0) return sha256(new Uint8Array(0));
if (leaves.length === 1) return leafHash(leaves[0]);
const k = splitPoint(leaves.length);
const [left, right] = await Promise.all([merkleRoot(leaves.slice(0, k)), merkleRoot(leaves.slice(k))]);
return nodeHash(left, right);
}
/** Audit path for the leaf at `index`, leaf-to-root order. */
export async function inclusionProof(leaves: Uint8Array[], index: number): Promise<Uint8Array[]> {
if (index < 0 || index >= leaves.length) throw new Error('inclusionProof: index out of range');
if (leaves.length <= 1) return [];
const k = splitPoint(leaves.length);
if (index < k) {
const sub = await inclusionProof(leaves.slice(0, k), index);
return [...sub, await merkleRoot(leaves.slice(k))];
}
const sub = await inclusionProof(leaves.slice(k), index - k);
return [...sub, await merkleRoot(leaves.slice(0, k))];
}
async function rootFromProof(index: number, size: number, leaf: Uint8Array, path: Uint8Array[]): Promise<Uint8Array> {
if (size <= 1) {
if (path.length !== 0) throw new Error('inclusion proof: path too long');
return leaf;
}
if (path.length === 0) throw new Error('inclusion proof: path too short');
const k = splitPoint(size);
const sibling = path[path.length - 1];
const rest = path.slice(0, -1);
if (index < k) {
const sub = await rootFromProof(index, k, leaf, rest);
return nodeHash(sub, sibling);
}
const sub = await rootFromProof(index - k, size - k, leaf, rest);
return nodeHash(sibling, sub);
}
export interface InclusionInput {
index: number;
size: number;
leafHash: Uint8Array;
auditPath: Uint8Array[];
root: Uint8Array;
}
export async function verifyInclusion(input: InclusionInput): Promise<boolean> {
if (input.index < 0 || input.index >= input.size) return false;
try {
const computed = await rootFromProof(input.index, input.size, input.leafHash, input.auditPath);
return bytesEqual(computed, input.root);
} catch {
return false;
}
}

View file

@ -0,0 +1,30 @@
// Transparency-log verifier keys, indexed by 4-byte kid (lowercase hex).
//
// M1 accepts a JWKS-style list ({ kid, jwk }). The upstream sumdb-note
// (`/root-keys`) parser is M2 (TODO) — captured against fixtures.
import { importEs256VerifyKey } from './es256.js';
import { fromHex, toHex } from '../bytes.js';
import type { RootKeys } from '../types.js';
export interface RootKeyEntry {
/** 4-byte key id as hex (e.g. "01020304") or any string the receipts use. */
kid: string;
jwk: JsonWebKey;
}
export async function rootKeysFromEntries(entries: RootKeyEntry[]): Promise<RootKeys> {
const keys = new Map<string, CryptoKey>();
for (const entry of entries) {
const kidHex = normalizeKid(entry.kid);
keys.set(kidHex, await importEs256VerifyKey(entry.jwk));
}
return { keys };
}
/** Accept kid as hex ("01020304") or as already-normalized; lowercases hex. */
function normalizeKid(kid: string): string {
const lower = kid.toLowerCase();
if (/^[0-9a-f]+$/.test(lower) && lower.length % 2 === 0) return toHex(fromHex(lower));
return lower;
}

View file

@ -0,0 +1,86 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { buildReceipt } from './cose.js';
import { inclusionProof, leafHash, merkleRoot } from './merkle.js';
import { rootKeysFromEntries } from './rootkeys.js';
import { verifyReceipt, verifyResolution } from './index.js';
import { utf8 } from '../bytes.js';
import type { AnsIdentity, RootKeys } from '../types.js';
const KID = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
const NAME = 'ans://v1.0.0.my-agent.example.com';
const payload = utf8(NAME);
const otherLeaves = [utf8('sibling-0'), utf8('sibling-2'), utf8('sibling-3')];
const leaves = [otherLeaves[0], payload, otherLeaves[1], otherLeaves[2]];
const INDEX = 1;
let keyPair: CryptoKeyPair;
let rootKeys: RootKeys;
let receiptCbor: Uint8Array;
async function sign(message: Uint8Array): Promise<Uint8Array> {
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, message as unknown as ArrayBuffer);
return new Uint8Array(sig);
}
beforeAll(async () => {
keyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])) as CryptoKeyPair;
const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
rootKeys = await rootKeysFromEntries([{ kid: '01020304', jwk }]);
const root = await merkleRoot(leaves);
const path = await inclusionProof(leaves, INDEX);
receiptCbor = await buildReceipt({ kid: KID, payload, proof: { root, index: INDEX, size: leaves.length, path }, sign });
});
describe('offline receipt verification', () => {
it('verifies a well-formed receipt (signature + inclusion proof)', async () => {
const result = await verifyReceipt({ cbor: receiptCbor }, { rootKeys });
expect(result.ok).toBe(true);
expect(result.rootHashHex).toMatch(/^[0-9a-f]{64}$/);
});
it('verifies a resolution whose payload binds the resolved name', async () => {
const identity: AnsIdentity = {
name: NAME as never, // toAnsName normalizes the string form
capabilities: [],
events: [],
receipt: { cbor: receiptCbor },
} as unknown as AnsIdentity;
const result = await verifyResolution({ ...identity, name: { raw: NAME, version: '1.0.0', agent: 'my-agent', domain: 'example.com' } }, { rootKeys });
expect(result.ok).toBe(true);
});
it('rejects an unknown kid', async () => {
const empty = await rootKeysFromEntries([]);
const result = await verifyReceipt({ cbor: receiptCbor }, { rootKeys: empty });
expect(result.ok).toBe(false);
expect(result.reason).toContain('unknown kid');
});
it('rejects a receipt signed by a different key', async () => {
const other = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])) as CryptoKeyPair;
const wrongJwk = await crypto.subtle.exportKey('jwk', other.publicKey);
const wrongKeys = await rootKeysFromEntries([{ kid: '01020304', jwk: wrongJwk }]);
const result = await verifyReceipt({ cbor: receiptCbor }, { rootKeys: wrongKeys });
expect(result.ok).toBe(false);
expect(result.reason).toBe('invalid signature');
});
it('rejects a tampered inclusion proof (wrong root)', async () => {
const badRoot = new Uint8Array(32).fill(0xff);
const path = await inclusionProof(leaves, INDEX);
const tampered = await buildReceipt({ kid: KID, payload, proof: { root: badRoot, index: INDEX, size: leaves.length, path }, sign });
const result = await verifyReceipt({ cbor: tampered }, { rootKeys });
expect(result.ok).toBe(false);
expect(result.reason).toBe('inclusion proof failed');
});
it('rejects a resolution whose name does not match the signed payload', async () => {
const result = await verifyResolution(
{ name: { raw: 'ans://v1.0.0.other.example.com', version: '1.0.0', agent: 'other', domain: 'example.com' }, capabilities: [], events: [], receipt: { cbor: receiptCbor } },
{ rootKeys },
);
expect(result.ok).toBe(false);
expect(result.reason).toContain('name binding mismatch');
});
});