mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 14:37:26 +00:00
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:
parent
0ae24719e3
commit
29975b2df7
20 changed files with 1009 additions and 28 deletions
38
package-lock.json
generated
38
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
15
packages/ans/package.json
Normal 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
55
packages/ans/src/bytes.ts
Normal 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);
|
||||||
|
}
|
||||||
35
packages/ans/src/cbor.test.ts
Normal file
35
packages/ans/src/cbor.test.ts
Normal 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
129
packages/ans/src/cbor.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
packages/ans/src/client.test.ts
Normal file
49
packages/ans/src/client.test.ts
Normal 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
113
packages/ans/src/client.ts
Normal 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
15
packages/ans/src/index.ts
Normal 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';
|
||||||
34
packages/ans/src/name.test.ts
Normal file
34
packages/ans/src/name.test.ts
Normal 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
34
packages/ans/src/name.ts
Normal 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
80
packages/ans/src/types.ts
Normal 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>;
|
||||||
|
}
|
||||||
114
packages/ans/src/verify/cose.ts
Normal file
114
packages/ans/src/verify/cose.ts
Normal 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[] };
|
||||||
|
}
|
||||||
15
packages/ans/src/verify/es256.ts
Normal file
15
packages/ans/src/verify/es256.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
61
packages/ans/src/verify/index.ts
Normal file
61
packages/ans/src/verify/index.ts
Normal 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;
|
||||||
|
}
|
||||||
38
packages/ans/src/verify/merkle.test.ts
Normal file
38
packages/ans/src/verify/merkle.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
85
packages/ans/src/verify/merkle.ts
Normal file
85
packages/ans/src/verify/merkle.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/ans/src/verify/rootkeys.ts
Normal file
30
packages/ans/src/verify/rootkeys.ts
Normal 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;
|
||||||
|
}
|
||||||
86
packages/ans/src/verify/verify.test.ts
Normal file
86
packages/ans/src/verify/verify.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
9
packages/ans/tsconfig.json
Normal file
9
packages/ans/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue