Merge feat/ans-sdk: @logicsrc/ans spec + M1 (resolver + offline verifier)

This commit is contained in:
Anthony Ettinger 2026-06-24 15:30:20 +00:00
commit ffe37a01ce
21 changed files with 1303 additions and 28 deletions

294
docs/ans-sdk.md Normal file
View file

@ -0,0 +1,294 @@
# ANS SDK (`@logicsrc/ans`)
`@logicsrc/ans` is the LogicSRC TypeScript SDK for the
[Agent Name Service](https://github.com/agentnameservice) (ANS): the JS/TS
client + **offline verifier** for resolving an agent *name* to a verifiable,
versioned identity.
ANS is "DNS for agents": where DNS resolves a domain to an address, ANS resolves
an agent name (`ans://v1.0.0.my-agent.example.com`) to a cryptographic identity,
anchored to domain ownership (DNS/ACME) and backed by a private CA plus an
append-only **transparency log** (SCITT/COSE receipts, RFC 9162 / RFC 6962).
Upstream ANS ships SDKs for Go, Java, and Rust — **but not JavaScript/TypeScript**,
which is the language of the agent/MCP/web ecosystem (LogicSRC, sh1pt, AgentBBS).
This SDK fills that gap and makes ANS a first-class identity source alongside the
existing LogicSRC DID model.
## Why this lives in LogicSRC
- **No upstream TS SDK.** Go/Java/Rust only. This is a clean, reusable OSS
artifact and a first-mover contribution.
- **LogicSRC is already an identity layer.** Identity here is a DID (via the
`coinpay` plugin's `did.auth`). ANS is the *naming + domain-anchored
verification* layer that DIDs lack — they are complementary, not competing
(see [DID bridge](#did-bridge)).
- **Multiple in-house consumers.** [AgentGit](./agentgit.md) members, the
`sh1pt` `registry-ans` ship target, AgentBBS join-time verification, and
`commandboard` discovery can all consume one SDK.
The split, stated once:
- **ANS answers**: *what is this agent's canonical name, is it really it, and
which version/endpoint?* — discovery + domain-anchored verification.
- **LogicSRC DID answers**: *is this a portable identity I can authorize, pay,
and score?* — sovereign identity + reputation + payment rails.
## Scope
In scope for the SDK:
1. **Resolver**`ans://` name → `AnsIdentity` (cert chain, endpoint, version,
lifecycle events) via the registry HTTP API.
2. **Offline verifier** — cryptographically verify a resolution against the
transparency log **without trusting the operator** beyond advertised root
keys. This is the hard, high-value part and ports the `ans-verify` semantics
to TS.
3. **Registration client** — open a registration, drive a domain-ownership
challenge (DNS-01 / ACME), and read back the issued identity + receipt.
4. **DID bridge** — map between an `ans://` name and a LogicSRC/`coinpay` DID.
Out of scope (delegated, not reimplemented):
- Running a registry or transparency log (that's the upstream Go `ans` server).
- DNS record application — delegated to a DNS provider. In sh1pt that's its DNS
adapters; in LogicSRC the caller supplies a `DnsApplier` (see
[Registration](#registration)).
- Certificate issuance / the private CA (server-side).
## Architecture
```txt
@logicsrc/ans (this package)
┌──────────────────────────────────────────┐
│ AnsClient │
│ ├─ resolve(name) ── registry HTTP ──┼──► ANS registry
│ ├─ register(req) ── registry HTTP ──┼──► (Go `ans` server)
│ └─ rootKeys() ── registry HTTP ──┘
│ │
│ Verifier (offline, pure) │
│ ├─ verifyReceipt(receipt, rootKeys) │ COSE_Sign1 + Merkle proof
│ └─ verifyResolution(identity, opts) │ (no network)
│ │
│ DidBridge │
│ ├─ ansNameForDid(did) │
│ └─ didForAnsName(name) │
└──────────────────────────────────────────┘
▲ ▲
│ consumed by │
sh1pt registry-ans AgentGit / AgentBBS / commandboard
```
The **Verifier is pure and dependency-light** (crypto + CBOR/COSE only, no
`fetch`), so it runs in Node, Deno, Bun, edge runtimes, and the browser, and is
trivially unit-testable with fixtures captured from the upstream Go server.
## Capabilities
```txt
name.resolve resolve an ans:// name to an identity
name.verify offline-verify a resolution against the transparency log
name.register open a registration + domain-ownership challenge
name.status poll a pending registration / verification
receipt.verify verify a SCITT COSE_Sign1 inclusion receipt
rootkeys.fetch fetch + parse the registry root-keys (sumdb-note)
did.bind bind an ans:// name to a LogicSRC DID
did.resolve resolve a DID to its ans:// name (and back)
```
## Package layout
```txt
packages/ans/
package.json @logicsrc/ans (ESM, tsc build, vitest)
tsconfig.json extends ../../tsconfig.base.json
src/
index.ts public exports
types.ts AnsName, AnsIdentity, Receipt, RootKeys, …
name.ts parse/format ans:// names (zod-validated)
client.ts AnsClient — registry HTTP (resolve/register/status)
verify/
receipt.ts COSE_Sign1 parse + ES256 verify
merkle.ts RFC 6962 leaf hash + inclusion-proof walk
rootkeys.ts sumdb-note root-keys parser + kid→key map
index.ts verifyReceipt(), verifyResolution()
did.ts DidBridge (ANS ↔ coinpay DID)
index.test.ts unit tests (fixtures/ from upstream Go server)
fixtures/ captured receipts, root-keys, resolutions
```
`@logicsrc/ans` is a **leaf package** (like `@logicsrc/sdk`): it depends only on
crypto/CBOR libraries and `@logicsrc/schemas` for shared types. The `coinpay`
DID coupling stays behind a small injected interface so the verifier core has no
LogicSRC dependency and could be published standalone.
## Public API (TypeScript surface)
```ts
// ── names ───────────────────────────────────────────────────────────
/** ans://v<semver>.<agent>.<domain> */
export interface AnsName {
raw: string; // "ans://v1.0.0.my-agent.example.com"
version: string; // "1.0.0"
agent: string; // "my-agent"
domain: string; // "example.com"
}
export function parseAnsName(raw: string): AnsName; // throws on malformed
export function formatAnsName(parts: Omit<AnsName, 'raw'>): string;
// ── identity / receipts ─────────────────────────────────────────────
export interface AnsIdentity {
name: AnsName;
endpoint?: string; // advertised agent endpoint
capabilities: string[];
certChainPem: string; // identity cert (private-CA signed, mTLS)
serverCertTlsa?: string; // optional BYOC pinned TLSA
events: LifecycleEvent[]; // from the transparency log
receipt: Receipt; // SCITT COSE_Sign1 inclusion receipt
}
export interface LifecycleEvent { type: string; at: string; payload?: unknown; }
export interface Receipt { cbor: Uint8Array; } // raw COSE_Sign1 bytes
export interface RootKeys { keys: Map<string /*4-byte kid hex*/, CryptoKey>; }
// ── client (network) ────────────────────────────────────────────────
export interface AnsClientOptions {
registryUrl: string; // e.g. https://registry.ans.dev
token?: string; // for register/status
pinnedRootKeysPem?: string; // skip /root-keys; trust this instead
fetch?: typeof fetch; // injectable for tests/edge
}
export class AnsClient {
constructor(opts: AnsClientOptions);
resolve(name: string | AnsName): Promise<AnsIdentity>;
rootKeys(): Promise<RootKeys>;
register(req: RegisterRequest): Promise<Registration>;
status(name: string | AnsName): Promise<RegistrationStatus>;
}
// ── verifier (offline, pure, no network) ────────────────────────────
export interface VerifyOptions { rootKeys: RootKeys; now?: Date; }
export interface VerifyResult { ok: boolean; reason?: string; rootHashHex: string; }
export function verifyReceipt(receipt: Receipt, opts: VerifyOptions): Promise<VerifyResult>;
export function verifyResolution(id: AnsIdentity, opts: VerifyOptions): Promise<VerifyResult>;
// ── registration ────────────────────────────────────────────────────
export interface RegisterRequest {
agent: string; domain: string; version: string;
endpoint?: string; capabilities?: string[];
verify: 'dns' | 'acme';
dns?: DnsApplier; // when set, SDK applies the challenge
}
/** Caller-supplied DNS automation (e.g. a sh1pt DNS adapter). */
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; }
// ── DID bridge ──────────────────────────────────────────────────────
export interface DidBridge {
ansNameForDid(did: string): Promise<AnsName | null>;
didForAnsName(name: string | AnsName): Promise<string | null>;
}
```
### Usage sketches
```ts
// Resolve + verify (the common path; trustless)
const ans = new AnsClient({ registryUrl: 'https://registry.ans.dev' });
const id = await ans.resolve('ans://v1.0.0.my-agent.example.com');
const { ok } = await verifyResolution(id, { rootKeys: await ans.rootKeys() });
if (!ok) throw new Error('unverified agent identity');
// Register with automated DNS (DNS applier supplied by the caller, e.g. sh1pt)
const reg = await ans.register({
agent: 'my-agent', domain: 'example.com', version: '1.0.0',
endpoint: 'https://my-agent.example.com', verify: 'dns',
dns: { upsertTxt: ({ name, value }) => dnsAdapter.upsertTxt(name, value) },
});
```
## Verification algorithm
Ports the upstream `ans-verify` flow. Pure functions over bytes; the only trust
input is the root keys (fetched once, or pinned):
1. Obtain root keys: parse `/root-keys` (sumdb-note format) **or** the pinned
PEM. Build a `kid (4-byte) → verifier key` map.
2. Parse the receipt as `COSE_Sign1` (RFC 8152 tag 18, ES256).
3. Extract the Merkle inclusion proof + leaf payload from the protected/unprotected
headers.
4. Compute the leaf hash via RFC 6962: `SHA-256(0x00 || payload)`.
5. Walk the Merkle path from the leaf hash to the claimed root hash.
6. ES256-verify the COSE `Sig_structure` signature using the `kid`-mapped key.
7. Cross-check leaf-hash consistency and the resolved identity binding (name,
cert, lifecycle).
`verifyResolution()` wires the resolved `AnsIdentity` through steps 27 and also
checks the cert chain binds to the resolved `domain`.
## DID bridge
The bridge is where ANS and the LogicSRC/`coinpay` DID model meet — directly the
"CoinPay DID ↔ ANS" question.
- **`did:web` under a verified ANS domain.** Once a name's domain is ANS-verified,
the agent's `coinpay` DID can be published as `did:web:<domain>:<agent>` and
resolved from the same anchor. ANS provides the discoverable human-readable
name + transparency proof; the DID provides the portable identity + reputation
receipts + payment rails.
- **Binding direction.** `did.bind` records the `ans://` ↔ DID mapping (as an ANS
lifecycle event and/or a `coinpay` DID service entry). `DidBridge` reads it both
ways so AgentGit can keep authenticating with a DID while exposing a verifiable
ANS name to the outside world.
- **No DID minting here.** The SDK never issues DIDs (that's `coinpay`) and never
issues ANS certs (that's the registry). It only *binds* and *resolves*.
## Milestones
- **M1 — Resolver + offline verifier.** `parseAnsName`, `AnsClient.resolve`,
`rootKeys`, `verifyReceipt`/`verifyResolution`, fixtures from the upstream Go
server. This is the standalone-publishable core and unblocks read-side
consumers.
- **M2 — Registration client.** `register`/`status` + the `DnsApplier` hook.
Lets the sh1pt `registry-ans` target complete its `TODO(M2)` (apply challenge,
verify, poll receipt) by delegating to this SDK instead of hand-rolled `fetch`.
- **M3 — DID bridge.** `DidBridge` + `did:web` publication under the verified
domain, wired through the `coinpay` plugin. Gate on M1+M2 and on how far the
upstream IETF draft has stabilized.
## Dependencies & testing
- **Crypto/CBOR:** prefer WebCrypto (`crypto.subtle`, ES256) for portability;
a minimal COSE/CBOR decoder (e.g. `cbor-x` or a vendored decoder) for
`COSE_Sign1`. Keep the verifier free of Node-only APIs so it runs on edge and
in the browser.
- **Validation:** `zod` for name + wire-shape parsing (matches LogicSRC schema
conventions; consider emitting the shapes into `@logicsrc/schemas`).
- **Tests:** capture real `/root-keys`, resolutions, and receipts from a local
upstream `ans` server into `fixtures/`; unit-test the verifier against them
(happy path + tampered-payload, wrong-kid, bad-proof, expired-cert negatives).
Verifier is `vitest run src` like the other packages, with no network.
## Risks / open questions
- **Draft-stage standard.** ANS wire formats (receipt headers, root-keys note)
may shift; pin to a server commit for fixtures and version the SDK against it.
- **More centralized than DIDs.** ANS uses a registry + private CA. Treat ANS as
*naming/discovery/verification* and keep sovereign identity in the DID layer;
do not let ANS become the system of record for identity.
- **CBOR/COSE surface in TS.** No single blessed lib; the verifier's COSE_Sign1
handling is the main implementation risk — keep it small, vendored if needed,
and fixture-driven.
- **Trust bootstrap.** `pinnedRootKeysPem` vs `/root-keys` is a real trust
decision; default to pinning for in-house consumers (AgentGit/AgentBBS) and
document the TOFU tradeoff for `/root-keys`.
## First consumers
- **sh1pt `registry-ans` target** — replaces its hand-rolled `fetch` register
call and completes M2 verification by depending on `@logicsrc/ans`.
- **AgentGit / AgentBBS** — verify an agent's `ans://` name at join/merge time
alongside the existing DID auth.
- **commandboard discovery** — resolve + verify advertised agent endpoints.

38
package-lock.json generated
View file

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

View file

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

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

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

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

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

View file

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

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

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

View file

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

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

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

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

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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