Skip to content

Commit ffe37a0

Browse files
committed
Merge feat/ans-sdk: @logicsrc/ans spec + M1 (resolver + offline verifier)
2 parents 2f07f74 + 29975b2 commit ffe37a0

21 files changed

Lines changed: 1303 additions & 28 deletions

docs/ans-sdk.md

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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

Comments
 (0)