mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 23:07:29 +00:00
Merge feat/ans-sdk: @logicsrc/ans spec + M1 (resolver + offline verifier)
This commit is contained in:
commit
ffe37a01ce
21 changed files with 1303 additions and 28 deletions
294
docs/ans-sdk.md
Normal file
294
docs/ans-sdk.md
Normal 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 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:<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
38
package-lock.json
generated
|
|
@ -1388,6 +1388,10 @@
|
||||||
"resolved": "packages/agentstack",
|
"resolved": "packages/agentstack",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@logicsrc/ans": {
|
||||||
|
"resolved": "packages/ans",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@logicsrc/cli": {
|
"node_modules/@logicsrc/cli": {
|
||||||
"resolved": "packages/cli",
|
"resolved": "packages/cli",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
@ -4495,7 +4499,6 @@
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -5165,7 +5168,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5182,7 +5184,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5199,7 +5200,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5216,7 +5216,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5233,7 +5232,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5250,7 +5248,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5267,7 +5264,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5284,7 +5280,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5301,7 +5296,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5318,7 +5312,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5335,7 +5328,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5352,7 +5344,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5369,7 +5360,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5386,7 +5376,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5403,7 +5392,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5420,7 +5408,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5437,7 +5424,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5454,7 +5440,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5471,7 +5456,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5488,7 +5472,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5505,7 +5488,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5522,7 +5504,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5539,7 +5520,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5556,7 +5536,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5573,7 +5552,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -5590,7 +5568,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -6061,6 +6038,13 @@
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/ans": {
|
||||||
|
"name": "@logicsrc/ans",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@logicsrc/cli",
|
"name": "@logicsrc/cli",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
"apps/*"
|
"apps/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build",
|
"build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build",
|
||||||
"start": "npm --workspace @logicsrc/web run start",
|
"start": "npm --workspace @logicsrc/web run start",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"check": "npm run build && npm run test",
|
"check": "npm run build && npm run test",
|
||||||
|
|
|
||||||
15
packages/ans/package.json
Normal file
15
packages/ans/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "@logicsrc/ans",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "LogicSRC TypeScript SDK for the Agent Name Service (ANS): resolver + offline transparency-log verifier.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"test": "vitest run src"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
packages/ans/src/bytes.ts
Normal file
55
packages/ans/src/bytes.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Byte + hash utilities. Kept dependency-free and runtime-portable (Node, Deno,
|
||||||
|
// Bun, edge, browser) — only WebCrypto + standard text/base64 globals.
|
||||||
|
|
||||||
|
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', data as unknown as ArrayBuffer);
|
||||||
|
return new Uint8Array(digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function concat(...parts: Uint8Array[]): Uint8Array {
|
||||||
|
const total = parts.reduce((n, p) => n + p.length, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
out.set(part, offset);
|
||||||
|
offset += part.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toHex(bytes: Uint8Array): string {
|
||||||
|
let hex = '';
|
||||||
|
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromHex(hex: string): Uint8Array {
|
||||||
|
const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
|
||||||
|
if (clean.length % 2 !== 0) throw new Error('odd-length hex string');
|
||||||
|
const out = new Uint8Array(clean.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromBase64(b64: string): Uint8Array {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Length-safe constant-time-ish comparison for hashes. */
|
||||||
|
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function utf8(text: string): Uint8Array {
|
||||||
|
return new TextEncoder().encode(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromUtf8(bytes: Uint8Array): string {
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
35
packages/ans/src/cbor.test.ts
Normal file
35
packages/ans/src/cbor.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CborTag, decodeCbor, encodeCbor } from './cbor.js';
|
||||||
|
|
||||||
|
describe('cbor codec', () => {
|
||||||
|
it('encodes known RFC 8949 vectors', () => {
|
||||||
|
expect([...encodeCbor(0)]).toEqual([0x00]);
|
||||||
|
expect([...encodeCbor(23)]).toEqual([0x17]);
|
||||||
|
expect([...encodeCbor(24)]).toEqual([0x18, 0x18]);
|
||||||
|
expect([...encodeCbor(1000)]).toEqual([0x19, 0x03, 0xe8]);
|
||||||
|
expect([...encodeCbor(-1)]).toEqual([0x20]);
|
||||||
|
expect([...encodeCbor(-7)]).toEqual([0x26]);
|
||||||
|
expect([...encodeCbor('a')]).toEqual([0x61, 0x61]);
|
||||||
|
expect([...encodeCbor(new Uint8Array([1, 2, 3]))]).toEqual([0x43, 0x01, 0x02, 0x03]);
|
||||||
|
expect([...encodeCbor([1, 2, 3])]).toEqual([0x83, 0x01, 0x02, 0x03]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips ints, bytes, strings, arrays, maps, tags', () => {
|
||||||
|
const value = new CborTag(18, [
|
||||||
|
new Uint8Array([0xa1, 0x01, 0x26]),
|
||||||
|
new Map<unknown, unknown>([
|
||||||
|
[4, new Uint8Array([1, 2, 3, 4])],
|
||||||
|
['ans-proof', new Map<unknown, unknown>([['index', 1], ['size', 3], ['path', [new Uint8Array([9])]]])],
|
||||||
|
]),
|
||||||
|
new Uint8Array([0xde, 0xad]),
|
||||||
|
new Uint8Array(64).fill(7),
|
||||||
|
]);
|
||||||
|
const decoded = decodeCbor(encodeCbor(value as never));
|
||||||
|
expect(decoded).toEqual(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a large (4-byte and 8-byte) integer', () => {
|
||||||
|
expect(decodeCbor(encodeCbor(70000))).toBe(70000);
|
||||||
|
expect(decodeCbor(encodeCbor(5_000_000_000))).toBe(5_000_000_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
129
packages/ans/src/cbor.ts
Normal file
129
packages/ans/src/cbor.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
// Minimal CBOR (RFC 8949) codec — the subset needed for COSE_Sign1 receipts:
|
||||||
|
// unsigned/negative ints, byte strings, text strings, arrays, maps, tags, and
|
||||||
|
// the simple values null/true/false. Maps decode to JS Map so integer header
|
||||||
|
// labels (COSE alg=1, kid=4) round-trip without key-coercion.
|
||||||
|
//
|
||||||
|
// This is intentionally small and fixture-driven; it is NOT a general-purpose
|
||||||
|
// CBOR implementation (no floats, no indefinite-length items, no bignums).
|
||||||
|
|
||||||
|
export class CborTag {
|
||||||
|
constructor(public readonly tag: number, public readonly value: CborValue) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CborValue =
|
||||||
|
| number
|
||||||
|
| bigint
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| Uint8Array
|
||||||
|
| string
|
||||||
|
| CborValue[]
|
||||||
|
| Map<CborValue, CborValue>
|
||||||
|
| CborTag;
|
||||||
|
|
||||||
|
export function encodeCbor(value: CborValue): Uint8Array {
|
||||||
|
const out: number[] = [];
|
||||||
|
writeValue(out, value);
|
||||||
|
return Uint8Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeCbor(bytes: Uint8Array): CborValue {
|
||||||
|
const reader = { bytes, pos: 0 };
|
||||||
|
const value = readValue(reader);
|
||||||
|
if (reader.pos !== bytes.length) throw new Error('cbor: trailing bytes');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeHead(out: number[], major: number, n: number | bigint): void {
|
||||||
|
const mt = major << 5;
|
||||||
|
const big = BigInt(n);
|
||||||
|
if (big < 24n) out.push(mt | Number(big));
|
||||||
|
else if (big < 0x100n) out.push(mt | 24, Number(big));
|
||||||
|
else if (big < 0x10000n) out.push(mt | 25, Number(big >> 8n) & 0xff, Number(big) & 0xff);
|
||||||
|
else if (big < 0x100000000n) {
|
||||||
|
out.push(mt | 26, Number(big >> 24n) & 0xff, Number(big >> 16n) & 0xff, Number(big >> 8n) & 0xff, Number(big) & 0xff);
|
||||||
|
} else {
|
||||||
|
out.push(mt | 27);
|
||||||
|
for (let shift = 56n; shift >= 0n; shift -= 8n) out.push(Number((big >> shift) & 0xffn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeValue(out: number[], value: CborValue): void {
|
||||||
|
if (value === null) { out.push(0xf6); return; }
|
||||||
|
if (value === false) { out.push(0xf4); return; }
|
||||||
|
if (value === true) { out.push(0xf5); return; }
|
||||||
|
if (typeof value === 'number' || typeof value === 'bigint') {
|
||||||
|
if (value < 0) writeHead(out, 1, -BigInt(value) - 1n);
|
||||||
|
else writeHead(out, 0, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value instanceof Uint8Array) {
|
||||||
|
writeHead(out, 2, value.length);
|
||||||
|
for (const b of value) out.push(b);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const bytes = new TextEncoder().encode(value);
|
||||||
|
writeHead(out, 3, bytes.length);
|
||||||
|
for (const b of bytes) out.push(b);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
writeHead(out, 4, value.length);
|
||||||
|
for (const item of value) writeValue(out, item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value instanceof Map) {
|
||||||
|
writeHead(out, 5, value.size);
|
||||||
|
for (const [k, v] of value) { writeValue(out, k); writeValue(out, v); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value instanceof CborTag) {
|
||||||
|
writeHead(out, 6, value.tag);
|
||||||
|
writeValue(out, value.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('cbor: unsupported value');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Reader { bytes: Uint8Array; pos: number; }
|
||||||
|
|
||||||
|
function readArg(r: Reader, info: number): number {
|
||||||
|
if (info < 24) return info;
|
||||||
|
if (info === 24) return r.bytes[r.pos++];
|
||||||
|
if (info === 25) { const v = (r.bytes[r.pos] << 8) | r.bytes[r.pos + 1]; r.pos += 2; return v; }
|
||||||
|
if (info === 26) {
|
||||||
|
const v = (r.bytes[r.pos] * 0x1000000) + (r.bytes[r.pos + 1] << 16) + (r.bytes[r.pos + 2] << 8) + r.bytes[r.pos + 3];
|
||||||
|
r.pos += 4;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (info === 27) {
|
||||||
|
let v = 0n;
|
||||||
|
for (let i = 0; i < 8; i++) v = (v << 8n) | BigInt(r.bytes[r.pos++]);
|
||||||
|
if (v > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('cbor: integer too large');
|
||||||
|
return Number(v);
|
||||||
|
}
|
||||||
|
throw new Error(`cbor: bad additional info ${info}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readValue(r: Reader): CborValue {
|
||||||
|
const initial = r.bytes[r.pos++];
|
||||||
|
const major = initial >> 5;
|
||||||
|
const info = initial & 0x1f;
|
||||||
|
switch (major) {
|
||||||
|
case 0: return readArg(r, info);
|
||||||
|
case 1: return -1 - readArg(r, info);
|
||||||
|
case 2: { const len = readArg(r, info); const v = r.bytes.slice(r.pos, r.pos + len); r.pos += len; return v; }
|
||||||
|
case 3: { const len = readArg(r, info); const v = new TextDecoder().decode(r.bytes.slice(r.pos, r.pos + len)); r.pos += len; return v; }
|
||||||
|
case 4: { const len = readArg(r, info); const arr: CborValue[] = []; for (let i = 0; i < len; i++) arr.push(readValue(r)); return arr; }
|
||||||
|
case 5: { const len = readArg(r, info); const map = new Map<CborValue, CborValue>(); for (let i = 0; i < len; i++) { const k = readValue(r); map.set(k, readValue(r)); } return map; }
|
||||||
|
case 6: { const tag = readArg(r, info); return new CborTag(tag, readValue(r)); }
|
||||||
|
case 7:
|
||||||
|
if (info === 20) return false;
|
||||||
|
if (info === 21) return true;
|
||||||
|
if (info === 22) return null;
|
||||||
|
throw new Error(`cbor: unsupported simple value ${info}`);
|
||||||
|
default:
|
||||||
|
throw new Error(`cbor: unknown major type ${major}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
packages/ans/src/client.test.ts
Normal file
49
packages/ans/src/client.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AnsClient } from './client.js';
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown): Response {
|
||||||
|
return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(body) } as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AnsClient', () => {
|
||||||
|
it('resolves a name and decodes the base64 receipt', async () => {
|
||||||
|
const receiptB64 = btoa(String.fromCharCode(0xd2, 0x84, 0x40));
|
||||||
|
const fetchMock = vi.fn(async () => jsonResponse({
|
||||||
|
name: 'ans://v1.0.0.my-agent.example.com',
|
||||||
|
endpoint: 'https://my-agent.example.com',
|
||||||
|
capabilities: ['chat'],
|
||||||
|
receipt: receiptB64,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const client = new AnsClient({ registryUrl: 'https://registry.ans.dev/', fetch: fetchMock as unknown as typeof fetch });
|
||||||
|
const id = await client.resolve('ans://v1.0.0.my-agent.example.com');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'https://registry.ans.dev/v1/resolve/ans%3A%2F%2Fv1.0.0.my-agent.example.com',
|
||||||
|
expect.objectContaining({ method: 'GET' }),
|
||||||
|
);
|
||||||
|
expect(id.name.agent).toBe('my-agent');
|
||||||
|
expect(id.capabilities).toEqual(['chat']);
|
||||||
|
expect([...id.receipt.cbor]).toEqual([0xd2, 0x84, 0x40]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register applies the DNS challenge via the supplied applier', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => jsonResponse({ challengeToken: 'tok-xyz', recordName: '_ans-challenge.my-agent.example.com' }));
|
||||||
|
const upsertTxt = vi.fn(async () => {});
|
||||||
|
|
||||||
|
const client = new AnsClient({ registryUrl: 'https://registry.ans.dev', token: 't', fetch: fetchMock as unknown as typeof fetch });
|
||||||
|
const reg = await client.register({ agent: 'my-agent', domain: 'example.com', version: '1.0.0', verify: 'dns', dns: { upsertTxt } });
|
||||||
|
|
||||||
|
expect(reg.name.raw).toBe('ans://v1.0.0.my-agent.example.com');
|
||||||
|
expect(reg.challenge).toEqual({ type: 'TXT', name: '_ans-challenge.my-agent.example.com', value: 'tok-xyz' });
|
||||||
|
expect(upsertTxt).toHaveBeenCalledWith({ name: '_ans-challenge.my-agent.example.com', value: 'tok-xyz' });
|
||||||
|
const [, init] = fetchMock.mock.calls[0];
|
||||||
|
expect((init as RequestInit).headers).toMatchObject({ Authorization: 'Bearer t' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a non-OK response', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: false, status: 404, statusText: 'Not Found', text: async () => 'no such name' } as unknown as Response));
|
||||||
|
const client = new AnsClient({ registryUrl: 'https://registry.ans.dev', fetch: fetchMock as unknown as typeof fetch });
|
||||||
|
await expect(client.resolve('ans://v1.0.0.ghost.example.com')).rejects.toThrow('ANS GET /v1/resolve/');
|
||||||
|
});
|
||||||
|
});
|
||||||
113
packages/ans/src/client.ts
Normal file
113
packages/ans/src/client.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// ANS registry HTTP client (the network half). Pairs with the offline verifier
|
||||||
|
// in ./verify — callers should resolve() then verifyResolution() rather than
|
||||||
|
// trusting resolve() output directly.
|
||||||
|
|
||||||
|
import { toAnsName } from './name.js';
|
||||||
|
import { rootKeysFromEntries, type RootKeyEntry } from './verify/rootkeys.js';
|
||||||
|
import { fromBase64 } from './bytes.js';
|
||||||
|
import type {
|
||||||
|
AnsIdentity,
|
||||||
|
AnsName,
|
||||||
|
RegisterRequest,
|
||||||
|
Registration,
|
||||||
|
RegistrationStatus,
|
||||||
|
RootKeys,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export interface AnsClientOptions {
|
||||||
|
registryUrl: string;
|
||||||
|
token?: string;
|
||||||
|
/** Pin verifier keys instead of trusting GET /root-keys (recommended in-house). */
|
||||||
|
pinnedRootKeys?: RootKeyEntry[];
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolveResponse {
|
||||||
|
name: string;
|
||||||
|
endpoint?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
certChainPem?: string;
|
||||||
|
serverCertTlsa?: string;
|
||||||
|
events?: { type: string; at: string; payload?: unknown }[];
|
||||||
|
/** base64-encoded COSE_Sign1 receipt bytes. */
|
||||||
|
receipt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnsClient {
|
||||||
|
private readonly base: string;
|
||||||
|
private readonly doFetch: typeof fetch;
|
||||||
|
|
||||||
|
constructor(private readonly opts: AnsClientOptions) {
|
||||||
|
this.base = opts.registryUrl.replace(/\/+$/, '');
|
||||||
|
const f = opts.fetch ?? globalThis.fetch;
|
||||||
|
if (!f) throw new Error('AnsClient: no fetch available; pass opts.fetch');
|
||||||
|
this.doFetch = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(name: string | AnsName): Promise<AnsIdentity> {
|
||||||
|
const ans = toAnsName(name);
|
||||||
|
const body = await this.get<ResolveResponse>(`/v1/resolve/${encodeURIComponent(ans.raw)}`);
|
||||||
|
return {
|
||||||
|
name: toAnsName(body.name),
|
||||||
|
endpoint: body.endpoint,
|
||||||
|
capabilities: body.capabilities ?? [],
|
||||||
|
certChainPem: body.certChainPem,
|
||||||
|
serverCertTlsa: body.serverCertTlsa,
|
||||||
|
events: body.events ?? [],
|
||||||
|
receipt: { cbor: fromBase64(body.receipt) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async rootKeys(): Promise<RootKeys> {
|
||||||
|
if (this.opts.pinnedRootKeys) return rootKeysFromEntries(this.opts.pinnedRootKeys);
|
||||||
|
// M1: registry serves a JWKS-style list. sumdb-note parsing is M2.
|
||||||
|
const body = await this.get<{ keys: RootKeyEntry[] }>('/root-keys');
|
||||||
|
return rootKeysFromEntries(body.keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(req: RegisterRequest): Promise<Registration> {
|
||||||
|
const body = await this.post<{ challengeToken: string; recordName?: string }>('/v1/register', {
|
||||||
|
agent: req.agent,
|
||||||
|
domain: req.domain,
|
||||||
|
version: req.version,
|
||||||
|
endpoint: req.endpoint,
|
||||||
|
capabilities: req.capabilities ?? [],
|
||||||
|
verify: req.verify,
|
||||||
|
});
|
||||||
|
const name = toAnsName(`ans://v${req.version}.${req.agent}.${req.domain}`);
|
||||||
|
const challenge = {
|
||||||
|
type: 'TXT' as const,
|
||||||
|
name: body.recordName ?? `_ans-challenge.${req.agent}.${req.domain}`,
|
||||||
|
value: body.challengeToken,
|
||||||
|
};
|
||||||
|
if (req.dns) await req.dns.upsertTxt({ name: challenge.name, value: challenge.value });
|
||||||
|
return { name, challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(name: string | AnsName): Promise<RegistrationStatus> {
|
||||||
|
const ans = toAnsName(name);
|
||||||
|
return this.get<RegistrationStatus>(`/v1/status/${encodeURIComponent(ans.raw)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async get<T>(path: string): Promise<T> {
|
||||||
|
return this.request<T>('GET', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return this.request<T>('POST', path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
|
if (this.opts.token) headers.Authorization = `Bearer ${this.opts.token}`;
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
|
const response = await this.doFetch(`${this.base}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) throw new Error(`ANS ${method} ${path} failed: ${response.status} ${text || response.statusText}`);
|
||||||
|
return (text ? JSON.parse(text) : {}) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
packages/ans/src/index.ts
Normal file
15
packages/ans/src/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// @logicsrc/ans — TypeScript SDK for the Agent Name Service (ANS).
|
||||||
|
// See docs/ans-sdk.md. M1: resolver + offline transparency-log verifier.
|
||||||
|
|
||||||
|
export * from './types.js';
|
||||||
|
export { parseAnsName, formatAnsName, toAnsName } from './name.js';
|
||||||
|
export { AnsClient, type AnsClientOptions } from './client.js';
|
||||||
|
export { verifyReceipt, verifyResolution } from './verify/index.js';
|
||||||
|
export { rootKeysFromEntries, type RootKeyEntry } from './verify/rootkeys.js';
|
||||||
|
|
||||||
|
// Lower-level building blocks (stable enough to reuse; handy for tooling/tests).
|
||||||
|
export { merkleRoot, inclusionProof, verifyInclusion, leafHash, nodeHash } from './verify/merkle.js';
|
||||||
|
export { buildReceipt, parseReceipt, sigStructure, type ReceiptEnvelope, type InclusionProof } from './verify/cose.js';
|
||||||
|
export { importEs256VerifyKey, verifyEs256 } from './verify/es256.js';
|
||||||
|
export { encodeCbor, decodeCbor, CborTag, type CborValue } from './cbor.js';
|
||||||
|
export * as bytes from './bytes.js';
|
||||||
34
packages/ans/src/name.test.ts
Normal file
34
packages/ans/src/name.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatAnsName, parseAnsName } from './name.js';
|
||||||
|
|
||||||
|
describe('parseAnsName', () => {
|
||||||
|
it('parses a well-formed ans:// name', () => {
|
||||||
|
expect(parseAnsName('ans://v1.0.0.my-agent.example.com')).toEqual({
|
||||||
|
raw: 'ans://v1.0.0.my-agent.example.com',
|
||||||
|
version: '1.0.0',
|
||||||
|
agent: 'my-agent',
|
||||||
|
domain: 'example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses multi-label domains and prerelease versions', () => {
|
||||||
|
const parsed = parseAnsName('ans://v2.3.1-beta.bot.agents.example.co.uk');
|
||||||
|
expect(parsed.version).toBe('2.3.1-beta');
|
||||||
|
expect(parsed.agent).toBe('bot');
|
||||||
|
expect(parsed.domain).toBe('agents.example.co.uk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'https://v1.0.0.a.example.com',
|
||||||
|
'ans://1.0.0.a.example.com',
|
||||||
|
'ans://v1.0.a.example.com',
|
||||||
|
'ans://v1.0.0.bad agent.example.com',
|
||||||
|
'ans://v1.0.0.agent',
|
||||||
|
])('rejects malformed name %s', (raw) => {
|
||||||
|
expect(() => parseAnsName(raw)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through formatAnsName', () => {
|
||||||
|
expect(formatAnsName({ version: '1.2.3', agent: 'a', domain: 'example.com' })).toBe('ans://v1.2.3.a.example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
34
packages/ans/src/name.ts
Normal file
34
packages/ans/src/name.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { AnsName } from './types.js';
|
||||||
|
|
||||||
|
const LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i;
|
||||||
|
// ans://v<major.minor.patch[-prerelease]>.<agent>.<domain>
|
||||||
|
// The version's dotted core (X.Y.Z) is anchored so it doesn't get confused with
|
||||||
|
// the agent/domain labels. Prerelease is dot-free in M1 to keep the boundary
|
||||||
|
// unambiguous; build metadata (+...) is M2.
|
||||||
|
const ANS = /^ans:\/\/v(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+)?)\.([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.(.+)$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an `ans://v<semver>.<agent>.<domain>` name. The version is `v<semver>`,
|
||||||
|
* the agent is a single DNS label, and the domain is one or more labels after
|
||||||
|
* it. Throws on a malformed name.
|
||||||
|
*/
|
||||||
|
export function parseAnsName(raw: string): AnsName {
|
||||||
|
const match = ANS.exec(raw.trim());
|
||||||
|
if (!match) throw new Error(`malformed ans name — got ${raw}`);
|
||||||
|
const [, version, agent, domain] = match;
|
||||||
|
|
||||||
|
if (!LABEL.test(agent)) throw new Error(`ans name has invalid agent label — got ${agent}`);
|
||||||
|
if (!domain || domain.split('.').some((label) => !LABEL.test(label))) {
|
||||||
|
throw new Error(`ans name has invalid domain — got ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { raw: `ans://v${version}.${agent}.${domain}`, version, agent, domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAnsName(parts: { version: string; agent: string; domain: string }): string {
|
||||||
|
return parseAnsName(`ans://v${parts.version}.${parts.agent}.${parts.domain}`).raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAnsName(name: string | AnsName): AnsName {
|
||||||
|
return typeof name === 'string' ? parseAnsName(name) : name;
|
||||||
|
}
|
||||||
80
packages/ans/src/types.ts
Normal file
80
packages/ans/src/types.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
// Public types for @logicsrc/ans. See docs/ans-sdk.md for the full spec.
|
||||||
|
|
||||||
|
/** Parsed ans://v<semver>.<agent>.<domain> name. */
|
||||||
|
export interface AnsName {
|
||||||
|
raw: string;
|
||||||
|
version: string;
|
||||||
|
agent: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lifecycle event recorded in the transparency log. */
|
||||||
|
export interface LifecycleEvent {
|
||||||
|
type: string;
|
||||||
|
at: string;
|
||||||
|
payload?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw SCITT COSE_Sign1 inclusion receipt bytes. */
|
||||||
|
export interface Receipt {
|
||||||
|
cbor: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A resolved, not-yet-verified agent identity. */
|
||||||
|
export interface AnsIdentity {
|
||||||
|
name: AnsName;
|
||||||
|
endpoint?: string;
|
||||||
|
capabilities: string[];
|
||||||
|
certChainPem?: string;
|
||||||
|
serverCertTlsa?: string;
|
||||||
|
events: LifecycleEvent[];
|
||||||
|
receipt: Receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registry transparency-log verifier keys, indexed by 4-byte kid (lowercase hex). */
|
||||||
|
export interface RootKeys {
|
||||||
|
keys: Map<string, CryptoKey>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyOptions {
|
||||||
|
rootKeys: RootKeys;
|
||||||
|
now?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyResult {
|
||||||
|
ok: boolean;
|
||||||
|
reason?: string;
|
||||||
|
/** Transparency-log root hash the receipt was proven against (hex). */
|
||||||
|
rootHashHex?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
agent: string;
|
||||||
|
domain: string;
|
||||||
|
version: string;
|
||||||
|
endpoint?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
verify: 'dns' | 'acme';
|
||||||
|
dns?: DnsApplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caller-supplied DNS automation (e.g. a sh1pt DNS adapter) for verify-dns. */
|
||||||
|
export interface DnsApplier {
|
||||||
|
upsertTxt(record: { name: string; value: string }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Registration {
|
||||||
|
name: AnsName;
|
||||||
|
challenge: { type: 'TXT'; name: string; value: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationStatus {
|
||||||
|
state: 'pending' | 'verifying' | 'live' | 'failed';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bidirectional map between an ans:// name and a LogicSRC/coinpay DID (M3). */
|
||||||
|
export interface DidBridge {
|
||||||
|
ansNameForDid(did: string): Promise<AnsName | null>;
|
||||||
|
didForAnsName(name: string | AnsName): Promise<string | null>;
|
||||||
|
}
|
||||||
114
packages/ans/src/verify/cose.ts
Normal file
114
packages/ans/src/verify/cose.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// COSE_Sign1 (RFC 8152) construction + parsing, plus the M1 ANS receipt
|
||||||
|
// envelope model.
|
||||||
|
//
|
||||||
|
// A receipt is a COSE_Sign1 (CBOR tag 18) over the agent identity statement
|
||||||
|
// (the payload). The transparency-log inclusion proof is carried in an
|
||||||
|
// unprotected header under the label "ans-proof":
|
||||||
|
//
|
||||||
|
// { root: bstr, index: uint, size: uint, path: [bstr] }
|
||||||
|
//
|
||||||
|
// NOTE: this envelope shape is the LogicSRC M1 model. Upstream ANS may place the
|
||||||
|
// proof in a different (registered SCITT) header; M2 pins this against fixtures
|
||||||
|
// captured from the upstream Go server and adjusts `parseReceipt` accordingly.
|
||||||
|
|
||||||
|
import { CborTag, decodeCbor, encodeCbor, type CborValue } from '../cbor.js';
|
||||||
|
import { toHex } from '../bytes.js';
|
||||||
|
|
||||||
|
export const COSE_SIGN1_TAG = 18;
|
||||||
|
export const COSE_ALG_ES256 = -7;
|
||||||
|
const HEADER_ALG = 1;
|
||||||
|
const HEADER_KID = 4;
|
||||||
|
const HEADER_ANS_PROOF = 'ans-proof';
|
||||||
|
|
||||||
|
export interface InclusionProof {
|
||||||
|
root: Uint8Array;
|
||||||
|
index: number;
|
||||||
|
size: number;
|
||||||
|
path: Uint8Array[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceiptEnvelope {
|
||||||
|
/** lowercase hex of the 4-byte kid header. */
|
||||||
|
kidHex: string;
|
||||||
|
alg: number;
|
||||||
|
payload: Uint8Array;
|
||||||
|
signature: Uint8Array;
|
||||||
|
proof: InclusionProof;
|
||||||
|
/** Raw CBOR of the protected header map (signed). */
|
||||||
|
protectedBytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical COSE Sig_structure bytes the signature covers. */
|
||||||
|
export function sigStructure(protectedBytes: Uint8Array, payload: Uint8Array): Uint8Array {
|
||||||
|
return encodeCbor(['Signature1', protectedBytes, new Uint8Array(0), payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a COSE_Sign1 receipt. Used by tests/tools; the signer provides ES256(r||s). */
|
||||||
|
export async function buildReceipt(opts: {
|
||||||
|
kid: Uint8Array;
|
||||||
|
payload: Uint8Array;
|
||||||
|
proof: InclusionProof;
|
||||||
|
sign: (message: Uint8Array) => Promise<Uint8Array>;
|
||||||
|
}): Promise<Uint8Array> {
|
||||||
|
const protectedBytes = encodeCbor(new Map<CborValue, CborValue>([[HEADER_ALG, COSE_ALG_ES256]]));
|
||||||
|
const proofMap = new Map<CborValue, CborValue>([
|
||||||
|
['root', opts.proof.root],
|
||||||
|
['index', opts.proof.index],
|
||||||
|
['size', opts.proof.size],
|
||||||
|
['path', opts.proof.path as CborValue[]],
|
||||||
|
]);
|
||||||
|
const unprotected = new Map<CborValue, CborValue>([
|
||||||
|
[HEADER_KID, opts.kid],
|
||||||
|
[HEADER_ANS_PROOF, proofMap],
|
||||||
|
]);
|
||||||
|
const signature = await opts.sign(sigStructure(protectedBytes, opts.payload));
|
||||||
|
const sign1 = new CborTag(COSE_SIGN1_TAG, [protectedBytes, unprotected, opts.payload, signature]);
|
||||||
|
return encodeCbor(sign1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse COSE_Sign1 receipt bytes into a structured envelope. */
|
||||||
|
export function parseReceipt(cbor: Uint8Array): ReceiptEnvelope {
|
||||||
|
let decoded = decodeCbor(cbor);
|
||||||
|
if (decoded instanceof CborTag) {
|
||||||
|
if (decoded.tag !== COSE_SIGN1_TAG) throw new Error(`receipt: unexpected CBOR tag ${decoded.tag}`);
|
||||||
|
decoded = decoded.value;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(decoded) || decoded.length !== 4) throw new Error('receipt: not a COSE_Sign1 array');
|
||||||
|
|
||||||
|
const [protectedBytes, unprotected, payload, signature] = decoded;
|
||||||
|
if (!(protectedBytes instanceof Uint8Array)) throw new Error('receipt: bad protected header');
|
||||||
|
if (!(unprotected instanceof Map)) throw new Error('receipt: bad unprotected header');
|
||||||
|
if (!(payload instanceof Uint8Array)) throw new Error('receipt: detached/empty payload unsupported in M1');
|
||||||
|
if (!(signature instanceof Uint8Array)) throw new Error('receipt: bad signature');
|
||||||
|
|
||||||
|
const protectedMap = decodeCbor(protectedBytes);
|
||||||
|
const alg = protectedMap instanceof Map ? protectedMap.get(HEADER_ALG) : undefined;
|
||||||
|
|
||||||
|
const kid = unprotected.get(HEADER_KID) ?? (protectedMap instanceof Map ? protectedMap.get(HEADER_KID) : undefined);
|
||||||
|
if (!(kid instanceof Uint8Array)) throw new Error('receipt: missing kid header');
|
||||||
|
|
||||||
|
const proofRaw = unprotected.get(HEADER_ANS_PROOF);
|
||||||
|
if (!(proofRaw instanceof Map)) throw new Error('receipt: missing ans-proof header');
|
||||||
|
const proof = parseProof(proofRaw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kidHex: toHex(kid),
|
||||||
|
alg: typeof alg === 'number' ? alg : COSE_ALG_ES256,
|
||||||
|
payload,
|
||||||
|
signature,
|
||||||
|
proof,
|
||||||
|
protectedBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProof(map: Map<CborValue, CborValue>): InclusionProof {
|
||||||
|
const root = map.get('root');
|
||||||
|
const index = map.get('index');
|
||||||
|
const size = map.get('size');
|
||||||
|
const path = map.get('path');
|
||||||
|
if (!(root instanceof Uint8Array)) throw new Error('receipt: proof.root missing');
|
||||||
|
if (typeof index !== 'number') throw new Error('receipt: proof.index missing');
|
||||||
|
if (typeof size !== 'number') throw new Error('receipt: proof.size missing');
|
||||||
|
if (!Array.isArray(path) || path.some((p) => !(p instanceof Uint8Array))) throw new Error('receipt: proof.path malformed');
|
||||||
|
return { root, index, size, path: path as Uint8Array[] };
|
||||||
|
}
|
||||||
15
packages/ans/src/verify/es256.ts
Normal file
15
packages/ans/src/verify/es256.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// ES256 (ECDSA P-256 / SHA-256) verification via WebCrypto. COSE signatures are
|
||||||
|
// raw IEEE-P1363 (r || s, 64 bytes), which is exactly what WebCrypto expects.
|
||||||
|
|
||||||
|
export function importEs256VerifyKey(jwk: JsonWebKey): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyEs256(key: CryptoKey, signature: Uint8Array, message: Uint8Array): Promise<boolean> {
|
||||||
|
return crypto.subtle.verify(
|
||||||
|
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||||
|
key,
|
||||||
|
signature as unknown as ArrayBuffer,
|
||||||
|
message as unknown as ArrayBuffer,
|
||||||
|
);
|
||||||
|
}
|
||||||
61
packages/ans/src/verify/index.ts
Normal file
61
packages/ans/src/verify/index.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// Offline ANS verifier. No network: given root keys (fetched once or pinned),
|
||||||
|
// it cryptographically verifies a receipt against the transparency log without
|
||||||
|
// trusting the registry operator.
|
||||||
|
|
||||||
|
import { parseReceipt, sigStructure } from './cose.js';
|
||||||
|
import { verifyEs256 } from './es256.js';
|
||||||
|
import { leafHash, verifyInclusion } from './merkle.js';
|
||||||
|
import { toHex, fromUtf8 } from '../bytes.js';
|
||||||
|
import { toAnsName } from '../name.js';
|
||||||
|
import type { AnsIdentity, Receipt, VerifyOptions, VerifyResult } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a SCITT COSE_Sign1 inclusion receipt:
|
||||||
|
* 1. map kid -> root verifier key
|
||||||
|
* 2. ES256-verify the signature over the COSE Sig_structure
|
||||||
|
* 3. recompute the leaf hash (RFC 6962) and walk the inclusion proof to the root
|
||||||
|
*/
|
||||||
|
export async function verifyReceipt(receipt: Receipt, opts: VerifyOptions): Promise<VerifyResult> {
|
||||||
|
let env;
|
||||||
|
try {
|
||||||
|
env = parseReceipt(receipt.cbor);
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, reason: `parse: ${(error as Error).message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = opts.rootKeys.keys.get(env.kidHex);
|
||||||
|
if (!key) return { ok: false, reason: `unknown kid ${env.kidHex}` };
|
||||||
|
|
||||||
|
const message = sigStructure(env.protectedBytes, env.payload);
|
||||||
|
const signatureOk = await verifyEs256(key, env.signature, message);
|
||||||
|
if (!signatureOk) return { ok: false, reason: 'invalid signature' };
|
||||||
|
|
||||||
|
const leaf = await leafHash(env.payload);
|
||||||
|
const inclusionOk = await verifyInclusion({
|
||||||
|
index: env.proof.index,
|
||||||
|
size: env.proof.size,
|
||||||
|
leafHash: leaf,
|
||||||
|
auditPath: env.proof.path,
|
||||||
|
root: env.proof.root,
|
||||||
|
});
|
||||||
|
if (!inclusionOk) return { ok: false, reason: 'inclusion proof failed' };
|
||||||
|
|
||||||
|
return { ok: true, rootHashHex: toHex(env.proof.root) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a resolved identity: the receipt must verify AND its signed payload
|
||||||
|
* must bind the resolved ans:// name.
|
||||||
|
*/
|
||||||
|
export async function verifyResolution(identity: AnsIdentity, opts: VerifyOptions): Promise<VerifyResult> {
|
||||||
|
const result = await verifyReceipt(identity.receipt, opts);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
|
||||||
|
const env = parseReceipt(identity.receipt.cbor);
|
||||||
|
const signedName = fromUtf8(env.payload).trim();
|
||||||
|
const expected = toAnsName(identity.name).raw;
|
||||||
|
if (signedName !== expected) {
|
||||||
|
return { ok: false, reason: `name binding mismatch: receipt signs ${signedName}, resolved ${expected}` };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
38
packages/ans/src/verify/merkle.test.ts
Normal file
38
packages/ans/src/verify/merkle.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { inclusionProof, leafHash, merkleRoot, nodeHash, verifyInclusion } from './merkle.js';
|
||||||
|
import { utf8, toHex } from '../bytes.js';
|
||||||
|
|
||||||
|
const leaves = ['a', 'b', 'c', 'd'].map(utf8);
|
||||||
|
|
||||||
|
describe('RFC 6962 merkle tree', () => {
|
||||||
|
it('computes the root as node(node(la,lb), node(lc,ld)) for 4 leaves', async () => {
|
||||||
|
const [la, lb, lc, ld] = await Promise.all(leaves.map(leafHash));
|
||||||
|
const expected = await nodeHash(await nodeHash(la, lb), await nodeHash(lc, ld));
|
||||||
|
expect(toHex(await merkleRoot(leaves))).toBe(toHex(expected));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies inclusion proofs for every leaf', async () => {
|
||||||
|
const root = await merkleRoot(leaves);
|
||||||
|
for (let i = 0; i < leaves.length; i++) {
|
||||||
|
const auditPath = await inclusionProof(leaves, i);
|
||||||
|
const ok = await verifyInclusion({ index: i, size: leaves.length, leafHash: await leafHash(leaves[i]), auditPath, root });
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies inclusion for an odd-sized (5-leaf) tree', async () => {
|
||||||
|
const five = ['a', 'b', 'c', 'd', 'e'].map(utf8);
|
||||||
|
const root = await merkleRoot(five);
|
||||||
|
const auditPath = await inclusionProof(five, 4);
|
||||||
|
expect(await verifyInclusion({ index: 4, size: 5, leafHash: await leafHash(five[4]), auditPath, root })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a tampered proof, wrong index, and out-of-range index', async () => {
|
||||||
|
const root = await merkleRoot(leaves);
|
||||||
|
const auditPath = await inclusionProof(leaves, 1);
|
||||||
|
const lb = await leafHash(leaves[1]);
|
||||||
|
expect(await verifyInclusion({ index: 1, size: 4, leafHash: await leafHash(utf8('x')), auditPath, root })).toBe(false);
|
||||||
|
expect(await verifyInclusion({ index: 2, size: 4, leafHash: lb, auditPath, root })).toBe(false);
|
||||||
|
expect(await verifyInclusion({ index: 9, size: 4, leafHash: lb, auditPath, root })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
85
packages/ans/src/verify/merkle.ts
Normal file
85
packages/ans/src/verify/merkle.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// RFC 6962 Merkle tree hashing + inclusion-proof verification.
|
||||||
|
//
|
||||||
|
// Leaf hash: SHA-256(0x00 || data)
|
||||||
|
// Node hash: SHA-256(0x01 || left || right)
|
||||||
|
//
|
||||||
|
// The tree builder + proof generator are included so the verifier can be tested
|
||||||
|
// against self-consistent vectors (and against hand-derived small trees) before
|
||||||
|
// upstream fixtures are available.
|
||||||
|
|
||||||
|
import { sha256, concat, bytesEqual } from '../bytes.js';
|
||||||
|
|
||||||
|
const LEAF_PREFIX = new Uint8Array([0x00]);
|
||||||
|
const NODE_PREFIX = new Uint8Array([0x01]);
|
||||||
|
|
||||||
|
export function leafHash(data: Uint8Array): Promise<Uint8Array> {
|
||||||
|
return sha256(concat(LEAF_PREFIX, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeHash(left: Uint8Array, right: Uint8Array): Promise<Uint8Array> {
|
||||||
|
return sha256(concat(NODE_PREFIX, left, right));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largest power of two strictly less than n (n >= 2). */
|
||||||
|
function splitPoint(n: number): number {
|
||||||
|
let k = 1;
|
||||||
|
while (k * 2 < n) k *= 2;
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merkle Tree Hash (MTH) over a list of leaf payloads. */
|
||||||
|
export async function merkleRoot(leaves: Uint8Array[]): Promise<Uint8Array> {
|
||||||
|
if (leaves.length === 0) return sha256(new Uint8Array(0));
|
||||||
|
if (leaves.length === 1) return leafHash(leaves[0]);
|
||||||
|
const k = splitPoint(leaves.length);
|
||||||
|
const [left, right] = await Promise.all([merkleRoot(leaves.slice(0, k)), merkleRoot(leaves.slice(k))]);
|
||||||
|
return nodeHash(left, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Audit path for the leaf at `index`, leaf-to-root order. */
|
||||||
|
export async function inclusionProof(leaves: Uint8Array[], index: number): Promise<Uint8Array[]> {
|
||||||
|
if (index < 0 || index >= leaves.length) throw new Error('inclusionProof: index out of range');
|
||||||
|
if (leaves.length <= 1) return [];
|
||||||
|
const k = splitPoint(leaves.length);
|
||||||
|
if (index < k) {
|
||||||
|
const sub = await inclusionProof(leaves.slice(0, k), index);
|
||||||
|
return [...sub, await merkleRoot(leaves.slice(k))];
|
||||||
|
}
|
||||||
|
const sub = await inclusionProof(leaves.slice(k), index - k);
|
||||||
|
return [...sub, await merkleRoot(leaves.slice(0, k))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rootFromProof(index: number, size: number, leaf: Uint8Array, path: Uint8Array[]): Promise<Uint8Array> {
|
||||||
|
if (size <= 1) {
|
||||||
|
if (path.length !== 0) throw new Error('inclusion proof: path too long');
|
||||||
|
return leaf;
|
||||||
|
}
|
||||||
|
if (path.length === 0) throw new Error('inclusion proof: path too short');
|
||||||
|
const k = splitPoint(size);
|
||||||
|
const sibling = path[path.length - 1];
|
||||||
|
const rest = path.slice(0, -1);
|
||||||
|
if (index < k) {
|
||||||
|
const sub = await rootFromProof(index, k, leaf, rest);
|
||||||
|
return nodeHash(sub, sibling);
|
||||||
|
}
|
||||||
|
const sub = await rootFromProof(index - k, size - k, leaf, rest);
|
||||||
|
return nodeHash(sibling, sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InclusionInput {
|
||||||
|
index: number;
|
||||||
|
size: number;
|
||||||
|
leafHash: Uint8Array;
|
||||||
|
auditPath: Uint8Array[];
|
||||||
|
root: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyInclusion(input: InclusionInput): Promise<boolean> {
|
||||||
|
if (input.index < 0 || input.index >= input.size) return false;
|
||||||
|
try {
|
||||||
|
const computed = await rootFromProof(input.index, input.size, input.leafHash, input.auditPath);
|
||||||
|
return bytesEqual(computed, input.root);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/ans/src/verify/rootkeys.ts
Normal file
30
packages/ans/src/verify/rootkeys.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// Transparency-log verifier keys, indexed by 4-byte kid (lowercase hex).
|
||||||
|
//
|
||||||
|
// M1 accepts a JWKS-style list ({ kid, jwk }). The upstream sumdb-note
|
||||||
|
// (`/root-keys`) parser is M2 (TODO) — captured against fixtures.
|
||||||
|
|
||||||
|
import { importEs256VerifyKey } from './es256.js';
|
||||||
|
import { fromHex, toHex } from '../bytes.js';
|
||||||
|
import type { RootKeys } from '../types.js';
|
||||||
|
|
||||||
|
export interface RootKeyEntry {
|
||||||
|
/** 4-byte key id as hex (e.g. "01020304") or any string the receipts use. */
|
||||||
|
kid: string;
|
||||||
|
jwk: JsonWebKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rootKeysFromEntries(entries: RootKeyEntry[]): Promise<RootKeys> {
|
||||||
|
const keys = new Map<string, CryptoKey>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const kidHex = normalizeKid(entry.kid);
|
||||||
|
keys.set(kidHex, await importEs256VerifyKey(entry.jwk));
|
||||||
|
}
|
||||||
|
return { keys };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accept kid as hex ("01020304") or as already-normalized; lowercases hex. */
|
||||||
|
function normalizeKid(kid: string): string {
|
||||||
|
const lower = kid.toLowerCase();
|
||||||
|
if (/^[0-9a-f]+$/.test(lower) && lower.length % 2 === 0) return toHex(fromHex(lower));
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
86
packages/ans/src/verify/verify.test.ts
Normal file
86
packages/ans/src/verify/verify.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { buildReceipt } from './cose.js';
|
||||||
|
import { inclusionProof, leafHash, merkleRoot } from './merkle.js';
|
||||||
|
import { rootKeysFromEntries } from './rootkeys.js';
|
||||||
|
import { verifyReceipt, verifyResolution } from './index.js';
|
||||||
|
import { utf8 } from '../bytes.js';
|
||||||
|
import type { AnsIdentity, RootKeys } from '../types.js';
|
||||||
|
|
||||||
|
const KID = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
|
||||||
|
const NAME = 'ans://v1.0.0.my-agent.example.com';
|
||||||
|
const payload = utf8(NAME);
|
||||||
|
const otherLeaves = [utf8('sibling-0'), utf8('sibling-2'), utf8('sibling-3')];
|
||||||
|
const leaves = [otherLeaves[0], payload, otherLeaves[1], otherLeaves[2]];
|
||||||
|
const INDEX = 1;
|
||||||
|
|
||||||
|
let keyPair: CryptoKeyPair;
|
||||||
|
let rootKeys: RootKeys;
|
||||||
|
let receiptCbor: Uint8Array;
|
||||||
|
|
||||||
|
async function sign(message: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, message as unknown as ArrayBuffer);
|
||||||
|
return new Uint8Array(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
keyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])) as CryptoKeyPair;
|
||||||
|
const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||||
|
rootKeys = await rootKeysFromEntries([{ kid: '01020304', jwk }]);
|
||||||
|
|
||||||
|
const root = await merkleRoot(leaves);
|
||||||
|
const path = await inclusionProof(leaves, INDEX);
|
||||||
|
receiptCbor = await buildReceipt({ kid: KID, payload, proof: { root, index: INDEX, size: leaves.length, path }, sign });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('offline receipt verification', () => {
|
||||||
|
it('verifies a well-formed receipt (signature + inclusion proof)', async () => {
|
||||||
|
const result = await verifyReceipt({ cbor: receiptCbor }, { rootKeys });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.rootHashHex).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies a resolution whose payload binds the resolved name', async () => {
|
||||||
|
const identity: AnsIdentity = {
|
||||||
|
name: NAME as never, // toAnsName normalizes the string form
|
||||||
|
capabilities: [],
|
||||||
|
events: [],
|
||||||
|
receipt: { cbor: receiptCbor },
|
||||||
|
} as unknown as AnsIdentity;
|
||||||
|
const result = await verifyResolution({ ...identity, name: { raw: NAME, version: '1.0.0', agent: 'my-agent', domain: 'example.com' } }, { rootKeys });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown kid', async () => {
|
||||||
|
const empty = await rootKeysFromEntries([]);
|
||||||
|
const result = await verifyReceipt({ cbor: receiptCbor }, { rootKeys: empty });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toContain('unknown kid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a receipt signed by a different key', async () => {
|
||||||
|
const other = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])) as CryptoKeyPair;
|
||||||
|
const wrongJwk = await crypto.subtle.exportKey('jwk', other.publicKey);
|
||||||
|
const wrongKeys = await rootKeysFromEntries([{ kid: '01020304', jwk: wrongJwk }]);
|
||||||
|
const result = await verifyReceipt({ cbor: receiptCbor }, { rootKeys: wrongKeys });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toBe('invalid signature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a tampered inclusion proof (wrong root)', async () => {
|
||||||
|
const badRoot = new Uint8Array(32).fill(0xff);
|
||||||
|
const path = await inclusionProof(leaves, INDEX);
|
||||||
|
const tampered = await buildReceipt({ kid: KID, payload, proof: { root: badRoot, index: INDEX, size: leaves.length, path }, sign });
|
||||||
|
const result = await verifyReceipt({ cbor: tampered }, { rootKeys });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toBe('inclusion proof failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a resolution whose name does not match the signed payload', async () => {
|
||||||
|
const result = await verifyResolution(
|
||||||
|
{ name: { raw: 'ans://v1.0.0.other.example.com', version: '1.0.0', agent: 'other', domain: 'example.com' }, capabilities: [], events: [], receipt: { cbor: receiptCbor } },
|
||||||
|
{ rootKeys },
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toContain('name binding mismatch');
|
||||||
|
});
|
||||||
|
});
|
||||||
9
packages/ans/tsconfig.json
Normal file
9
packages/ans/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue