|
| 1 | +# ANS SDK (`@logicsrc/ans`) |
| 2 | + |
| 3 | +`@logicsrc/ans` is the LogicSRC TypeScript SDK for the |
| 4 | +[Agent Name Service](https://github.com/agentnameservice) (ANS): the JS/TS |
| 5 | +client + **offline verifier** for resolving an agent *name* to a verifiable, |
| 6 | +versioned identity. |
| 7 | + |
| 8 | +ANS is "DNS for agents": where DNS resolves a domain to an address, ANS resolves |
| 9 | +an agent name (`ans://v1.0.0.my-agent.example.com`) to a cryptographic identity, |
| 10 | +anchored to domain ownership (DNS/ACME) and backed by a private CA plus an |
| 11 | +append-only **transparency log** (SCITT/COSE receipts, RFC 9162 / RFC 6962). |
| 12 | + |
| 13 | +Upstream ANS ships SDKs for Go, Java, and Rust — **but not JavaScript/TypeScript**, |
| 14 | +which is the language of the agent/MCP/web ecosystem (LogicSRC, sh1pt, AgentBBS). |
| 15 | +This SDK fills that gap and makes ANS a first-class identity source alongside the |
| 16 | +existing LogicSRC DID model. |
| 17 | + |
| 18 | +## Why this lives in LogicSRC |
| 19 | + |
| 20 | +- **No upstream TS SDK.** Go/Java/Rust only. This is a clean, reusable OSS |
| 21 | + artifact and a first-mover contribution. |
| 22 | +- **LogicSRC is already an identity layer.** Identity here is a DID (via the |
| 23 | + `coinpay` plugin's `did.auth`). ANS is the *naming + domain-anchored |
| 24 | + verification* layer that DIDs lack — they are complementary, not competing |
| 25 | + (see [DID bridge](#did-bridge)). |
| 26 | +- **Multiple in-house consumers.** [AgentGit](./agentgit.md) members, the |
| 27 | + `sh1pt` `registry-ans` ship target, AgentBBS join-time verification, and |
| 28 | + `commandboard` discovery can all consume one SDK. |
| 29 | + |
| 30 | +The split, stated once: |
| 31 | + |
| 32 | +- **ANS answers**: *what is this agent's canonical name, is it really it, and |
| 33 | + which version/endpoint?* — discovery + domain-anchored verification. |
| 34 | +- **LogicSRC DID answers**: *is this a portable identity I can authorize, pay, |
| 35 | + and score?* — sovereign identity + reputation + payment rails. |
| 36 | + |
| 37 | +## Scope |
| 38 | + |
| 39 | +In scope for the SDK: |
| 40 | + |
| 41 | +1. **Resolver** — `ans://` name → `AnsIdentity` (cert chain, endpoint, version, |
| 42 | + lifecycle events) via the registry HTTP API. |
| 43 | +2. **Offline verifier** — cryptographically verify a resolution against the |
| 44 | + transparency log **without trusting the operator** beyond advertised root |
| 45 | + keys. This is the hard, high-value part and ports the `ans-verify` semantics |
| 46 | + to TS. |
| 47 | +3. **Registration client** — open a registration, drive a domain-ownership |
| 48 | + challenge (DNS-01 / ACME), and read back the issued identity + receipt. |
| 49 | +4. **DID bridge** — map between an `ans://` name and a LogicSRC/`coinpay` DID. |
| 50 | + |
| 51 | +Out of scope (delegated, not reimplemented): |
| 52 | + |
| 53 | +- Running a registry or transparency log (that's the upstream Go `ans` server). |
| 54 | +- DNS record application — delegated to a DNS provider. In sh1pt that's its DNS |
| 55 | + adapters; in LogicSRC the caller supplies a `DnsApplier` (see |
| 56 | + [Registration](#registration)). |
| 57 | +- Certificate issuance / the private CA (server-side). |
| 58 | + |
| 59 | +## Architecture |
| 60 | + |
| 61 | +```txt |
| 62 | + @logicsrc/ans (this package) |
| 63 | + ┌──────────────────────────────────────────┐ |
| 64 | + │ AnsClient │ |
| 65 | + │ ├─ resolve(name) ── registry HTTP ──┼──► ANS registry |
| 66 | + │ ├─ register(req) ── registry HTTP ──┼──► (Go `ans` server) |
| 67 | + │ └─ rootKeys() ── registry HTTP ──┘ |
| 68 | + │ │ |
| 69 | + │ Verifier (offline, pure) │ |
| 70 | + │ ├─ verifyReceipt(receipt, rootKeys) │ COSE_Sign1 + Merkle proof |
| 71 | + │ └─ verifyResolution(identity, opts) │ (no network) |
| 72 | + │ │ |
| 73 | + │ DidBridge │ |
| 74 | + │ ├─ ansNameForDid(did) │ |
| 75 | + │ └─ didForAnsName(name) │ |
| 76 | + └──────────────────────────────────────────┘ |
| 77 | + ▲ ▲ |
| 78 | + │ consumed by │ |
| 79 | + sh1pt registry-ans AgentGit / AgentBBS / commandboard |
| 80 | +``` |
| 81 | + |
| 82 | +The **Verifier is pure and dependency-light** (crypto + CBOR/COSE only, no |
| 83 | +`fetch`), so it runs in Node, Deno, Bun, edge runtimes, and the browser, and is |
| 84 | +trivially unit-testable with fixtures captured from the upstream Go server. |
| 85 | + |
| 86 | +## Capabilities |
| 87 | + |
| 88 | +```txt |
| 89 | +name.resolve resolve an ans:// name to an identity |
| 90 | +name.verify offline-verify a resolution against the transparency log |
| 91 | +name.register open a registration + domain-ownership challenge |
| 92 | +name.status poll a pending registration / verification |
| 93 | +receipt.verify verify a SCITT COSE_Sign1 inclusion receipt |
| 94 | +rootkeys.fetch fetch + parse the registry root-keys (sumdb-note) |
| 95 | +did.bind bind an ans:// name to a LogicSRC DID |
| 96 | +did.resolve resolve a DID to its ans:// name (and back) |
| 97 | +``` |
| 98 | + |
| 99 | +## Package layout |
| 100 | + |
| 101 | +```txt |
| 102 | +packages/ans/ |
| 103 | + package.json @logicsrc/ans (ESM, tsc build, vitest) |
| 104 | + tsconfig.json extends ../../tsconfig.base.json |
| 105 | + src/ |
| 106 | + index.ts public exports |
| 107 | + types.ts AnsName, AnsIdentity, Receipt, RootKeys, … |
| 108 | + name.ts parse/format ans:// names (zod-validated) |
| 109 | + client.ts AnsClient — registry HTTP (resolve/register/status) |
| 110 | + verify/ |
| 111 | + receipt.ts COSE_Sign1 parse + ES256 verify |
| 112 | + merkle.ts RFC 6962 leaf hash + inclusion-proof walk |
| 113 | + rootkeys.ts sumdb-note root-keys parser + kid→key map |
| 114 | + index.ts verifyReceipt(), verifyResolution() |
| 115 | + did.ts DidBridge (ANS ↔ coinpay DID) |
| 116 | + index.test.ts unit tests (fixtures/ from upstream Go server) |
| 117 | + fixtures/ captured receipts, root-keys, resolutions |
| 118 | +``` |
| 119 | + |
| 120 | +`@logicsrc/ans` is a **leaf package** (like `@logicsrc/sdk`): it depends only on |
| 121 | +crypto/CBOR libraries and `@logicsrc/schemas` for shared types. The `coinpay` |
| 122 | +DID coupling stays behind a small injected interface so the verifier core has no |
| 123 | +LogicSRC dependency and could be published standalone. |
| 124 | + |
| 125 | +## Public API (TypeScript surface) |
| 126 | + |
| 127 | +```ts |
| 128 | +// ── names ─────────────────────────────────────────────────────────── |
| 129 | +/** ans://v<semver>.<agent>.<domain> */ |
| 130 | +export interface AnsName { |
| 131 | + raw: string; // "ans://v1.0.0.my-agent.example.com" |
| 132 | + version: string; // "1.0.0" |
| 133 | + agent: string; // "my-agent" |
| 134 | + domain: string; // "example.com" |
| 135 | +} |
| 136 | +export function parseAnsName(raw: string): AnsName; // throws on malformed |
| 137 | +export function formatAnsName(parts: Omit<AnsName, 'raw'>): string; |
| 138 | + |
| 139 | +// ── identity / receipts ───────────────────────────────────────────── |
| 140 | +export interface AnsIdentity { |
| 141 | + name: AnsName; |
| 142 | + endpoint?: string; // advertised agent endpoint |
| 143 | + capabilities: string[]; |
| 144 | + certChainPem: string; // identity cert (private-CA signed, mTLS) |
| 145 | + serverCertTlsa?: string; // optional BYOC pinned TLSA |
| 146 | + events: LifecycleEvent[]; // from the transparency log |
| 147 | + receipt: Receipt; // SCITT COSE_Sign1 inclusion receipt |
| 148 | +} |
| 149 | +export interface LifecycleEvent { type: string; at: string; payload?: unknown; } |
| 150 | +export interface Receipt { cbor: Uint8Array; } // raw COSE_Sign1 bytes |
| 151 | +export interface RootKeys { keys: Map<string /*4-byte kid hex*/, CryptoKey>; } |
| 152 | + |
| 153 | +// ── client (network) ──────────────────────────────────────────────── |
| 154 | +export interface AnsClientOptions { |
| 155 | + registryUrl: string; // e.g. https://registry.ans.dev |
| 156 | + token?: string; // for register/status |
| 157 | + pinnedRootKeysPem?: string; // skip /root-keys; trust this instead |
| 158 | + fetch?: typeof fetch; // injectable for tests/edge |
| 159 | +} |
| 160 | +export class AnsClient { |
| 161 | + constructor(opts: AnsClientOptions); |
| 162 | + resolve(name: string | AnsName): Promise<AnsIdentity>; |
| 163 | + rootKeys(): Promise<RootKeys>; |
| 164 | + register(req: RegisterRequest): Promise<Registration>; |
| 165 | + status(name: string | AnsName): Promise<RegistrationStatus>; |
| 166 | +} |
| 167 | + |
| 168 | +// ── verifier (offline, pure, no network) ──────────────────────────── |
| 169 | +export interface VerifyOptions { rootKeys: RootKeys; now?: Date; } |
| 170 | +export interface VerifyResult { ok: boolean; reason?: string; rootHashHex: string; } |
| 171 | +export function verifyReceipt(receipt: Receipt, opts: VerifyOptions): Promise<VerifyResult>; |
| 172 | +export function verifyResolution(id: AnsIdentity, opts: VerifyOptions): Promise<VerifyResult>; |
| 173 | + |
| 174 | +// ── registration ──────────────────────────────────────────────────── |
| 175 | +export interface RegisterRequest { |
| 176 | + agent: string; domain: string; version: string; |
| 177 | + endpoint?: string; capabilities?: string[]; |
| 178 | + verify: 'dns' | 'acme'; |
| 179 | + dns?: DnsApplier; // when set, SDK applies the challenge |
| 180 | +} |
| 181 | +/** Caller-supplied DNS automation (e.g. a sh1pt DNS adapter). */ |
| 182 | +export interface DnsApplier { |
| 183 | + upsertTxt(record: { name: string; value: string }): Promise<void>; |
| 184 | +} |
| 185 | +export interface Registration { name: AnsName; challenge: { type: 'TXT'; name: string; value: string }; } |
| 186 | +export interface RegistrationStatus { state: 'pending' | 'verifying' | 'live' | 'failed'; message?: string; } |
| 187 | + |
| 188 | +// ── DID bridge ────────────────────────────────────────────────────── |
| 189 | +export interface DidBridge { |
| 190 | + ansNameForDid(did: string): Promise<AnsName | null>; |
| 191 | + didForAnsName(name: string | AnsName): Promise<string | null>; |
| 192 | +} |
| 193 | +``` |
| 194 | + |
| 195 | +### Usage sketches |
| 196 | + |
| 197 | +```ts |
| 198 | +// Resolve + verify (the common path; trustless) |
| 199 | +const ans = new AnsClient({ registryUrl: 'https://registry.ans.dev' }); |
| 200 | +const id = await ans.resolve('ans://v1.0.0.my-agent.example.com'); |
| 201 | +const { ok } = await verifyResolution(id, { rootKeys: await ans.rootKeys() }); |
| 202 | +if (!ok) throw new Error('unverified agent identity'); |
| 203 | + |
| 204 | +// Register with automated DNS (DNS applier supplied by the caller, e.g. sh1pt) |
| 205 | +const reg = await ans.register({ |
| 206 | + agent: 'my-agent', domain: 'example.com', version: '1.0.0', |
| 207 | + endpoint: 'https://my-agent.example.com', verify: 'dns', |
| 208 | + dns: { upsertTxt: ({ name, value }) => dnsAdapter.upsertTxt(name, value) }, |
| 209 | +}); |
| 210 | +``` |
| 211 | + |
| 212 | +## Verification algorithm |
| 213 | + |
| 214 | +Ports the upstream `ans-verify` flow. Pure functions over bytes; the only trust |
| 215 | +input is the root keys (fetched once, or pinned): |
| 216 | + |
| 217 | +1. Obtain root keys: parse `/root-keys` (sumdb-note format) **or** the pinned |
| 218 | + PEM. Build a `kid (4-byte) → verifier key` map. |
| 219 | +2. Parse the receipt as `COSE_Sign1` (RFC 8152 tag 18, ES256). |
| 220 | +3. Extract the Merkle inclusion proof + leaf payload from the protected/unprotected |
| 221 | + headers. |
| 222 | +4. Compute the leaf hash via RFC 6962: `SHA-256(0x00 || payload)`. |
| 223 | +5. Walk the Merkle path from the leaf hash to the claimed root hash. |
| 224 | +6. ES256-verify the COSE `Sig_structure` signature using the `kid`-mapped key. |
| 225 | +7. Cross-check leaf-hash consistency and the resolved identity binding (name, |
| 226 | + cert, lifecycle). |
| 227 | + |
| 228 | +`verifyResolution()` wires the resolved `AnsIdentity` through steps 2–7 and also |
| 229 | +checks the cert chain binds to the resolved `domain`. |
| 230 | + |
| 231 | +## DID bridge |
| 232 | + |
| 233 | +The bridge is where ANS and the LogicSRC/`coinpay` DID model meet — directly the |
| 234 | +"CoinPay DID ↔ ANS" question. |
| 235 | + |
| 236 | +- **`did:web` under a verified ANS domain.** Once a name's domain is ANS-verified, |
| 237 | + the agent's `coinpay` DID can be published as `did:web:<domain>:<agent>` and |
| 238 | + resolved from the same anchor. ANS provides the discoverable human-readable |
| 239 | + name + transparency proof; the DID provides the portable identity + reputation |
| 240 | + receipts + payment rails. |
| 241 | +- **Binding direction.** `did.bind` records the `ans://` ↔ DID mapping (as an ANS |
| 242 | + lifecycle event and/or a `coinpay` DID service entry). `DidBridge` reads it both |
| 243 | + ways so AgentGit can keep authenticating with a DID while exposing a verifiable |
| 244 | + ANS name to the outside world. |
| 245 | +- **No DID minting here.** The SDK never issues DIDs (that's `coinpay`) and never |
| 246 | + issues ANS certs (that's the registry). It only *binds* and *resolves*. |
| 247 | + |
| 248 | +## Milestones |
| 249 | + |
| 250 | +- **M1 — Resolver + offline verifier.** `parseAnsName`, `AnsClient.resolve`, |
| 251 | + `rootKeys`, `verifyReceipt`/`verifyResolution`, fixtures from the upstream Go |
| 252 | + server. This is the standalone-publishable core and unblocks read-side |
| 253 | + consumers. |
| 254 | +- **M2 — Registration client.** `register`/`status` + the `DnsApplier` hook. |
| 255 | + Lets the sh1pt `registry-ans` target complete its `TODO(M2)` (apply challenge, |
| 256 | + verify, poll receipt) by delegating to this SDK instead of hand-rolled `fetch`. |
| 257 | +- **M3 — DID bridge.** `DidBridge` + `did:web` publication under the verified |
| 258 | + domain, wired through the `coinpay` plugin. Gate on M1+M2 and on how far the |
| 259 | + upstream IETF draft has stabilized. |
| 260 | + |
| 261 | +## Dependencies & testing |
| 262 | + |
| 263 | +- **Crypto/CBOR:** prefer WebCrypto (`crypto.subtle`, ES256) for portability; |
| 264 | + a minimal COSE/CBOR decoder (e.g. `cbor-x` or a vendored decoder) for |
| 265 | + `COSE_Sign1`. Keep the verifier free of Node-only APIs so it runs on edge and |
| 266 | + in the browser. |
| 267 | +- **Validation:** `zod` for name + wire-shape parsing (matches LogicSRC schema |
| 268 | + conventions; consider emitting the shapes into `@logicsrc/schemas`). |
| 269 | +- **Tests:** capture real `/root-keys`, resolutions, and receipts from a local |
| 270 | + upstream `ans` server into `fixtures/`; unit-test the verifier against them |
| 271 | + (happy path + tampered-payload, wrong-kid, bad-proof, expired-cert negatives). |
| 272 | + Verifier is `vitest run src` like the other packages, with no network. |
| 273 | + |
| 274 | +## Risks / open questions |
| 275 | + |
| 276 | +- **Draft-stage standard.** ANS wire formats (receipt headers, root-keys note) |
| 277 | + may shift; pin to a server commit for fixtures and version the SDK against it. |
| 278 | +- **More centralized than DIDs.** ANS uses a registry + private CA. Treat ANS as |
| 279 | + *naming/discovery/verification* and keep sovereign identity in the DID layer; |
| 280 | + do not let ANS become the system of record for identity. |
| 281 | +- **CBOR/COSE surface in TS.** No single blessed lib; the verifier's COSE_Sign1 |
| 282 | + handling is the main implementation risk — keep it small, vendored if needed, |
| 283 | + and fixture-driven. |
| 284 | +- **Trust bootstrap.** `pinnedRootKeysPem` vs `/root-keys` is a real trust |
| 285 | + decision; default to pinning for in-house consumers (AgentGit/AgentBBS) and |
| 286 | + document the TOFU tradeoff for `/root-keys`. |
| 287 | + |
| 288 | +## First consumers |
| 289 | + |
| 290 | +- **sh1pt `registry-ans` target** — replaces its hand-rolled `fetch` register |
| 291 | + call and completes M2 verification by depending on `@logicsrc/ans`. |
| 292 | +- **AgentGit / AgentBBS** — verify an agent's `ans://` name at join/merge time |
| 293 | + alongside the existing DID auth. |
| 294 | +- **commandboard discovery** — resolve + verify advertised agent endpoints. |
0 commit comments