diff --git a/docs/ans-sdk.md b/docs/ans-sdk.md new file mode 100644 index 0000000..d3c7c7c --- /dev/null +++ b/docs/ans-sdk.md @@ -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.. */ +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): 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; } + +// ── 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; + rootKeys(): Promise; + register(req: RegisterRequest): Promise; + status(name: string | AnsName): Promise; +} + +// ── 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; +export function verifyResolution(id: AnsIdentity, opts: VerifyOptions): Promise; + +// ── 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; +} +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; + didForAnsName(name: string | AnsName): Promise; +} +``` + +### 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 2–7 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::` 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.