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

38
package-lock.json generated
View file

@ -1388,6 +1388,10 @@
"resolved": "packages/agentstack", "resolved": "packages/agentstack",
"link": true "link": true
}, },
"node_modules/@logicsrc/ans": {
"resolved": "packages/ans",
"link": true
},
"node_modules/@logicsrc/cli": { "node_modules/@logicsrc/cli": {
"resolved": "packages/cli", "resolved": "packages/cli",
"link": true "link": true
@ -4495,7 +4499,6 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -5165,7 +5168,6 @@
"os": [ "os": [
"aix" "aix"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5182,7 +5184,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5199,7 +5200,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5216,7 +5216,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5233,7 +5232,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5250,7 +5248,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5267,7 +5264,6 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5284,7 +5280,6 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5301,7 +5296,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5318,7 +5312,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5335,7 +5328,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5352,7 +5344,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5369,7 +5360,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5386,7 +5376,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5403,7 +5392,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5420,7 +5408,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5437,7 +5424,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5454,7 +5440,6 @@
"os": [ "os": [
"netbsd" "netbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5471,7 +5456,6 @@
"os": [ "os": [
"netbsd" "netbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5488,7 +5472,6 @@
"os": [ "os": [
"openbsd" "openbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5505,7 +5488,6 @@
"os": [ "os": [
"openbsd" "openbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5522,7 +5504,6 @@
"os": [ "os": [
"openharmony" "openharmony"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5539,7 +5520,6 @@
"os": [ "os": [
"sunos" "sunos"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5556,7 +5536,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5573,7 +5552,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5590,7 +5568,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -6061,6 +6038,13 @@
"vitest": "^4.0.8" "vitest": "^4.0.8"
} }
}, },
"packages/ans": {
"name": "@logicsrc/ans",
"version": "0.1.0",
"devDependencies": {
"vitest": "^4.0.8"
}
},
"packages/cli": { "packages/cli": {
"name": "@logicsrc/cli", "name": "@logicsrc/cli",
"version": "0.1.0", "version": "0.1.0",

View file

@ -12,7 +12,7 @@
"apps/*" "apps/*"
], ],
"scripts": { "scripts": {
"build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build",
"start": "npm --workspace @logicsrc/web run start", "start": "npm --workspace @logicsrc/web run start",
"test": "npm run test --workspaces --if-present", "test": "npm run test --workspaces --if-present",
"check": "npm run build && npm run test", "check": "npm run build && npm run test",

15
packages/ans/package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "@logicsrc/ans",
"version": "0.1.0",
"description": "LogicSRC TypeScript SDK for the Agent Name Service (ANS): resolver + offline transparency-log verifier.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run src"
},
"devDependencies": {
"vitest": "^4.0.8"
}
}

55
packages/ans/src/bytes.ts Normal file
View file

@ -0,0 +1,55 @@
// Byte + hash utilities. Kept dependency-free and runtime-portable (Node, Deno,
// Bun, edge, browser) — only WebCrypto + standard text/base64 globals.
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest('SHA-256', data as unknown as ArrayBuffer);
return new Uint8Array(digest);
}
export function concat(...parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
export function toHex(bytes: Uint8Array): string {
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex;
}
export function fromHex(hex: string): Uint8Array {
const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
if (clean.length % 2 !== 0) throw new Error('odd-length hex string');
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
return out;
}
export function fromBase64(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
/** Length-safe constant-time-ish comparison for hashes. */
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
export function utf8(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
export function fromUtf8(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes);
}

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { CborTag, decodeCbor, encodeCbor } from './cbor.js';
describe('cbor codec', () => {
it('encodes known RFC 8949 vectors', () => {
expect([...encodeCbor(0)]).toEqual([0x00]);
expect([...encodeCbor(23)]).toEqual([0x17]);
expect([...encodeCbor(24)]).toEqual([0x18, 0x18]);
expect([...encodeCbor(1000)]).toEqual([0x19, 0x03, 0xe8]);
expect([...encodeCbor(-1)]).toEqual([0x20]);
expect([...encodeCbor(-7)]).toEqual([0x26]);
expect([...encodeCbor('a')]).toEqual([0x61, 0x61]);
expect([...encodeCbor(new Uint8Array([1, 2, 3]))]).toEqual([0x43, 0x01, 0x02, 0x03]);
expect([...encodeCbor([1, 2, 3])]).toEqual([0x83, 0x01, 0x02, 0x03]);
});
it('round-trips ints, bytes, strings, arrays, maps, tags', () => {
const value = new CborTag(18, [
new Uint8Array([0xa1, 0x01, 0x26]),
new Map<unknown, unknown>([
[4, new Uint8Array([1, 2, 3, 4])],
['ans-proof', new Map<unknown, unknown>([['index', 1], ['size', 3], ['path', [new Uint8Array([9])]]])],
]),
new Uint8Array([0xde, 0xad]),
new Uint8Array(64).fill(7),
]);
const decoded = decodeCbor(encodeCbor(value as never));
expect(decoded).toEqual(value);
});
it('round-trips a large (4-byte and 8-byte) integer', () => {
expect(decodeCbor(encodeCbor(70000))).toBe(70000);
expect(decodeCbor(encodeCbor(5_000_000_000))).toBe(5_000_000_000);
});
});

129
packages/ans/src/cbor.ts Normal file
View file

@ -0,0 +1,129 @@
// Minimal CBOR (RFC 8949) codec — the subset needed for COSE_Sign1 receipts:
// unsigned/negative ints, byte strings, text strings, arrays, maps, tags, and
// the simple values null/true/false. Maps decode to JS Map so integer header
// labels (COSE alg=1, kid=4) round-trip without key-coercion.
//
// This is intentionally small and fixture-driven; it is NOT a general-purpose
// CBOR implementation (no floats, no indefinite-length items, no bignums).
export class CborTag {
constructor(public readonly tag: number, public readonly value: CborValue) {}
}
export type CborValue =
| number
| bigint
| boolean
| null
| Uint8Array
| string
| CborValue[]
| Map<CborValue, CborValue>
| CborTag;
export function encodeCbor(value: CborValue): Uint8Array {
const out: number[] = [];
writeValue(out, value);
return Uint8Array.from(out);
}
export function decodeCbor(bytes: Uint8Array): CborValue {
const reader = { bytes, pos: 0 };
const value = readValue(reader);
if (reader.pos !== bytes.length) throw new Error('cbor: trailing bytes');
return value;
}
function writeHead(out: number[], major: number, n: number | bigint): void {
const mt = major << 5;
const big = BigInt(n);
if (big < 24n) out.push(mt | Number(big));
else if (big < 0x100n) out.push(mt | 24, Number(big));
else if (big < 0x10000n) out.push(mt | 25, Number(big >> 8n) & 0xff, Number(big) & 0xff);
else if (big < 0x100000000n) {
out.push(mt | 26, Number(big >> 24n) & 0xff, Number(big >> 16n) & 0xff, Number(big >> 8n) & 0xff, Number(big) & 0xff);
} else {
out.push(mt | 27);
for (let shift = 56n; shift >= 0n; shift -= 8n) out.push(Number((big >> shift) & 0xffn));
}
}
function writeValue(out: number[], value: CborValue): void {
if (value === null) { out.push(0xf6); return; }
if (value === false) { out.push(0xf4); return; }
if (value === true) { out.push(0xf5); return; }
if (typeof value === 'number' || typeof value === 'bigint') {
if (value < 0) writeHead(out, 1, -BigInt(value) - 1n);
else writeHead(out, 0, value);
return;
}
if (value instanceof Uint8Array) {
writeHead(out, 2, value.length);
for (const b of value) out.push(b);
return;
}
if (typeof value === 'string') {
const bytes = new TextEncoder().encode(value);
writeHead(out, 3, bytes.length);
for (const b of bytes) out.push(b);
return;
}
if (Array.isArray(value)) {
writeHead(out, 4, value.length);
for (const item of value) writeValue(out, item);
return;
}
if (value instanceof Map) {
writeHead(out, 5, value.size);
for (const [k, v] of value) { writeValue(out, k); writeValue(out, v); }
return;
}
if (value instanceof CborTag) {
writeHead(out, 6, value.tag);
writeValue(out, value.value);
return;
}
throw new Error('cbor: unsupported value');
}
interface Reader { bytes: Uint8Array; pos: number; }
function readArg(r: Reader, info: number): number {
if (info < 24) return info;
if (info === 24) return r.bytes[r.pos++];
if (info === 25) { const v = (r.bytes[r.pos] << 8) | r.bytes[r.pos + 1]; r.pos += 2; return v; }
if (info === 26) {
const v = (r.bytes[r.pos] * 0x1000000) + (r.bytes[r.pos + 1] << 16) + (r.bytes[r.pos + 2] << 8) + r.bytes[r.pos + 3];
r.pos += 4;
return v;
}
if (info === 27) {
let v = 0n;
for (let i = 0; i < 8; i++) v = (v << 8n) | BigInt(r.bytes[r.pos++]);
if (v > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('cbor: integer too large');
return Number(v);
}
throw new Error(`cbor: bad additional info ${info}`);
}
function readValue(r: Reader): CborValue {
const initial = r.bytes[r.pos++];
const major = initial >> 5;
const info = initial & 0x1f;
switch (major) {
case 0: return readArg(r, info);
case 1: return -1 - readArg(r, info);
case 2: { const len = readArg(r, info); const v = r.bytes.slice(r.pos, r.pos + len); r.pos += len; return v; }
case 3: { const len = readArg(r, info); const v = new TextDecoder().decode(r.bytes.slice(r.pos, r.pos + len)); r.pos += len; return v; }
case 4: { const len = readArg(r, info); const arr: CborValue[] = []; for (let i = 0; i < len; i++) arr.push(readValue(r)); return arr; }
case 5: { const len = readArg(r, info); const map = new Map<CborValue, CborValue>(); for (let i = 0; i < len; i++) { const k = readValue(r); map.set(k, readValue(r)); } return map; }
case 6: { const tag = readArg(r, info); return new CborTag(tag, readValue(r)); }
case 7:
if (info === 20) return false;
if (info === 21) return true;
if (info === 22) return null;
throw new Error(`cbor: unsupported simple value ${info}`);
default:
throw new Error(`cbor: unknown major type ${major}`);
}
}

View file

@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
import { AnsClient } from './client.js';
function jsonResponse(body: unknown): Response {
return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(body) } as unknown as Response;
}
describe('AnsClient', () => {
it('resolves a name and decodes the base64 receipt', async () => {
const receiptB64 = btoa(String.fromCharCode(0xd2, 0x84, 0x40));
const fetchMock = vi.fn(async () => jsonResponse({
name: 'ans://v1.0.0.my-agent.example.com',
endpoint: 'https://my-agent.example.com',
capabilities: ['chat'],
receipt: receiptB64,
}));
const client = new AnsClient({ registryUrl: 'https://registry.ans.dev/', fetch: fetchMock as unknown as typeof fetch });
const id = await client.resolve('ans://v1.0.0.my-agent.example.com');
expect(fetchMock).toHaveBeenCalledWith(
'https://registry.ans.dev/v1/resolve/ans%3A%2F%2Fv1.0.0.my-agent.example.com',
expect.objectContaining({ method: 'GET' }),
);
expect(id.name.agent).toBe('my-agent');
expect(id.capabilities).toEqual(['chat']);
expect([...id.receipt.cbor]).toEqual([0xd2, 0x84, 0x40]);
});
it('register applies the DNS challenge via the supplied applier', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ challengeToken: 'tok-xyz', recordName: '_ans-challenge.my-agent.example.com' }));
const upsertTxt = vi.fn(async () => {});
const client = new AnsClient({ registryUrl: 'https://registry.ans.dev', token: 't', fetch: fetchMock as unknown as typeof fetch });
const reg = await client.register({ agent: 'my-agent', domain: 'example.com', version: '1.0.0', verify: 'dns', dns: { upsertTxt } });
expect(reg.name.raw).toBe('ans://v1.0.0.my-agent.example.com');
expect(reg.challenge).toEqual({ type: 'TXT', name: '_ans-challenge.my-agent.example.com', value: 'tok-xyz' });
expect(upsertTxt).toHaveBeenCalledWith({ name: '_ans-challenge.my-agent.example.com', value: 'tok-xyz' });
const [, init] = fetchMock.mock.calls[0];
expect((init as RequestInit).headers).toMatchObject({ Authorization: 'Bearer t' });
});
it('throws on a non-OK response', async () => {
const fetchMock = vi.fn(async () => ({ ok: false, status: 404, statusText: 'Not Found', text: async () => 'no such name' } as unknown as Response));
const client = new AnsClient({ registryUrl: 'https://registry.ans.dev', fetch: fetchMock as unknown as typeof fetch });
await expect(client.resolve('ans://v1.0.0.ghost.example.com')).rejects.toThrow('ANS GET /v1/resolve/');
});
});

113
packages/ans/src/client.ts Normal file
View file

@ -0,0 +1,113 @@
// ANS registry HTTP client (the network half). Pairs with the offline verifier
// in ./verify — callers should resolve() then verifyResolution() rather than
// trusting resolve() output directly.
import { toAnsName } from './name.js';
import { rootKeysFromEntries, type RootKeyEntry } from './verify/rootkeys.js';
import { fromBase64 } from './bytes.js';
import type {
AnsIdentity,
AnsName,
RegisterRequest,
Registration,
RegistrationStatus,
RootKeys,
} from './types.js';
export interface AnsClientOptions {
registryUrl: string;
token?: string;
/** Pin verifier keys instead of trusting GET /root-keys (recommended in-house). */
pinnedRootKeys?: RootKeyEntry[];
fetch?: typeof fetch;
}
interface ResolveResponse {
name: string;
endpoint?: string;
capabilities?: string[];
certChainPem?: string;
serverCertTlsa?: string;
events?: { type: string; at: string; payload?: unknown }[];
/** base64-encoded COSE_Sign1 receipt bytes. */
receipt: string;
}
export class AnsClient {
private readonly base: string;
private readonly doFetch: typeof fetch;
constructor(private readonly opts: AnsClientOptions) {
this.base = opts.registryUrl.replace(/\/+$/, '');
const f = opts.fetch ?? globalThis.fetch;
if (!f) throw new Error('AnsClient: no fetch available; pass opts.fetch');
this.doFetch = f;
}
async resolve(name: string | AnsName): Promise<AnsIdentity> {
const ans = toAnsName(name);
const body = await this.get<ResolveResponse>(`/v1/resolve/${encodeURIComponent(ans.raw)}`);
return {
name: toAnsName(body.name),
endpoint: body.endpoint,
capabilities: body.capabilities ?? [],
certChainPem: body.certChainPem,
serverCertTlsa: body.serverCertTlsa,
events: body.events ?? [],
receipt: { cbor: fromBase64(body.receipt) },
};
}
async rootKeys(): Promise<RootKeys> {
if (this.opts.pinnedRootKeys) return rootKeysFromEntries(this.opts.pinnedRootKeys);
// M1: registry serves a JWKS-style list. sumdb-note parsing is M2.
const body = await this.get<{ keys: RootKeyEntry[] }>('/root-keys');
return rootKeysFromEntries(body.keys);
}
async register(req: RegisterRequest): Promise<Registration> {
const body = await this.post<{ challengeToken: string; recordName?: string }>('/v1/register', {
agent: req.agent,
domain: req.domain,
version: req.version,
endpoint: req.endpoint,
capabilities: req.capabilities ?? [],
verify: req.verify,
});
const name = toAnsName(`ans://v${req.version}.${req.agent}.${req.domain}`);
const challenge = {
type: 'TXT' as const,
name: body.recordName ?? `_ans-challenge.${req.agent}.${req.domain}`,
value: body.challengeToken,
};
if (req.dns) await req.dns.upsertTxt({ name: challenge.name, value: challenge.value });
return { name, challenge };
}
async status(name: string | AnsName): Promise<RegistrationStatus> {
const ans = toAnsName(name);
return this.get<RegistrationStatus>(`/v1/status/${encodeURIComponent(ans.raw)}`);
}
private async get<T>(path: string): Promise<T> {
return this.request<T>('GET', path);
}
private async post<T>(path: string, body: unknown): Promise<T> {
return this.request<T>('POST', path, body);
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (this.opts.token) headers.Authorization = `Bearer ${this.opts.token}`;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const response = await this.doFetch(`${this.base}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
if (!response.ok) throw new Error(`ANS ${method} ${path} failed: ${response.status} ${text || response.statusText}`);
return (text ? JSON.parse(text) : {}) as T;
}
}

15
packages/ans/src/index.ts Normal file
View file

@ -0,0 +1,15 @@
// @logicsrc/ans — TypeScript SDK for the Agent Name Service (ANS).
// See docs/ans-sdk.md. M1: resolver + offline transparency-log verifier.
export * from './types.js';
export { parseAnsName, formatAnsName, toAnsName } from './name.js';
export { AnsClient, type AnsClientOptions } from './client.js';
export { verifyReceipt, verifyResolution } from './verify/index.js';
export { rootKeysFromEntries, type RootKeyEntry } from './verify/rootkeys.js';
// Lower-level building blocks (stable enough to reuse; handy for tooling/tests).
export { merkleRoot, inclusionProof, verifyInclusion, leafHash, nodeHash } from './verify/merkle.js';
export { buildReceipt, parseReceipt, sigStructure, type ReceiptEnvelope, type InclusionProof } from './verify/cose.js';
export { importEs256VerifyKey, verifyEs256 } from './verify/es256.js';
export { encodeCbor, decodeCbor, CborTag, type CborValue } from './cbor.js';
export * as bytes from './bytes.js';

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { formatAnsName, parseAnsName } from './name.js';
describe('parseAnsName', () => {
it('parses a well-formed ans:// name', () => {
expect(parseAnsName('ans://v1.0.0.my-agent.example.com')).toEqual({
raw: 'ans://v1.0.0.my-agent.example.com',
version: '1.0.0',
agent: 'my-agent',
domain: 'example.com',
});
});
it('parses multi-label domains and prerelease versions', () => {
const parsed = parseAnsName('ans://v2.3.1-beta.bot.agents.example.co.uk');
expect(parsed.version).toBe('2.3.1-beta');
expect(parsed.agent).toBe('bot');
expect(parsed.domain).toBe('agents.example.co.uk');
});
it.each([
'https://v1.0.0.a.example.com',
'ans://1.0.0.a.example.com',
'ans://v1.0.a.example.com',
'ans://v1.0.0.bad agent.example.com',
'ans://v1.0.0.agent',
])('rejects malformed name %s', (raw) => {
expect(() => parseAnsName(raw)).toThrow();
});
it('round-trips through formatAnsName', () => {
expect(formatAnsName({ version: '1.2.3', agent: 'a', domain: 'example.com' })).toBe('ans://v1.2.3.a.example.com');
});
});

34
packages/ans/src/name.ts Normal file
View file

@ -0,0 +1,34 @@
import type { AnsName } from './types.js';
const LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i;
// ans://v<major.minor.patch[-prerelease]>.<agent>.<domain>
// The version's dotted core (X.Y.Z) is anchored so it doesn't get confused with
// the agent/domain labels. Prerelease is dot-free in M1 to keep the boundary
// unambiguous; build metadata (+...) is M2.
const ANS = /^ans:\/\/v(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+)?)\.([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.(.+)$/i;
/**
* Parse an `ans://v<semver>.<agent>.<domain>` name. The version is `v<semver>`,
* the agent is a single DNS label, and the domain is one or more labels after
* it. Throws on a malformed name.
*/
export function parseAnsName(raw: string): AnsName {
const match = ANS.exec(raw.trim());
if (!match) throw new Error(`malformed ans name — got ${raw}`);
const [, version, agent, domain] = match;
if (!LABEL.test(agent)) throw new Error(`ans name has invalid agent label — got ${agent}`);
if (!domain || domain.split('.').some((label) => !LABEL.test(label))) {
throw new Error(`ans name has invalid domain — got ${domain}`);
}
return { raw: `ans://v${version}.${agent}.${domain}`, version, agent, domain };
}
export function formatAnsName(parts: { version: string; agent: string; domain: string }): string {
return parseAnsName(`ans://v${parts.version}.${parts.agent}.${parts.domain}`).raw;
}
export function toAnsName(name: string | AnsName): AnsName {
return typeof name === 'string' ? parseAnsName(name) : name;
}

80
packages/ans/src/types.ts Normal file
View file

@ -0,0 +1,80 @@
// Public types for @logicsrc/ans. See docs/ans-sdk.md for the full spec.
/** Parsed ans://v<semver>.<agent>.<domain> name. */
export interface AnsName {
raw: string;
version: string;
agent: string;
domain: string;
}
/** Lifecycle event recorded in the transparency log. */
export interface LifecycleEvent {
type: string;
at: string;
payload?: unknown;
}
/** Raw SCITT COSE_Sign1 inclusion receipt bytes. */
export interface Receipt {
cbor: Uint8Array;
}
/** A resolved, not-yet-verified agent identity. */
export interface AnsIdentity {
name: AnsName;
endpoint?: string;
capabilities: string[];
certChainPem?: string;
serverCertTlsa?: string;
events: LifecycleEvent[];
receipt: Receipt;
}
/** Registry transparency-log verifier keys, indexed by 4-byte kid (lowercase hex). */
export interface RootKeys {
keys: Map<string, CryptoKey>;
}
export interface VerifyOptions {
rootKeys: RootKeys;
now?: Date;
}
export interface VerifyResult {
ok: boolean;
reason?: string;
/** Transparency-log root hash the receipt was proven against (hex). */
rootHashHex?: string;
}
export interface RegisterRequest {
agent: string;
domain: string;
version: string;
endpoint?: string;
capabilities?: string[];
verify: 'dns' | 'acme';
dns?: DnsApplier;
}
/** Caller-supplied DNS automation (e.g. a sh1pt DNS adapter) for verify-dns. */
export interface DnsApplier {
upsertTxt(record: { name: string; value: string }): Promise<void>;
}
export interface Registration {
name: AnsName;
challenge: { type: 'TXT'; name: string; value: string };
}
export interface RegistrationStatus {
state: 'pending' | 'verifying' | 'live' | 'failed';
message?: string;
}
/** Bidirectional map between an ans:// name and a LogicSRC/coinpay DID (M3). */
export interface DidBridge {
ansNameForDid(did: string): Promise<AnsName | null>;
didForAnsName(name: string | AnsName): Promise<string | null>;
}

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');
});
});

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}