diff --git a/package-lock.json b/package-lock.json index a04dcb3..976f600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1388,6 +1388,10 @@ "resolved": "packages/agentstack", "link": true }, + "node_modules/@logicsrc/ans": { + "resolved": "packages/ans", + "link": true + }, "node_modules/@logicsrc/cli": { "resolved": "packages/cli", "link": true @@ -4495,7 +4499,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5165,7 +5168,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -5182,7 +5184,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -5199,7 +5200,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -5216,7 +5216,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -5233,7 +5232,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -5250,7 +5248,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -5267,7 +5264,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -5284,7 +5280,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -5301,7 +5296,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -5318,7 +5312,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -5335,7 +5328,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -5352,7 +5344,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -5369,7 +5360,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -5386,7 +5376,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -5403,7 +5392,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -5420,7 +5408,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -5437,7 +5424,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -5454,7 +5440,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -5471,7 +5456,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -5488,7 +5472,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -5505,7 +5488,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -5522,7 +5504,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -5539,7 +5520,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -5556,7 +5536,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -5573,7 +5552,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -5590,7 +5568,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -6061,6 +6038,13 @@ "vitest": "^4.0.8" } }, + "packages/ans": { + "name": "@logicsrc/ans", + "version": "0.1.0", + "devDependencies": { + "vitest": "^4.0.8" + } + }, "packages/cli": { "name": "@logicsrc/cli", "version": "0.1.0", diff --git a/package.json b/package.json index fe49683..d9d71c4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "apps/*" ], "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", "test": "npm run test --workspaces --if-present", "check": "npm run build && npm run test", diff --git a/packages/ans/package.json b/packages/ans/package.json new file mode 100644 index 0000000..4285754 --- /dev/null +++ b/packages/ans/package.json @@ -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" + } +} diff --git a/packages/ans/src/bytes.ts b/packages/ans/src/bytes.ts new file mode 100644 index 0000000..8cc809f --- /dev/null +++ b/packages/ans/src/bytes.ts @@ -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 { + 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); +} diff --git a/packages/ans/src/cbor.test.ts b/packages/ans/src/cbor.test.ts new file mode 100644 index 0000000..fbcf17c --- /dev/null +++ b/packages/ans/src/cbor.test.ts @@ -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([ + [4, new Uint8Array([1, 2, 3, 4])], + ['ans-proof', new Map([['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); + }); +}); diff --git a/packages/ans/src/cbor.ts b/packages/ans/src/cbor.ts new file mode 100644 index 0000000..7408ee3 --- /dev/null +++ b/packages/ans/src/cbor.ts @@ -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 + | 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(); 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}`); + } +} diff --git a/packages/ans/src/client.test.ts b/packages/ans/src/client.test.ts new file mode 100644 index 0000000..3c5d630 --- /dev/null +++ b/packages/ans/src/client.test.ts @@ -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/'); + }); +}); diff --git a/packages/ans/src/client.ts b/packages/ans/src/client.ts new file mode 100644 index 0000000..e22c05c --- /dev/null +++ b/packages/ans/src/client.ts @@ -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 { + const ans = toAnsName(name); + const body = await this.get(`/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 { + 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 { + 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 { + const ans = toAnsName(name); + return this.get(`/v1/status/${encodeURIComponent(ans.raw)}`); + } + + private async get(path: string): Promise { + return this.request('GET', path); + } + + private async post(path: string, body: unknown): Promise { + return this.request('POST', path, body); + } + + private async request(method: string, path: string, body?: unknown): Promise { + const headers: Record = { 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; + } +} diff --git a/packages/ans/src/index.ts b/packages/ans/src/index.ts new file mode 100644 index 0000000..363adf6 --- /dev/null +++ b/packages/ans/src/index.ts @@ -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'; diff --git a/packages/ans/src/name.test.ts b/packages/ans/src/name.test.ts new file mode 100644 index 0000000..13f5132 --- /dev/null +++ b/packages/ans/src/name.test.ts @@ -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'); + }); +}); diff --git a/packages/ans/src/name.ts b/packages/ans/src/name.ts new file mode 100644 index 0000000..595c26d --- /dev/null +++ b/packages/ans/src/name.ts @@ -0,0 +1,34 @@ +import type { AnsName } from './types.js'; + +const LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i; +// ans://v.. +// 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..` name. The version is `v`, + * 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; +} diff --git a/packages/ans/src/types.ts b/packages/ans/src/types.ts new file mode 100644 index 0000000..6806181 --- /dev/null +++ b/packages/ans/src/types.ts @@ -0,0 +1,80 @@ +// Public types for @logicsrc/ans. See docs/ans-sdk.md for the full spec. + +/** Parsed ans://v.. 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; +} + +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; +} + +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; + didForAnsName(name: string | AnsName): Promise; +} diff --git a/packages/ans/src/verify/cose.ts b/packages/ans/src/verify/cose.ts new file mode 100644 index 0000000..e9ba25e --- /dev/null +++ b/packages/ans/src/verify/cose.ts @@ -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; +}): Promise { + const protectedBytes = encodeCbor(new Map([[HEADER_ALG, COSE_ALG_ES256]])); + const proofMap = new Map([ + ['root', opts.proof.root], + ['index', opts.proof.index], + ['size', opts.proof.size], + ['path', opts.proof.path as CborValue[]], + ]); + const unprotected = new Map([ + [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): 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[] }; +} diff --git a/packages/ans/src/verify/es256.ts b/packages/ans/src/verify/es256.ts new file mode 100644 index 0000000..b51316e --- /dev/null +++ b/packages/ans/src/verify/es256.ts @@ -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 { + return crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); +} + +export function verifyEs256(key: CryptoKey, signature: Uint8Array, message: Uint8Array): Promise { + return crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + signature as unknown as ArrayBuffer, + message as unknown as ArrayBuffer, + ); +} diff --git a/packages/ans/src/verify/index.ts b/packages/ans/src/verify/index.ts new file mode 100644 index 0000000..b429c6d --- /dev/null +++ b/packages/ans/src/verify/index.ts @@ -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 { + 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 { + 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; +} diff --git a/packages/ans/src/verify/merkle.test.ts b/packages/ans/src/verify/merkle.test.ts new file mode 100644 index 0000000..a11b765 --- /dev/null +++ b/packages/ans/src/verify/merkle.test.ts @@ -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); + }); +}); diff --git a/packages/ans/src/verify/merkle.ts b/packages/ans/src/verify/merkle.ts new file mode 100644 index 0000000..94a3da8 --- /dev/null +++ b/packages/ans/src/verify/merkle.ts @@ -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 { + return sha256(concat(LEAF_PREFIX, data)); +} + +export function nodeHash(left: Uint8Array, right: Uint8Array): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/ans/src/verify/rootkeys.ts b/packages/ans/src/verify/rootkeys.ts new file mode 100644 index 0000000..d5374e8 --- /dev/null +++ b/packages/ans/src/verify/rootkeys.ts @@ -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 { + const keys = new Map(); + 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; +} diff --git a/packages/ans/src/verify/verify.test.ts b/packages/ans/src/verify/verify.test.ts new file mode 100644 index 0000000..038d8a5 --- /dev/null +++ b/packages/ans/src/verify/verify.test.ts @@ -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 { + 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'); + }); +}); diff --git a/packages/ans/tsconfig.json b/packages/ans/tsconfig.json new file mode 100644 index 0000000..d38ac70 --- /dev/null +++ b/packages/ans/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +}