From e49588eb37ff9a7f65210a4c4c2633986304ae0f Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 18 Mar 2026 22:22:28 +0800 Subject: [PATCH 1/8] feat!: implement domain-separated signatures to prevent replay attacks Implements AgentWire-style domain separation across all signing contexts to prevent cross-context signature replay attacks. This is a BREAKING CHANGE that adds cryptographic domain separators to all signatures. Security improvements: - Adds 7 context-specific domain separators (HTTP_REQUEST, HTTP_RESPONSE, AGENT_CARD, KEY_ROTATION, ANNOUNCE, MESSAGE, WORLD_STATE) - Signatures valid in one context cannot be replayed in another context - Format: "AgentWorld-{Context}-{VERSION}\0" with null byte terminator Breaking changes: - All signing functions now prepend domain-specific prefix before signing - Existing signatures will NOT verify after this change - New exports: DOMAIN_SEPARATORS, signWithDomainSeparator, verifyWithDomainSeparator Testing: - All 102 existing tests pass - Added 19 new domain separation security tests - Total: 121 tests passing Co-Authored-By: Claude Sonnet 4.5 --- .changeset/domain-separated-signing.md | 61 +++ packages/agent-world-sdk/package-lock.json | 4 +- packages/agent-world-sdk/src/bootstrap.ts | 130 ++++-- packages/agent-world-sdk/src/card.ts | 70 ++-- packages/agent-world-sdk/src/crypto.ts | 335 ++++++++++----- packages/agent-world-sdk/src/index.ts | 43 +- packages/agent-world-sdk/src/peer-protocol.ts | 334 +++++++++------ packages/agent-world-sdk/src/world-server.ts | 214 ++++++---- test/domain-separation.test.mjs | 388 ++++++++++++++++++ 9 files changed, 1189 insertions(+), 390 deletions(-) create mode 100644 .changeset/domain-separated-signing.md create mode 100644 test/domain-separation.test.mjs diff --git a/.changeset/domain-separated-signing.md b/.changeset/domain-separated-signing.md new file mode 100644 index 0000000..dc13114 --- /dev/null +++ b/.changeset/domain-separated-signing.md @@ -0,0 +1,61 @@ +--- +"@resciencelab/agent-world-sdk": major +--- + +Implement domain-separated signatures to prevent cross-context replay attacks + +This is a BREAKING CHANGE that implements AgentWire-style domain separation across all signing contexts. + +## Security Improvements + +- **Prevents cross-context replay attacks**: Signatures valid in one context (e.g., HTTP requests) cannot be replayed in another context (e.g., Agent Cards) +- **Adds 7 domain separators**: HTTP_REQUEST, HTTP_RESPONSE, AGENT_CARD, KEY_ROTATION, ANNOUNCE, MESSAGE, WORLD_STATE +- **Format**: `"AgentWorld-{Context}-{VERSION}\0"` (includes null byte terminator to prevent JSON confusion) + +## Breaking Changes + +### Signature Format +All signatures now include a domain-specific prefix before the payload: +``` +message = DomainSeparator + JSON.stringify(canonicalize(payload)) +signature = Ed25519(message, secretKey) +``` + +### Affected APIs +- `signHttpRequest()` - Now uses `DOMAIN_SEPARATORS.HTTP_REQUEST` +- `verifyHttpRequestHeaders()` - Verifies with domain separation +- `signHttpResponse()` - Now uses `DOMAIN_SEPARATORS.HTTP_RESPONSE` +- `verifyHttpResponseHeaders()` - Verifies with domain separation +- `buildSignedAgentCard()` - Agent Card JWS now prepends `DOMAIN_SEPARATORS.AGENT_CARD` +- Peer protocol (announce, message, key-rotation) - All use context-specific separators + +### New Exports +- `DOMAIN_SEPARATORS` - Constant object with all 7 domain separators +- `signWithDomainSeparator(separator, payload, secretKey)` - Low-level signing function +- `verifyWithDomainSeparator(separator, publicKey, payload, signature)` - Low-level verification function + +## Migration Guide + +### For Signature Verification +Existing signatures created before this change will NOT verify. All agents must upgrade simultaneously or use a coordinated rollout strategy. + +### For Custom Signing +If you were using `signPayload()` or `verifySignature()` directly, migrate to domain-separated versions: + +**Before:** +```typescript +const sig = signPayload(payload, secretKey); +const valid = verifySignature(publicKey, payload, sig); +``` + +**After:** +```typescript +const sig = signWithDomainSeparator(DOMAIN_SEPARATORS.MESSAGE, payload, secretKey); +const valid = verifyWithDomainSeparator(DOMAIN_SEPARATORS.MESSAGE, publicKey, payload, sig); +``` + +## Agent Card Capability +Agent Cards now advertise `"domain-separated-signatures"` capability in the conformance block. + +## Verification +All existing tests pass + 19 new domain separation security tests covering cross-context replay attack prevention. diff --git a/packages/agent-world-sdk/package-lock.json b/packages/agent-world-sdk/package-lock.json index 9446d44..c08c056 100644 --- a/packages/agent-world-sdk/package-lock.json +++ b/packages/agent-world-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@resciencelab/agent-world-sdk", - "version": "0.3.2", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@resciencelab/agent-world-sdk", - "version": "0.3.2", + "version": "0.4.3", "license": "MIT", "dependencies": { "fastify": "^5.0.0", diff --git a/packages/agent-world-sdk/src/bootstrap.ts b/packages/agent-world-sdk/src/bootstrap.ts index 9cf9851..62ac3b5 100644 --- a/packages/agent-world-sdk/src/bootstrap.ts +++ b/packages/agent-world-sdk/src/bootstrap.ts @@ -1,30 +1,41 @@ -import { canonicalize, signPayload, signHttpRequest } from "./crypto.js" -import type { BootstrapNode, Identity } from "./types.js" -import type { PeerDb } from "./peer-db.js" +import { + canonicalize, + signPayload, + signHttpRequest, + DOMAIN_SEPARATORS, + signWithDomainSeparator, +} from "./crypto.js"; +import type { BootstrapNode, Identity } from "./types.js"; +import type { PeerDb } from "./peer-db.js"; -const DEFAULT_BOOTSTRAP_URL = "https://resciencelab.github.io/DAP/bootstrap.json" +const DEFAULT_BOOTSTRAP_URL = + "https://resciencelab.github.io/DAP/bootstrap.json"; -export async function fetchBootstrapNodes(url = DEFAULT_BOOTSTRAP_URL): Promise { +export async function fetchBootstrapNodes( + url = DEFAULT_BOOTSTRAP_URL +): Promise { try { - const resp = await fetch(url, { signal: AbortSignal.timeout(10_000) }) - if (!resp.ok) return [] - const data = await resp.json() as { bootstrap_nodes?: Array<{ addr: string; httpPort?: number }> } + const resp = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!resp.ok) return []; + const data = (await resp.json()) as { + bootstrap_nodes?: Array<{ addr: string; httpPort?: number }>; + }; return (data.bootstrap_nodes ?? []) .filter((n) => n.addr) - .map((n) => ({ addr: n.addr, httpPort: n.httpPort ?? 8099 })) + .map((n) => ({ addr: n.addr, httpPort: n.httpPort ?? 8099 })); } catch { - return [] + return []; } } export interface AnnounceOpts { - identity: Identity - alias: string - version?: string - publicAddr: string | null - publicPort: number - capabilities: string[] - peerDb: PeerDb + identity: Identity; + alias: string; + version?: string; + publicAddr: string | null; + publicPort: number; + capabilities: string[]; + peerDb: PeerDb; } export async function announceToNode( @@ -32,15 +43,31 @@ export async function announceToNode( httpPort: number, opts: AnnounceOpts ): Promise { - const { identity, alias, version, publicAddr, publicPort, capabilities, peerDb } = opts - const isIpv6 = addr.includes(":") && !addr.includes(".") + const { + identity, + alias, + version, + publicAddr, + publicPort, + capabilities, + peerDb, + } = opts; + const isIpv6 = addr.includes(":") && !addr.includes("."); const url = isIpv6 ? `http://[${addr}]:${httpPort}/peer/announce` - : `http://${addr}:${httpPort}/peer/announce` + : `http://${addr}:${httpPort}/peer/announce`; const endpoints = publicAddr - ? [{ transport: "tcp", address: publicAddr, port: publicPort, priority: 1, ttl: 3600 }] - : [] + ? [ + { + transport: "tcp", + address: publicAddr, + port: publicPort, + priority: 1, + ttl: 3600, + }, + ] + : []; const payload: Record = { from: identity.agentId, @@ -50,21 +77,40 @@ export async function announceToNode( endpoints, capabilities, timestamp: Date.now(), - } - payload["signature"] = signPayload(payload, identity.secretKey) + }; + payload["signature"] = signWithDomainSeparator( + DOMAIN_SEPARATORS.ANNOUNCE, + payload, + identity.secretKey + ); try { - const body = JSON.stringify(canonicalize(payload)) - const urlObj = new URL(url) - const awHeaders = signHttpRequest(identity, "POST", urlObj.host, urlObj.pathname, body) + const body = JSON.stringify(canonicalize(payload)); + const urlObj = new URL(url); + const awHeaders = signHttpRequest( + identity, + "POST", + urlObj.host, + urlObj.pathname, + body + ); const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...awHeaders }, body, signal: AbortSignal.timeout(10_000), - }) - if (!resp.ok) return - const data = await resp.json() as { peers?: Array<{ agentId: string; publicKey: string; alias: string; endpoints: []; capabilities: []; lastSeen: number }> } + }); + if (!resp.ok) return; + const data = (await resp.json()) as { + peers?: Array<{ + agentId: string; + publicKey: string; + alias: string; + endpoints: []; + capabilities: []; + lastSeen: number; + }>; + }; for (const peer of data.peers ?? []) { if (peer.agentId && peer.agentId !== identity.agentId) { peerDb.upsert(peer.agentId, peer.publicKey, { @@ -72,7 +118,7 @@ export async function announceToNode( endpoints: peer.endpoints, capabilities: peer.capabilities, lastSeen: peer.lastSeen, - }) + }); } } } catch { @@ -81,9 +127,9 @@ export async function announceToNode( } export interface DiscoveryOpts extends AnnounceOpts { - bootstrapUrl?: string - intervalMs?: number - onDiscovery?: (peerCount: number) => void + bootstrapUrl?: string; + intervalMs?: number; + onDiscovery?: (peerCount: number) => void; } /** @@ -91,15 +137,17 @@ export interface DiscoveryOpts extends AnnounceOpts { * Returns a cleanup function that cancels the interval. */ export async function startDiscovery(opts: DiscoveryOpts): Promise<() => void> { - const { bootstrapUrl, intervalMs = 10 * 60 * 1000, onDiscovery } = opts + const { bootstrapUrl, intervalMs = 10 * 60 * 1000, onDiscovery } = opts; async function runDiscovery() { - const nodes = await fetchBootstrapNodes(bootstrapUrl) - await Promise.allSettled(nodes.map((n) => announceToNode(n.addr, n.httpPort, opts))) - onDiscovery?.(opts.peerDb.size) + const nodes = await fetchBootstrapNodes(bootstrapUrl); + await Promise.allSettled( + nodes.map((n) => announceToNode(n.addr, n.httpPort, opts)) + ); + onDiscovery?.(opts.peerDb.size); } - setTimeout(runDiscovery, 3_000) - const timer = setInterval(runDiscovery, intervalMs) - return () => clearInterval(timer) + setTimeout(runDiscovery, 3_000); + const timer = setInterval(runDiscovery, intervalMs); + return () => clearInterval(timer); } diff --git a/packages/agent-world-sdk/src/card.ts b/packages/agent-world-sdk/src/card.ts index 503db1d..c74108e 100644 --- a/packages/agent-world-sdk/src/card.ts +++ b/packages/agent-world-sdk/src/card.ts @@ -8,36 +8,39 @@ * omitted from the stored signature entry — the card body itself is the * signed payload. */ -import { FlattenedSign } from "jose" -import { createPrivateKey } from "node:crypto" -import { canonicalize } from "./crypto.js" -import { deriveDidKey, toPublicKeyMultibase } from "./identity.js" -import { PROTOCOL_VERSION } from "./version.js" -import type { Identity } from "./types.js" +import { FlattenedSign } from "jose"; +import { createPrivateKey } from "node:crypto"; +import { canonicalize, DOMAIN_SEPARATORS } from "./crypto.js"; +import { deriveDidKey, toPublicKeyMultibase } from "./identity.js"; +import { PROTOCOL_VERSION } from "./version.js"; +import type { Identity } from "./types.js"; // PKCS8 DER header for an Ed25519 32-byte seed (RFC 8410) -const PKCS8_ED25519_HEADER = Buffer.from("302e020100300506032b657004220420", "hex") +const PKCS8_ED25519_HEADER = Buffer.from( + "302e020100300506032b657004220420", + "hex" +); function toNodePrivateKey(secretKey: Uint8Array) { - const seed = Buffer.from(secretKey.subarray(0, 32)) - const der = Buffer.concat([PKCS8_ED25519_HEADER, seed]) - return createPrivateKey({ key: der, format: "der", type: "pkcs8" }) + const seed = Buffer.from(secretKey.subarray(0, 32)); + const der = Buffer.concat([PKCS8_ED25519_HEADER, seed]); + return createPrivateKey({ key: der, format: "der", type: "pkcs8" }); } export interface AgentCardOpts { /** Human-readable agent name */ - name: string - description?: string + name: string; + description?: string; /** Canonical public URL of this card, e.g. https://gateway.example.com/.well-known/agent.json */ - cardUrl: string + cardUrl: string; /** A2A JSON-RPC endpoint URL (optional) */ - rpcUrl?: string + rpcUrl?: string; /** AgentWorld profiles to declare. Defaults to ["core/v0.2"] */ - profiles?: string[] + profiles?: string[]; /** Conformance node class. Defaults to "CoreNode" */ - nodeClass?: string + nodeClass?: string; /** Capabilities advertised in conformance block. Defaults to standard core/v0.2 set. */ - capabilities?: string[] + capabilities?: string[]; } /** @@ -53,10 +56,10 @@ export async function buildSignedAgentCard( opts: AgentCardOpts, identity: Identity ): Promise { - const profiles = opts.profiles ?? ["core/v0.2"] - const nodeClass = opts.nodeClass ?? "CoreNode" - const did = deriveDidKey(identity.pubB64) - const publicKeyMultibase = toPublicKeyMultibase(identity.pubB64) + const profiles = opts.profiles ?? ["core/v0.2"]; + const nodeClass = opts.nodeClass ?? "CoreNode"; + const did = deriveDidKey(identity.pubB64); + const publicKeyMultibase = toPublicKeyMultibase(identity.pubB64); const card: Record = { id: opts.cardUrl, @@ -87,29 +90,40 @@ export async function buildSignedAgentCard( profiles, conformance: { nodeClass, - profiles: profiles.map((id) => ({ id, required: id === "core/v0.2" })), + profiles: profiles.map((id) => ({ + id, + required: id === "core/v0.2", + })), capabilities: opts.capabilities ?? [ "signed-card-jws", "signed-http-requests", "signed-http-responses", "tofu-key-binding", + "domain-separated-signatures", ], }, }, }, - } + }; // Sign the card body (without the signatures field) using FlattenedSign (EdDSA) - const payload = Buffer.from(JSON.stringify(canonicalize(card)), "utf8") - const privateKey = toNodePrivateKey(identity.secretKey) + // with domain separation to prevent cross-context replay attacks + const canonicalCard = JSON.stringify(canonicalize(card)); + const domainPrefix = Buffer.from(DOMAIN_SEPARATORS.AGENT_CARD, "utf8"); + const cardBytes = Buffer.from(canonicalCard, "utf8"); + const payload = Buffer.concat([domainPrefix, cardBytes]); + const privateKey = toNodePrivateKey(identity.secretKey); const jws = await new FlattenedSign(payload) .setProtectedHeader({ alg: "EdDSA", kid: "#identity" }) - .sign(privateKey) + .sign(privateKey); // Return the signed card as a canonical JSON string. // Serving this string verbatim ensures the bytes on the wire exactly match // what was signed, making verification unambiguous. - const signedCard = { ...canonicalize(card) as object, signatures: [{ protected: jws.protected, signature: jws.signature }] } - return JSON.stringify(canonicalize(signedCard)) + const signedCard = { + ...(canonicalize(card) as object), + signatures: [{ protected: jws.protected, signature: jws.signature }], + }; + return JSON.stringify(canonicalize(signedCard)); } diff --git a/packages/agent-world-sdk/src/crypto.ts b/packages/agent-world-sdk/src/crypto.ts index e8730d9..46de400 100644 --- a/packages/agent-world-sdk/src/crypto.ts +++ b/packages/agent-world-sdk/src/crypto.ts @@ -1,34 +1,124 @@ -import crypto from "node:crypto" -import nacl from "tweetnacl" -import { PROTOCOL_VERSION } from "./version.js" +import crypto from "node:crypto"; +import nacl from "tweetnacl"; +import { PROTOCOL_VERSION } from "./version.js"; + +// ── Domain-Separated Signatures ────────────────────────────────────────────── +// +// Domain separation prevents cross-context signature replay attacks. +// Each signing context prepends a unique separator before signing: +// +// message = DomainSeparator + JSON.stringify(canonicalize(payload)) +// signature = Ed25519(message, secretKey) +// +// A signature valid in one context (e.g., HTTP requests) will NOT verify +// in another context (e.g., Agent Cards) because the domain separator differs. +// +// Format: "AgentWorld-{Context}-{VERSION}\0" +// - AgentWorld: Protocol namespace +// - {Context}: Specific context (Req, Res, Card, etc.) +// - {VERSION}: Protocol version from package.json +// - \0: NULL byte terminator (prevents JSON confusion) +// +export const DOMAIN_SEPARATORS = { + HTTP_REQUEST: `AgentWorld-Req-${PROTOCOL_VERSION}\0`, + HTTP_RESPONSE: `AgentWorld-Res-${PROTOCOL_VERSION}\0`, + AGENT_CARD: `AgentWorld-Card-${PROTOCOL_VERSION}\0`, + KEY_ROTATION: `AgentWorld-Rotation-${PROTOCOL_VERSION}\0`, + ANNOUNCE: `AgentWorld-Announce-${PROTOCOL_VERSION}\0`, + MESSAGE: `AgentWorld-Message-${PROTOCOL_VERSION}\0`, + WORLD_STATE: `AgentWorld-WorldState-${PROTOCOL_VERSION}\0`, +} as const; + +/** + * Sign with domain separation to prevent cross-context replay attacks. + * + * Prepends domain separator before canonicalized JSON, then signs with Ed25519. + * The domain separator ensures a signature valid in one context cannot be + * replayed in another context. + * + * @param domainSeparator - Context-specific separator (e.g., DOMAIN_SEPARATORS.HTTP_REQUEST) + * @param payload - Object to sign (will be canonicalized) + * @param secretKey - Ed25519 secret key (64 bytes from TweetNaCl) + * @returns Base64-encoded signature + */ +export function signWithDomainSeparator( + domainSeparator: string, + payload: unknown, + secretKey: Uint8Array +): string { + const canonicalJson = JSON.stringify(canonicalize(payload)); + const domainPrefix = Buffer.from(domainSeparator, "utf8"); + const payloadBytes = Buffer.from(canonicalJson, "utf8"); + const message = Buffer.concat([domainPrefix, payloadBytes]); + + const sig = nacl.sign.detached(message, secretKey); + return Buffer.from(sig).toString("base64"); +} + +/** + * Verify signature with domain separation. + * + * Reconstructs the domain-separated message and verifies the Ed25519 signature. + * MUST use the same domain separator as the signer. + * + * @param domainSeparator - Same separator used during signing + * @param publicKeyB64 - Base64-encoded Ed25519 public key + * @param payload - Object that was signed (will be canonicalized) + * @param signatureB64 - Base64-encoded signature + * @returns true if signature is valid, false otherwise + */ +export function verifyWithDomainSeparator( + domainSeparator: string, + publicKeyB64: string, + payload: unknown, + signatureB64: string +): boolean { + try { + const canonicalJson = JSON.stringify(canonicalize(payload)); + const domainPrefix = Buffer.from(domainSeparator, "utf8"); + const payloadBytes = Buffer.from(canonicalJson, "utf8"); + const message = Buffer.concat([domainPrefix, payloadBytes]); + + const pubKey = Buffer.from(publicKeyB64, "base64"); + const sig = Buffer.from(signatureB64, "base64"); + return nacl.sign.detached.verify(message, sig, pubKey); + } catch { + return false; + } +} export function agentIdFromPublicKey(publicKeyB64: string): string { - const fullHex = crypto.createHash("sha256") + const fullHex = crypto + .createHash("sha256") .update(Buffer.from(publicKeyB64, "base64")) - .digest("hex") - return `aw:sha256:${fullHex}` + .digest("hex"); + return `aw:sha256:${fullHex}`; } export function canonicalize(value: unknown): unknown { - if (Array.isArray(value)) return value.map(canonicalize) + if (Array.isArray(value)) return value.map(canonicalize); if (value !== null && typeof value === "object") { - const sorted: Record = {} + const sorted: Record = {}; for (const k of Object.keys(value as object).sort()) { - sorted[k] = canonicalize((value as Record)[k]) + sorted[k] = canonicalize((value as Record)[k]); } - return sorted + return sorted; } - return value + return value; } -export function verifySignature(publicKeyB64: string, obj: unknown, signatureB64: string): boolean { +export function verifySignature( + publicKeyB64: string, + obj: unknown, + signatureB64: string +): boolean { try { - const pubKey = Buffer.from(publicKeyB64, "base64") - const sig = Buffer.from(signatureB64, "base64") - const msg = Buffer.from(JSON.stringify(canonicalize(obj))) - return nacl.sign.detached.verify(msg, sig, pubKey) + const pubKey = Buffer.from(publicKeyB64, "base64"); + const sig = Buffer.from(signatureB64, "base64"); + const msg = Buffer.from(JSON.stringify(canonicalize(obj))); + return nacl.sign.detached.verify(msg, sig, pubKey); } catch { - return false + return false; } } @@ -36,36 +126,39 @@ export function signPayload(payload: unknown, secretKey: Uint8Array): string { const sig = nacl.sign.detached( Buffer.from(JSON.stringify(canonicalize(payload))), secretKey - ) - return Buffer.from(sig).toString("base64") + ); + return Buffer.from(sig).toString("base64"); } // ── AgentWorld v0.2 HTTP header signing ─────────────────────────────────────── -const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000 +const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; export function computeContentDigest(body: string): string { - const hash = crypto.createHash("sha256").update(Buffer.from(body, "utf8")).digest("base64") - return `sha-256=:${hash}:` + const hash = crypto + .createHash("sha256") + .update(Buffer.from(body, "utf8")) + .digest("base64"); + return `sha-256=:${hash}:`; } export interface AwRequestHeaders { - "X-AgentWorld-Version": string - "X-AgentWorld-From": string - "X-AgentWorld-KeyId": string - "X-AgentWorld-Timestamp": string - "Content-Digest": string - "X-AgentWorld-Signature": string + "X-AgentWorld-Version": string; + "X-AgentWorld-From": string; + "X-AgentWorld-KeyId": string; + "X-AgentWorld-Timestamp": string; + "Content-Digest": string; + "X-AgentWorld-Signature": string; } function buildRequestSigningInput(opts: { - from: string - kid: string - ts: string - method: string - authority: string - path: string - contentDigest: string + from: string; + kid: string; + ts: string; + method: string; + authority: string; + path: string; + contentDigest: string; }): Record { return { v: PROTOCOL_VERSION, @@ -76,7 +169,7 @@ function buildRequestSigningInput(opts: { authority: opts.authority, path: opts.path, contentDigest: opts.contentDigest, - } + }; } /** @@ -90,24 +183,31 @@ export function signHttpRequest( path: string, body: string ): AwRequestHeaders { - const ts = new Date().toISOString() - const kid = "#identity" - const contentDigest = computeContentDigest(body) + const ts = new Date().toISOString(); + const kid = "#identity"; + const contentDigest = computeContentDigest(body); const signingInput = buildRequestSigningInput({ - from: identity.agentId, kid, ts, method, authority, path, contentDigest, - }) - const sig = nacl.sign.detached( - Buffer.from(JSON.stringify(canonicalize(signingInput))), + from: identity.agentId, + kid, + ts, + method, + authority, + path, + contentDigest, + }); + const signature = signWithDomainSeparator( + DOMAIN_SEPARATORS.HTTP_REQUEST, + signingInput, identity.secretKey - ) + ); return { "X-AgentWorld-Version": PROTOCOL_VERSION, "X-AgentWorld-From": identity.agentId, "X-AgentWorld-KeyId": kid, "X-AgentWorld-Timestamp": ts, "Content-Digest": contentDigest, - "X-AgentWorld-Signature": Buffer.from(sig).toString("base64"), - } + "X-AgentWorld-Signature": signature, + }; } /** @@ -123,53 +223,69 @@ export function verifyHttpRequestHeaders( publicKeyB64: string ): { ok: boolean; error?: string } { // Normalize to lowercase so callers can pass either Fastify req.headers or raw AwRequestHeaders - const h: Record = {} - for (const [k, v] of Object.entries(headers)) h[k.toLowerCase()] = v + const h: Record = {}; + for (const [k, v] of Object.entries(headers)) h[k.toLowerCase()] = v; - const sig = h["x-agentworld-signature"] as string | undefined - const from = h["x-agentworld-from"] as string | undefined - const kid = h["x-agentworld-keyid"] as string | undefined - const ts = h["x-agentworld-timestamp"] as string | undefined - const cd = h["content-digest"] as string | undefined + const sig = h["x-agentworld-signature"] as string | undefined; + const from = h["x-agentworld-from"] as string | undefined; + const kid = h["x-agentworld-keyid"] as string | undefined; + const ts = h["x-agentworld-timestamp"] as string | undefined; + const cd = h["content-digest"] as string | undefined; if (!sig || !from || !kid || !ts || !cd) { - return { ok: false, error: "Missing required AgentWorld headers" } + return { ok: false, error: "Missing required AgentWorld headers" }; } - const tsDiff = Math.abs(Date.now() - new Date(ts).getTime()) + const tsDiff = Math.abs(Date.now() - new Date(ts).getTime()); if (isNaN(tsDiff) || tsDiff > MAX_CLOCK_SKEW_MS) { - return { ok: false, error: "X-AgentWorld-Timestamp outside acceptable skew window" } + return { + ok: false, + error: "X-AgentWorld-Timestamp outside acceptable skew window", + }; } - const expectedDigest = computeContentDigest(body) + const expectedDigest = computeContentDigest(body); if (cd !== expectedDigest) { - return { ok: false, error: "Content-Digest mismatch" } + return { ok: false, error: "Content-Digest mismatch" }; } const signingInput = buildRequestSigningInput({ - from, kid, ts, method, authority, path, contentDigest: cd, - }) - const ok = verifySignature(publicKeyB64, signingInput, sig) - return ok ? { ok: true } : { ok: false, error: "Invalid X-AgentWorld-Signature" } + from, + kid, + ts, + method, + authority, + path, + contentDigest: cd, + }); + const ok = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.HTTP_REQUEST, + publicKeyB64, + signingInput, + sig + ); + return ok + ? { ok: true } + : { ok: false, error: "Invalid X-AgentWorld-Signature" }; } // ── AgentWorld v0.2 HTTP response signing ───────────────────────────────────── export interface AwResponseHeaders { - "X-AgentWorld-Version": string - "X-AgentWorld-From": string - "X-AgentWorld-KeyId": string - "X-AgentWorld-Timestamp": string - "Content-Digest": string - "X-AgentWorld-Signature": string + "X-AgentWorld-Version": string; + "X-AgentWorld-From": string; + "X-AgentWorld-KeyId": string; + "X-AgentWorld-Timestamp": string; + "Content-Digest": string; + "X-AgentWorld-Signature": string; } function buildResponseSigningInput(opts: { - from: string - kid: string - ts: string - status: number - contentDigest: string + from: string; + kid: string; + ts: string; + status: number; + contentDigest: string; }): Record { return { v: PROTOCOL_VERSION, @@ -178,7 +294,7 @@ function buildResponseSigningInput(opts: { ts: opts.ts, status: opts.status, contentDigest: opts.contentDigest, - } + }; } /** @@ -190,24 +306,29 @@ export function signHttpResponse( status: number, body: string ): AwResponseHeaders { - const ts = new Date().toISOString() - const kid = "#identity" - const contentDigest = computeContentDigest(body) + const ts = new Date().toISOString(); + const kid = "#identity"; + const contentDigest = computeContentDigest(body); const signingInput = buildResponseSigningInput({ - from: identity.agentId, kid, ts, status, contentDigest, - }) - const sig = nacl.sign.detached( - Buffer.from(JSON.stringify(canonicalize(signingInput))), + from: identity.agentId, + kid, + ts, + status, + contentDigest, + }); + const signature = signWithDomainSeparator( + DOMAIN_SEPARATORS.HTTP_RESPONSE, + signingInput, identity.secretKey - ) + ); return { "X-AgentWorld-Version": PROTOCOL_VERSION, "X-AgentWorld-From": identity.agentId, "X-AgentWorld-KeyId": kid, "X-AgentWorld-Timestamp": ts, "Content-Digest": contentDigest, - "X-AgentWorld-Signature": Buffer.from(sig).toString("base64"), - } + "X-AgentWorld-Signature": signature, + }; } /** @@ -221,30 +342,46 @@ export function verifyHttpResponseHeaders( publicKeyB64: string ): { ok: boolean; error?: string } { // Normalize to lowercase so callers can pass title-cased AwResponseHeaders or fetch Headers - const h: Record = {} - for (const [k, v] of Object.entries(headers)) h[k.toLowerCase()] = v + const h: Record = {}; + for (const [k, v] of Object.entries(headers)) h[k.toLowerCase()] = v; - const sig = h["x-agentworld-signature"] - const from = h["x-agentworld-from"] - const kid = h["x-agentworld-keyid"] - const ts = h["x-agentworld-timestamp"] - const cd = h["content-digest"] + const sig = h["x-agentworld-signature"]; + const from = h["x-agentworld-from"]; + const kid = h["x-agentworld-keyid"]; + const ts = h["x-agentworld-timestamp"]; + const cd = h["content-digest"]; if (!sig || !from || !kid || !ts || !cd) { - return { ok: false, error: "Missing required AgentWorld response headers" } + return { ok: false, error: "Missing required AgentWorld response headers" }; } - const tsDiff = Math.abs(Date.now() - new Date(ts).getTime()) + const tsDiff = Math.abs(Date.now() - new Date(ts).getTime()); if (isNaN(tsDiff) || tsDiff > MAX_CLOCK_SKEW_MS) { - return { ok: false, error: "X-AgentWorld-Timestamp outside acceptable skew window" } + return { + ok: false, + error: "X-AgentWorld-Timestamp outside acceptable skew window", + }; } - const expectedDigest = computeContentDigest(body) + const expectedDigest = computeContentDigest(body); if (cd !== expectedDigest) { - return { ok: false, error: "Content-Digest mismatch" } + return { ok: false, error: "Content-Digest mismatch" }; } - const signingInput = buildResponseSigningInput({ from, kid, ts, status, contentDigest: cd }) - const ok = verifySignature(publicKeyB64, signingInput, sig) - return ok ? { ok: true } : { ok: false, error: "Invalid X-AgentWorld-Signature" } + const signingInput = buildResponseSigningInput({ + from, + kid, + ts, + status, + contentDigest: cd, + }); + const ok = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.HTTP_RESPONSE, + publicKeyB64, + signingInput, + sig + ); + return ok + ? { ok: true } + : { ok: false, error: "Invalid X-AgentWorld-Signature" }; } diff --git a/packages/agent-world-sdk/src/index.ts b/packages/agent-world-sdk/src/index.ts index 61a9a36..588bdf7 100644 --- a/packages/agent-world-sdk/src/index.ts +++ b/packages/agent-world-sdk/src/index.ts @@ -1,13 +1,34 @@ -export { PROTOCOL_VERSION } from "./version.js" -export { agentIdFromPublicKey, canonicalize, verifySignature, signPayload, computeContentDigest, signHttpRequest, verifyHttpRequestHeaders, signHttpResponse, verifyHttpResponseHeaders } from "./crypto.js" -export type { AwRequestHeaders, AwResponseHeaders } from "./crypto.js" -export { loadOrCreateIdentity, deriveDidKey, toPublicKeyMultibase } from "./identity.js" -export { buildSignedAgentCard } from "./card.js" -export type { AgentCardOpts } from "./card.js" -export { PeerDb } from "./peer-db.js" -export { fetchBootstrapNodes, announceToNode, startDiscovery } from "./bootstrap.js" -export { registerPeerRoutes } from "./peer-protocol.js" -export { createWorldServer } from "./world-server.js" +export { PROTOCOL_VERSION } from "./version.js"; +export { + agentIdFromPublicKey, + canonicalize, + verifySignature, + signPayload, + computeContentDigest, + signHttpRequest, + verifyHttpRequestHeaders, + signHttpResponse, + verifyHttpResponseHeaders, + DOMAIN_SEPARATORS, + signWithDomainSeparator, + verifyWithDomainSeparator, +} from "./crypto.js"; +export type { AwRequestHeaders, AwResponseHeaders } from "./crypto.js"; +export { + loadOrCreateIdentity, + deriveDidKey, + toPublicKeyMultibase, +} from "./identity.js"; +export { buildSignedAgentCard } from "./card.js"; +export type { AgentCardOpts } from "./card.js"; +export { PeerDb } from "./peer-db.js"; +export { + fetchBootstrapNodes, + announceToNode, + startDiscovery, +} from "./bootstrap.js"; +export { registerPeerRoutes } from "./peer-protocol.js"; +export { createWorldServer } from "./world-server.js"; export type { Endpoint, PeerRecord, @@ -24,4 +45,4 @@ export type { WorldServer, KeyRotationRequest, KeyRotationIdentity, -} from "./types.js" +} from "./types.js"; diff --git a/packages/agent-world-sdk/src/peer-protocol.ts b/packages/agent-world-sdk/src/peer-protocol.ts index aefe1d1..0c4af21 100644 --- a/packages/agent-world-sdk/src/peer-protocol.ts +++ b/packages/agent-world-sdk/src/peer-protocol.ts @@ -1,28 +1,36 @@ -import type { FastifyInstance } from "fastify" -import { createHash } from "node:crypto" -import { agentIdFromPublicKey, canonicalize, verifySignature, verifyHttpRequestHeaders, signHttpResponse } from "./crypto.js" -import { PROTOCOL_VERSION } from "./version.js" -import { buildSignedAgentCard } from "./card.js" -import type { AgentCardOpts } from "./card.js" -import type { Identity, KeyRotationRequest } from "./types.js" -import type { PeerDb as PeerDbType } from "./peer-db.js" +import type { FastifyInstance } from "fastify"; +import { createHash } from "node:crypto"; +import { + agentIdFromPublicKey, + canonicalize, + verifySignature, + verifyHttpRequestHeaders, + signHttpResponse, + DOMAIN_SEPARATORS, + verifyWithDomainSeparator, +} from "./crypto.js"; +import { PROTOCOL_VERSION } from "./version.js"; +import { buildSignedAgentCard } from "./card.js"; +import type { AgentCardOpts } from "./card.js"; +import type { Identity, KeyRotationRequest } from "./types.js"; +import type { PeerDb as PeerDbType } from "./peer-db.js"; -export type { AgentCardOpts } +export type { AgentCardOpts }; export interface PeerProtocolOpts { - identity: Identity - peerDb: PeerDbType + identity: Identity; + peerDb: PeerDbType; /** Extra fields to include in /peer/ping response (evaluated on every request) */ - pingExtra?: Record | (() => Record) + pingExtra?: Record | (() => Record); /** Called when a non-peer-protocol message arrives. Return reply body or null to skip. */ onMessage?: ( agentId: string, event: string, content: unknown, reply: (body: unknown, statusCode?: number) => void - ) => Promise + ) => Promise; /** If provided, serve GET /.well-known/agent.json with a JWS-signed Agent Card */ - card?: AgentCardOpts + card?: AgentCardOpts; } /** @@ -36,53 +44,55 @@ export function registerPeerRoutes( fastify: FastifyInstance, opts: PeerProtocolOpts ): void { - const { identity, peerDb, pingExtra, onMessage, card } = opts + const { identity, peerDb, pingExtra, onMessage, card } = opts; // Custom JSON parser that preserves the raw body string for digest verification. // The raw bytes are stored on req.rawBody so verifyHttpRequestHeaders can check // Content-Digest against exactly what the sender transmitted. - fastify.decorateRequest("rawBody", "") + fastify.decorateRequest("rawBody", ""); fastify.addContentTypeParser( "application/json", { parseAs: "string" }, (req, body, done) => { try { - ;(req as unknown as { rawBody: string }).rawBody = body as string - done(null, JSON.parse(body as string)) + (req as unknown as { rawBody: string }).rawBody = body as string; + done(null, JSON.parse(body as string)); } catch (err) { - done(err as Error, undefined) + done(err as Error, undefined); } } - ) + ); // Sign all /peer/* JSON responses (P2a — AgentWorld v0.2 response signing) fastify.addHook("onSend", async (_req, reply, payload) => { - if (typeof payload !== "string") return payload - const url = (_req.url ?? "").split("?")[0] - if (!url.startsWith("/peer/")) return payload - const ct = reply.getHeader("content-type") as string | undefined - if (!ct || !ct.includes("application/json")) return payload - const hdrs = signHttpResponse(identity, reply.statusCode, payload) - for (const [k, v] of Object.entries(hdrs)) reply.header(k, v) - return payload - }) + if (typeof payload !== "string") return payload; + const url = (_req.url ?? "").split("?")[0]; + if (!url.startsWith("/peer/")) return payload; + const ct = reply.getHeader("content-type") as string | undefined; + if (!ct || !ct.includes("application/json")) return payload; + const hdrs = signHttpResponse(identity, reply.statusCode, payload); + for (const [k, v] of Object.entries(hdrs)) reply.header(k, v); + return payload; + }); // Agent Card endpoint (optional — only registered when card opts are provided) if (card) { - let cachedCardJson: string | null = null - let cachedEtag: string | null = null + let cachedCardJson: string | null = null; + let cachedEtag: string | null = null; fastify.get("/.well-known/agent.json", async (_req, reply) => { if (!cachedCardJson) { - cachedCardJson = await buildSignedAgentCard(card, identity) + cachedCardJson = await buildSignedAgentCard(card, identity); const hash = createHash("sha256") - .update(cachedCardJson, "utf8").digest("hex").slice(0, 16) - cachedEtag = `"${hash}"` + .update(cachedCardJson, "utf8") + .digest("hex") + .slice(0, 16); + cachedEtag = `"${hash}"`; } - reply.header("Content-Type", "application/json; charset=utf-8") - reply.header("Cache-Control", "public, max-age=300") - reply.header("ETag", cachedEtag!) - reply.send(cachedCardJson) - }) + reply.header("Content-Type", "application/json; charset=utf-8"); + reply.header("Cache-Control", "public, max-age=300"); + reply.header("ETag", cachedEtag!); + reply.send(cachedCardJson); + }); } fastify.get("/peer/ping", async () => ({ @@ -90,134 +100,185 @@ export function registerPeerRoutes( ts: Date.now(), agentId: identity.agentId, ...(typeof pingExtra === "function" ? pingExtra() : pingExtra), - })) + })); fastify.get("/peer/peers", async () => ({ peers: peerDb.getPeersForExchange(), - })) + })); fastify.post("/peer/announce", async (req, reply) => { - const ann = req.body as Record + const ann = req.body as Record; if (!ann?.publicKey || !ann?.from) { - return reply.code(400).send({ error: "Invalid announce" }) + return reply.code(400).send({ error: "Invalid announce" }); } - const awSig = req.headers["x-agentworld-signature"] + const awSig = req.headers["x-agentworld-signature"]; if (awSig) { - const rawBody = (req as unknown as { rawBody: string }).rawBody - const authority = (req.headers["host"] as string) ?? "localhost" + const rawBody = (req as unknown as { rawBody: string }).rawBody; + const authority = (req.headers["host"] as string) ?? "localhost"; const result = verifyHttpRequestHeaders( req.headers as Record, - req.method, req.url, authority, rawBody, ann.publicKey as string - ) - if (!result.ok) return reply.code(403).send({ error: result.error }) + req.method, + req.url, + authority, + rawBody, + ann.publicKey as string + ); + if (!result.ok) return reply.code(403).send({ error: result.error }); } else { - const { signature, ...signable } = ann - if (!verifySignature(ann.publicKey as string, signable, signature as string)) { - return reply.code(403).send({ error: "Invalid signature" }) + const { signature, ...signable } = ann; + if ( + !verifyWithDomainSeparator( + DOMAIN_SEPARATORS.ANNOUNCE, + ann.publicKey as string, + signable, + signature as string + ) + ) { + return reply.code(403).send({ error: "Invalid signature" }); } } if (agentIdFromPublicKey(ann.publicKey as string) !== ann.from) { - return reply.code(400).send({ error: "agentId does not match publicKey" }) + return reply + .code(400) + .send({ error: "agentId does not match publicKey" }); } peerDb.upsert(ann.from as string, ann.publicKey as string, { alias: ann.alias as string, endpoints: ann.endpoints as [], capabilities: ann.capabilities as [], - }) - return { peers: peerDb.getPeersForExchange() } - }) + }); + return { peers: peerDb.getPeersForExchange() }; + }); fastify.post("/peer/message", async (req, reply) => { - const msg = req.body as Record + const msg = req.body as Record; if (!msg?.publicKey || !msg?.from) { - return reply.code(400).send({ error: "Invalid message" }) + return reply.code(400).send({ error: "Invalid message" }); } - const awSig = req.headers["x-agentworld-signature"] + const awSig = req.headers["x-agentworld-signature"]; if (awSig) { - const rawBody = (req as unknown as { rawBody: string }).rawBody - const authority = (req.headers["host"] as string) ?? "localhost" + const rawBody = (req as unknown as { rawBody: string }).rawBody; + const authority = (req.headers["host"] as string) ?? "localhost"; const result = verifyHttpRequestHeaders( req.headers as Record, - req.method, req.url, authority, rawBody, msg.publicKey as string - ) - if (!result.ok) return reply.code(403).send({ error: result.error }) + req.method, + req.url, + authority, + rawBody, + msg.publicKey as string + ); + if (!result.ok) return reply.code(403).send({ error: result.error }); } else { - const { signature, ...signable } = msg - if (!verifySignature(msg.publicKey as string, signable, signature as string)) { - return reply.code(403).send({ error: "Invalid signature" }) + const { signature, ...signable } = msg; + if ( + !verifyWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + msg.publicKey as string, + signable, + signature as string + ) + ) { + return reply.code(403).send({ error: "Invalid signature" }); } } - const agentId = msg.from as string + const agentId = msg.from as string; // TOFU: verify agentId ↔ publicKey binding - const known = peerDb.get(agentId) + const known = peerDb.get(agentId); if (known?.publicKey) { if (known.publicKey !== msg.publicKey) { - return reply.code(403).send({ error: "publicKey does not match TOFU binding for this agentId" }) + return reply.code(403).send({ + error: "publicKey does not match TOFU binding for this agentId", + }); } } else { if (agentIdFromPublicKey(msg.publicKey as string) !== agentId) { - return reply.code(400).send({ error: "agentId does not match publicKey" }) + return reply + .code(400) + .send({ error: "agentId does not match publicKey" }); } } - peerDb.upsert(agentId, msg.publicKey as string, {}) + peerDb.upsert(agentId, msg.publicKey as string, {}); - let content: unknown + let content: unknown; try { - content = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content + content = + typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; } catch { - content = msg.content + content = msg.content; } if (onMessage) { - let replied = false - await onMessage(agentId, msg.event as string, content, (body, statusCode) => { - replied = true - if (statusCode) reply.code(statusCode) - reply.send(body) - }) - if (!replied) return { ok: true } + let replied = false; + await onMessage( + agentId, + msg.event as string, + content, + (body, statusCode) => { + replied = true; + if (statusCode) reply.code(statusCode); + reply.send(body); + } + ); + if (!replied) return { ok: true }; } else { - return { ok: true } + return { ok: true }; } - }) + }); // POST /peer/key-rotation — AgentWorld v0.2 §6.10/§10.4 fastify.post("/peer/key-rotation", async (req, reply) => { - const rot = req.body as unknown as KeyRotationRequest + const rot = req.body as unknown as KeyRotationRequest; - if (rot?.type !== "agentworld-identity-rotation" || rot?.version !== PROTOCOL_VERSION) { - return reply.code(400).send({ error: `Expected type=agentworld-identity-rotation and version=${PROTOCOL_VERSION}` }) + if ( + rot?.type !== "agentworld-identity-rotation" || + rot?.version !== PROTOCOL_VERSION + ) { + return reply.code(400).send({ + error: `Expected type=agentworld-identity-rotation and version=${PROTOCOL_VERSION}`, + }); } - if (!rot.oldAgentId || !rot.newAgentId || - !rot.oldIdentity?.publicKeyMultibase || - !rot.newIdentity?.publicKeyMultibase || - !rot.proofs?.signedByOld?.signature || !rot.proofs?.signedByNew?.signature) { - return reply.code(400).send({ error: "Missing required key rotation fields" }) + if ( + !rot.oldAgentId || + !rot.newAgentId || + !rot.oldIdentity?.publicKeyMultibase || + !rot.newIdentity?.publicKeyMultibase || + !rot.proofs?.signedByOld?.signature || + !rot.proofs?.signedByNew?.signature + ) { + return reply + .code(400) + .send({ error: "Missing required key rotation fields" }); } - const agentId = rot.oldAgentId - let oldPublicKeyB64: string, newPublicKeyB64: string + const agentId = rot.oldAgentId; + let oldPublicKeyB64: string, newPublicKeyB64: string; try { - oldPublicKeyB64 = multibaseToBase64(rot.oldIdentity.publicKeyMultibase) - newPublicKeyB64 = multibaseToBase64(rot.newIdentity.publicKeyMultibase) + oldPublicKeyB64 = multibaseToBase64(rot.oldIdentity.publicKeyMultibase); + newPublicKeyB64 = multibaseToBase64(rot.newIdentity.publicKeyMultibase); } catch { - return reply.code(400).send({ error: "Invalid publicKeyMultibase encoding" }) + return reply + .code(400) + .send({ error: "Invalid publicKeyMultibase encoding" }); } - const timestamp = rot.timestamp + const timestamp = rot.timestamp; if (agentIdFromPublicKey(oldPublicKeyB64) !== agentId) { - return reply.code(400).send({ error: "agentId does not match oldPublicKey" }) + return reply + .code(400) + .send({ error: "agentId does not match oldPublicKey" }); } - const MAX_AGE_MS = 5 * 60 * 1000 + const MAX_AGE_MS = 5 * 60 * 1000; if (timestamp && Math.abs(Date.now() - timestamp) > MAX_AGE_MS) { - return reply.code(400).send({ error: "Key rotation timestamp too old or too far in the future" }) + return reply.code(400).send({ + error: "Key rotation timestamp too old or too far in the future", + }); } const signable = { @@ -225,51 +286,70 @@ export function registerPeerRoutes( oldPublicKey: oldPublicKeyB64, newPublicKey: newPublicKeyB64, timestamp, + }; + if ( + !verifyWithDomainSeparator( + DOMAIN_SEPARATORS.KEY_ROTATION, + oldPublicKeyB64, + signable, + rot.proofs.signedByOld.signature + ) + ) { + return reply.code(403).send({ error: "Invalid signatureByOldKey" }); } - if (!verifySignature(oldPublicKeyB64, signable, rot.proofs.signedByOld.signature)) { - return reply.code(403).send({ error: "Invalid signatureByOldKey" }) - } - if (!verifySignature(newPublicKeyB64, signable, rot.proofs.signedByNew.signature)) { - return reply.code(403).send({ error: "Invalid signatureByNewKey" }) + if ( + !verifyWithDomainSeparator( + DOMAIN_SEPARATORS.KEY_ROTATION, + newPublicKeyB64, + signable, + rot.proofs.signedByNew.signature + ) + ) { + return reply.code(403).send({ error: "Invalid signatureByNewKey" }); } - const known = peerDb.get(agentId) + const known = peerDb.get(agentId); if (known?.publicKey && known.publicKey !== oldPublicKeyB64) { - return reply.code(403).send({ error: "TOFU binding mismatch — key-loss recovery requires manual re-pairing" }) + return reply.code(403).send({ + error: + "TOFU binding mismatch — key-loss recovery requires manual re-pairing", + }); } - peerDb.upsert(agentId, newPublicKeyB64, {}) - return { ok: true } - }) + peerDb.upsert(agentId, newPublicKeyB64, {}); + return { ok: true }; + }); } /** Convert a multibase (z) Ed25519 public key to base64. */ function multibaseToBase64(multibase: string): string { - if (!multibase.startsWith("z")) throw new Error("Unsupported multibase prefix") - const bytes = base58Decode(multibase.slice(1)) - const keyBytes = bytes.length === 34 ? bytes.slice(2) : bytes - return Buffer.from(keyBytes).toString("base64") + if (!multibase.startsWith("z")) + throw new Error("Unsupported multibase prefix"); + const bytes = base58Decode(multibase.slice(1)); + const keyBytes = bytes.length === 34 ? bytes.slice(2) : bytes; + return Buffer.from(keyBytes).toString("base64"); } -const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; function base58Decode(str: string): Uint8Array { - const bytes = [0] + const bytes = [0]; for (const char of str) { - let carry = BASE58_ALPHABET.indexOf(char) - if (carry < 0) throw new Error(`Invalid base58 char: ${char}`) + let carry = BASE58_ALPHABET.indexOf(char); + if (carry < 0) throw new Error(`Invalid base58 char: ${char}`); for (let j = 0; j < bytes.length; j++) { - carry += bytes[j] * 58 - bytes[j] = carry & 0xff - carry >>= 8 + carry += bytes[j] * 58; + bytes[j] = carry & 0xff; + carry >>= 8; } while (carry > 0) { - bytes.push(carry & 0xff) - carry >>= 8 + bytes.push(carry & 0xff); + carry >>= 8; } } for (const char of str) { - if (char === "1") bytes.push(0) - else break + if (char === "1") bytes.push(0); + else break; } - return new Uint8Array(bytes.reverse()) + return new Uint8Array(bytes.reverse()); } diff --git a/packages/agent-world-sdk/src/world-server.ts b/packages/agent-world-sdk/src/world-server.ts index 16ac022..52f8a44 100644 --- a/packages/agent-world-sdk/src/world-server.ts +++ b/packages/agent-world-sdk/src/world-server.ts @@ -1,12 +1,24 @@ -import Fastify from "fastify" -import { loadOrCreateIdentity } from "./identity.js" -import { PeerDb } from "./peer-db.js" -import { registerPeerRoutes } from "./peer-protocol.js" -import { startDiscovery } from "./bootstrap.js" -import { canonicalize, signPayload, signHttpRequest } from "./crypto.js" -import type { WorldConfig, WorldHooks, WorldServer, WorldManifest } from "./types.js" +import Fastify from "fastify"; +import { loadOrCreateIdentity } from "./identity.js"; +import { PeerDb } from "./peer-db.js"; +import { registerPeerRoutes } from "./peer-protocol.js"; +import { startDiscovery } from "./bootstrap.js"; +import { + canonicalize, + signPayload, + signHttpRequest, + DOMAIN_SEPARATORS, + signWithDomainSeparator, +} from "./crypto.js"; +import type { + WorldConfig, + WorldHooks, + WorldServer, + WorldManifest, +} from "./types.js"; -const DEFAULT_BOOTSTRAP_URL = "https://resciencelab.github.io/DAP/bootstrap.json" +const DEFAULT_BOOTSTRAP_URL = + "https://resciencelab.github.io/DAP/bootstrap.json"; /** * Start a fully-wired DAP World Agent server. @@ -46,7 +58,7 @@ export async function createWorldServer( cardUrl, cardName, cardDescription, - } = config + } = config; function buildManifest(manifest?: WorldManifest): WorldManifest { const result: WorldManifest = { @@ -54,7 +66,7 @@ export async function createWorldServer( ...manifest, type: manifest?.type ?? worldType ?? "programmatic", theme: manifest?.theme ?? worldTheme, - } + }; if (result.type === "hosted" && hostAgentId) { result.host = { @@ -62,23 +74,25 @@ export async function createWorldServer( cardUrl: hostCardUrl, endpoints: hostEndpoints, ...result.host, - } + }; } - return result + return result; } - const resolvedPublicPort = publicPort ?? port + const resolvedPublicPort = publicPort ?? port; - const identity = loadOrCreateIdentity(dataDir, "world-identity") - console.log(`[world] agentId=${identity.agentId} world=${worldId} name="${worldName}"`) + const identity = loadOrCreateIdentity(dataDir, "world-identity"); + console.log( + `[world] agentId=${identity.agentId} world=${worldId} name="${worldName}"` + ); - const peerDb = new PeerDb({ staleTtlMs }) + const peerDb = new PeerDb({ staleTtlMs }); // Track agents currently in world for idle eviction - const agentLastSeen = new Map() + const agentLastSeen = new Map(); - const fastify = Fastify({ logger: false }) + const fastify = Fastify({ logger: false }); // Register peer protocol routes registerPeerRoutes(fastify, { @@ -96,65 +110,86 @@ export async function createWorldServer( passwordRequired: password.length > 0, }), onMessage: async (agentId, event, content, sendReply) => { - const data = (content ?? {}) as Record + const data = (content ?? {}) as Record; switch (event) { case "world.join": { if (maxAgents > 0 && agentLastSeen.size >= maxAgents) { - sendReply({ error: `World is full (${maxAgents}/${maxAgents} agents)` }, 403) - return + sendReply( + { error: `World is full (${maxAgents}/${maxAgents} agents)` }, + 403 + ); + return; } if (password && data["password"] !== password) { - sendReply({ error: "Invalid password" }, 403) - return + sendReply({ error: "Invalid password" }, 403); + return; } - agentLastSeen.set(agentId, Date.now()) - const result = await hooks.onJoin(agentId, data) - sendReply({ ok: true, worldId, manifest: buildManifest(result.manifest), state: result.state }) - console.log(`[world] ${agentId.slice(0, 8)} joined — ${agentLastSeen.size} agents`) - return + agentLastSeen.set(agentId, Date.now()); + const result = await hooks.onJoin(agentId, data); + sendReply({ + ok: true, + worldId, + manifest: buildManifest(result.manifest), + state: result.state, + }); + console.log( + `[world] ${agentId.slice(0, 8)} joined — ${ + agentLastSeen.size + } agents` + ); + return; } case "world.leave": { - const wasPresent = agentLastSeen.has(agentId) - agentLastSeen.delete(agentId) + const wasPresent = agentLastSeen.has(agentId); + agentLastSeen.delete(agentId); if (wasPresent) { - await hooks.onLeave(agentId) - console.log(`[world] ${agentId.slice(0, 8)} left — ${agentLastSeen.size} agents`) + await hooks.onLeave(agentId); + console.log( + `[world] ${agentId.slice(0, 8)} left — ${ + agentLastSeen.size + } agents` + ); } - sendReply({ ok: true }) - return + sendReply({ ok: true }); + return; } case "world.action": { if (!agentLastSeen.has(agentId)) { - sendReply({ error: "Agent not in world — join first" }, 400) - return + sendReply({ error: "Agent not in world — join first" }, 400); + return; } - agentLastSeen.set(agentId, Date.now()) - const { ok, state } = await hooks.onAction(agentId, data) - sendReply({ ok, state }) - return + agentLastSeen.set(agentId, Date.now()); + const { ok, state } = await hooks.onAction(agentId, data); + sendReply({ ok, state }); + return; } default: - sendReply({ ok: true }) + sendReply({ ok: true }); } }, - }) + }); // Allow caller to register additional routes before listen - if (setupRoutes) await setupRoutes(fastify) + if (setupRoutes) await setupRoutes(fastify); - await fastify.listen({ port, host: "::" }) - console.log(`[world] Listening on [::]:${port} world=${worldId}`) + await fastify.listen({ port, host: "::" }); + console.log(`[world] Listening on [::]:${port} world=${worldId}`); // Outbound: broadcast world.state to known peers async function broadcastWorldState() { - const state = hooks.getState() - const snapshot = { worldId, worldName, theme: worldTheme, ...((state as object) ?? {}) } - const knownPeers = [...peerDb.values()].filter((p) => p.endpoints?.length) - if (!knownPeers.length) return + const state = hooks.getState(); + const snapshot = { + worldId, + worldName, + theme: worldTheme, + ...((state as object) ?? {}), + }; + const knownPeers = [...peerDb.values()].filter((p) => p.endpoints?.length); + if (!knownPeers.length) return; const payload: Record = { from: identity.agentId, @@ -162,55 +197,70 @@ export async function createWorldServer( event: "world.state", content: JSON.stringify(snapshot), timestamp: Date.now(), - } - payload["signature"] = signPayload(payload, identity.secretKey) + }; + payload["signature"] = signWithDomainSeparator( + DOMAIN_SEPARATORS.WORLD_STATE, + payload, + identity.secretKey + ); await Promise.allSettled( knownPeers.map(async (peer) => { - for (const ep of [...peer.endpoints].sort((a, b) => a.priority - b.priority)) { + for (const ep of [...peer.endpoints].sort( + (a, b) => a.priority - b.priority + )) { try { - const isIpv6 = ep.address.includes(":") && !ep.address.includes(".") + const isIpv6 = + ep.address.includes(":") && !ep.address.includes("."); const url = isIpv6 ? `http://[${ep.address}]:${ep.port ?? 8099}/peer/message` - : `http://${ep.address}:${ep.port ?? 8099}/peer/message` - const body = JSON.stringify(canonicalize(payload)) - const urlObj = new URL(url) - const awHeaders = signHttpRequest(identity, "POST", urlObj.host, "/peer/message", body) + : `http://${ep.address}:${ep.port ?? 8099}/peer/message`; + const body = JSON.stringify(canonicalize(payload)); + const urlObj = new URL(url); + const awHeaders = signHttpRequest( + identity, + "POST", + urlObj.host, + "/peer/message", + body + ); await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...awHeaders }, body, signal: AbortSignal.timeout(8_000), - }) - return - } catch { /* try next endpoint */ } + }); + return; + } catch { + /* try next endpoint */ + } } }) - ) + ); } - const broadcastTimer = setInterval(broadcastWorldState, broadcastIntervalMs) + const broadcastTimer = setInterval(broadcastWorldState, broadcastIntervalMs); // Idle agent eviction (5 min) const evictionTimer = setInterval(async () => { - const cutoff = Date.now() - 5 * 60 * 1000 + const cutoff = Date.now() - 5 * 60 * 1000; for (const [id, ts] of agentLastSeen) { if (ts < cutoff) { - agentLastSeen.delete(id) - await hooks.onLeave(id).catch(() => {}) - console.log(`[world] ${id.slice(0, 8)} evicted (idle)`) + agentLastSeen.delete(id); + await hooks.onLeave(id).catch(() => {}); + console.log(`[world] ${id.slice(0, 8)} evicted (idle)`); } } - }, 60_000) + }, 60_000); // Stale peer pruning const pruneTimer = setInterval(() => { - const pruned = peerDb.prune() - if (pruned > 0) console.log(`[world] Pruned ${pruned} stale peer(s)`) - }, 5 * 60 * 1000) + const pruned = peerDb.prune(); + if (pruned > 0) console.log(`[world] Pruned ${pruned} stale peer(s)`); + }, 5 * 60 * 1000); // Bootstrap discovery - let stopDiscovery: (() => void) | undefined + let stopDiscovery: (() => void) | undefined; if (isPublic) { stopDiscovery = await startDiscovery({ identity, @@ -221,23 +271,23 @@ export async function createWorldServer( peerDb, bootstrapUrl, intervalMs: discoveryIntervalMs, - onDiscovery: (n) => console.log(`[world] Discovery complete — ${n} peer(s)`), - }) - console.log(`[world] Public mode — announcing to DAP network`) + onDiscovery: (n) => + console.log(`[world] Discovery complete — ${n} peer(s)`), + }); + console.log(`[world] Public mode — announcing to DAP network`); } else { - console.log(`[world] Private mode — skipping DAP network announce`) + console.log(`[world] Private mode — skipping DAP network announce`); } return { fastify, identity, async stop() { - clearInterval(broadcastTimer) - clearInterval(evictionTimer) - clearInterval(pruneTimer) - stopDiscovery?.() - await fastify.close() + clearInterval(broadcastTimer); + clearInterval(evictionTimer); + clearInterval(pruneTimer); + stopDiscovery?.(); + await fastify.close(); }, - } + }; } - diff --git a/test/domain-separation.test.mjs b/test/domain-separation.test.mjs new file mode 100644 index 0000000..7ce3a28 --- /dev/null +++ b/test/domain-separation.test.mjs @@ -0,0 +1,388 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +const nacl = (await import("tweetnacl")).default; +const { + signWithDomainSeparator, + verifyWithDomainSeparator, + DOMAIN_SEPARATORS, + canonicalize, +} = await import("../packages/agent-world-sdk/dist/crypto.js"); + +describe("Domain-Separated Signatures", () => { + // Generate a test keypair + const keypair = nacl.sign.keyPair(); + const secretKey = keypair.secretKey; + const publicKeyB64 = Buffer.from(keypair.publicKey).toString("base64"); + + const testPayload = { + from: "aw:sha256:test123", + timestamp: Date.now(), + content: "test message", + }; + + test("signWithDomainSeparator produces valid signature", () => { + const sig = signWithDomainSeparator( + DOMAIN_SEPARATORS.HTTP_REQUEST, + testPayload, + secretKey + ); + assert.ok(sig); + assert.equal(typeof sig, "string"); + assert.ok(sig.length > 0); + }); + + test("verifyWithDomainSeparator validates correct signature", () => { + const sig = signWithDomainSeparator( + DOMAIN_SEPARATORS.HTTP_REQUEST, + testPayload, + secretKey + ); + const valid = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.HTTP_REQUEST, + publicKeyB64, + testPayload, + sig + ); + assert.ok(valid); + }); + + test("signature from one context FAILS verification in another context", () => { + // Sign with HTTP_REQUEST separator + const sig = signWithDomainSeparator( + DOMAIN_SEPARATORS.HTTP_REQUEST, + testPayload, + secretKey + ); + + // Try to verify with HTTP_RESPONSE separator — should FAIL + const valid = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.HTTP_RESPONSE, + publicKeyB64, + testPayload, + sig + ); + assert.equal(valid, false); + }); + + test("HTTP request signature cannot be replayed as HTTP response", () => { + const requestPayload = { + v: "0.4.3", + from: "aw:sha256:test123", + kid: "#identity", + ts: new Date().toISOString(), + method: "POST", + authority: "example.com", + path: "/peer/message", + contentDigest: "sha-256=:abc123:", + }; + + const reqSig = signWithDomainSeparator( + DOMAIN_SEPARATORS.HTTP_REQUEST, + requestPayload, + secretKey + ); + + // Attacker tries to replay request signature as a response signature + const validAsResponse = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.HTTP_RESPONSE, + publicKeyB64, + requestPayload, + reqSig + ); + assert.equal(validAsResponse, false); + }); + + test("Agent Card signature cannot be replayed as message signature", () => { + const cardPayload = { + id: "https://example.com/.well-known/agent.json", + name: "Test Agent", + extensions: { + agentworld: { + version: "0.4.3", + agentId: "aw:sha256:test123", + }, + }, + }; + + const cardSig = signWithDomainSeparator( + DOMAIN_SEPARATORS.AGENT_CARD, + cardPayload, + secretKey + ); + + // Attacker tries to replay card signature as a P2P message + const validAsMessage = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + publicKeyB64, + cardPayload, + cardSig + ); + assert.equal(validAsMessage, false); + }); + + test("Announce signature cannot be replayed as message signature", () => { + const announcePayload = { + from: "aw:sha256:test123", + publicKey: publicKeyB64, + alias: "Test Agent", + version: "0.4.3", + endpoints: [], + capabilities: ["core"], + timestamp: Date.now(), + }; + + const announceSig = signWithDomainSeparator( + DOMAIN_SEPARATORS.ANNOUNCE, + announcePayload, + secretKey + ); + + // Attacker tries to replay announce signature as a message + const validAsMessage = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + publicKeyB64, + announcePayload, + announceSig + ); + assert.equal(validAsMessage, false); + }); + + test("Key rotation signature cannot be replayed in other contexts", () => { + const rotationPayload = { + agentId: "aw:sha256:test123", + oldPublicKey: publicKeyB64, + newPublicKey: "newkey123", + timestamp: Date.now(), + }; + + const rotationSig = signWithDomainSeparator( + DOMAIN_SEPARATORS.KEY_ROTATION, + rotationPayload, + secretKey + ); + + // Attacker tries to replay as announce + const validAsAnnounce = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.ANNOUNCE, + publicKeyB64, + rotationPayload, + rotationSig + ); + assert.equal(validAsAnnounce, false); + + // Attacker tries to replay as message + const validAsMessage = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + publicKeyB64, + rotationPayload, + rotationSig + ); + assert.equal(validAsMessage, false); + }); + + test("World state signature cannot be replayed as message", () => { + const worldStatePayload = { + from: "aw:sha256:test123", + publicKey: publicKeyB64, + event: "world.state", + content: JSON.stringify({ worldId: "test", agents: 5 }), + timestamp: Date.now(), + }; + + const stateSig = signWithDomainSeparator( + DOMAIN_SEPARATORS.WORLD_STATE, + worldStatePayload, + secretKey + ); + + // Attacker tries to replay as regular message + const validAsMessage = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + publicKeyB64, + worldStatePayload, + stateSig + ); + assert.equal(validAsMessage, false); + }); + + test("tampered payload fails verification even with correct separator", () => { + const sig = signWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + testPayload, + secretKey + ); + + const tamperedPayload = { ...testPayload, content: "TAMPERED" }; + const valid = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + publicKeyB64, + tamperedPayload, + sig + ); + assert.equal(valid, false); + }); + + test("wrong public key fails verification", () => { + const sig = signWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + testPayload, + secretKey + ); + + const wrongKeypair = nacl.sign.keyPair(); + const wrongPublicKeyB64 = Buffer.from(wrongKeypair.publicKey).toString( + "base64" + ); + + const valid = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + wrongPublicKeyB64, + testPayload, + sig + ); + assert.equal(valid, false); + }); + + test("all domain separators are unique", () => { + const separators = Object.values(DOMAIN_SEPARATORS); + const uniqueSeparators = new Set(separators); + assert.equal( + separators.length, + uniqueSeparators.size, + "Domain separators must be unique" + ); + }); + + test("domain separators contain protocol version", () => { + for (const [name, separator] of Object.entries(DOMAIN_SEPARATORS)) { + assert.ok( + separator.includes("0.4.3") || separator.includes("v"), + `${name} separator should contain version` + ); + } + }); + + test("domain separators have null byte terminator", () => { + for (const [name, separator] of Object.entries(DOMAIN_SEPARATORS)) { + assert.ok( + separator.endsWith("\0"), + `${name} separator should end with null byte` + ); + } + }); + + test("domain separators start with AgentWorld prefix", () => { + for (const [name, separator] of Object.entries(DOMAIN_SEPARATORS)) { + assert.ok( + separator.startsWith("AgentWorld-"), + `${name} separator should start with AgentWorld-` + ); + } + }); + + test("payload canonicalization is deterministic", () => { + const payload = { + z: 3, + a: 1, + m: { nested: true, other: "value" }, + b: 2, + }; + + const sig1 = signWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + payload, + secretKey + ); + const sig2 = signWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + payload, + secretKey + ); + + assert.equal(sig1, sig2, "Same payload should produce same signature"); + }); + + test("payload canonicalization is order-independent", () => { + const payload1 = { a: 1, b: 2, c: 3 }; + const payload2 = { c: 3, a: 1, b: 2 }; + + const sig1 = signWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + payload1, + secretKey + ); + const sig2 = signWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + payload2, + secretKey + ); + + assert.equal( + sig1, + sig2, + "Different key order should produce same signature" + ); + }); + + test("nested object canonicalization works correctly", () => { + const payload = { + outer: { + z: "last", + a: "first", + nested: { b: 2, a: 1 }, + }, + }; + + const sig = signWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + payload, + secretKey + ); + const valid = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + publicKeyB64, + payload, + sig + ); + assert.ok(valid); + }); + + test("verifyWithDomainSeparator handles invalid base64 gracefully", () => { + const valid = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + "invalid-base64!!!", + testPayload, + "invalid-sig!!!" + ); + assert.equal(valid, false); + }); + + test("verifyWithDomainSeparator handles malformed payload gracefully", () => { + const sig = signWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + testPayload, + secretKey + ); + + // Try to verify with circular reference (would throw without proper handling) + const circularPayload = { a: 1 }; + circularPayload.self = circularPayload; + + // Should return false, not throw + try { + const valid = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.MESSAGE, + publicKeyB64, + circularPayload, + sig + ); + // If we get here without throwing, the test passes + assert.equal(typeof valid, "boolean"); + } catch (err) { + // Circular reference will throw during JSON.stringify + // This is expected behavior + assert.ok(err); + } + }); +}); From 308df032e8675cb359464505f3b06bb281024c52 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 18 Mar 2026 22:57:36 +0800 Subject: [PATCH 2/8] fix: use major.minor version in domain separators to prevent network partitioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes Codex issue #2: Extract major.minor from semantic version (0.4.3 → 0.4) to prevent network partitioning on patch releases. Changes: - version.ts: Add extractMajorMinor() function with validation - Domain separators now use "0.4" instead of "0.4.3" - Patch releases (0.4.3 → 0.4.4) maintain signature compatibility - Minor/major releases (0.4.x → 0.5.0) change domain separators (breaking) Testing: - Updated domain-separation.test.mjs to check major.minor format - All 121 tests passing - Verified PROTOCOL_VERSION = "0.4" - Verified domain separators contain "AgentWorld-Req-0.4\0" Co-Authored-By: Claude Sonnet 4.5 --- .changeset/domain-separated-signing.md | 14 +++++++++ packages/agent-world-sdk/src/version.ts | 38 ++++++++++++++++++++++--- test/domain-separation.test.mjs | 5 ++-- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/.changeset/domain-separated-signing.md b/.changeset/domain-separated-signing.md index dc13114..a7bfe8b 100644 --- a/.changeset/domain-separated-signing.md +++ b/.changeset/domain-separated-signing.md @@ -11,6 +11,7 @@ This is a BREAKING CHANGE that implements AgentWire-style domain separation acro - **Prevents cross-context replay attacks**: Signatures valid in one context (e.g., HTTP requests) cannot be replayed in another context (e.g., Agent Cards) - **Adds 7 domain separators**: HTTP_REQUEST, HTTP_RESPONSE, AGENT_CARD, KEY_ROTATION, ANNOUNCE, MESSAGE, WORLD_STATE - **Format**: `"AgentWorld-{Context}-{VERSION}\0"` (includes null byte terminator to prevent JSON confusion) +- **Version format**: Domain separators use major.minor version (e.g., "0.4" instead of "0.4.3") to prevent network partitioning on patch releases ## Breaking Changes @@ -34,6 +35,19 @@ signature = Ed25519(message, secretKey) - `signWithDomainSeparator(separator, payload, secretKey)` - Low-level signing function - `verifyWithDomainSeparator(separator, publicKey, payload, signature)` - Low-level verification function +## Version Management + +Protocol version is extracted from package.json as **major.minor only**: +- **Patch releases** (0.4.3 → 0.4.4): Maintain signature compatibility - domain separators unchanged ("0.4") +- **Minor/major releases** (0.4.x → 0.5.0): Change domain separators - breaking change ("0.4" → "0.5") + +Examples: +- Package version `0.4.3` → Domain separator contains `0.4` +- Package version `0.5.0-beta.1` → Domain separator contains `0.5` +- Package version `1.0.0` → Domain separator contains `1.0` + +This prevents network partitioning on bug-fix releases while maintaining protocol versioning on minor/major updates. + ## Migration Guide ### For Signature Verification diff --git a/packages/agent-world-sdk/src/version.ts b/packages/agent-world-sdk/src/version.ts index 1069553..88ad8b2 100644 --- a/packages/agent-world-sdk/src/version.ts +++ b/packages/agent-world-sdk/src/version.ts @@ -1,4 +1,34 @@ -import { createRequire } from "node:module" -const require = createRequire(import.meta.url) -const pkg = require("../package.json") -export const PROTOCOL_VERSION: string = pkg.version +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const pkg = require("../package.json"); + +/** + * Extract major.minor version from package.json semantic version. + * + * Protocol version used in domain separators, HTTP headers, and signature + * validation. Changes to this value are BREAKING CHANGES that invalidate + * all existing signatures. + * + * Examples: + * "0.4.3" → "0.4" + * "1.0.0-alpha.2" → "1.0" + * "2.1.5-rc.3+build" → "2.1" + * + * @throws {Error} If package.json version is not valid semver + */ +function extractMajorMinor(fullVersion: string): string { + // Validate basic semver format: X.Y.Z or X.Y.Z-prerelease+build + const semverPattern = /^\d+\.\d+\.\d+/; + if (!semverPattern.test(fullVersion)) { + throw new Error( + `Invalid semver version in package.json: "${fullVersion}". ` + + `Expected format: X.Y.Z (e.g., "0.4.3", "1.0.0-alpha.2")` + ); + } + + // Extract major.minor by splitting on '.' and taking first two parts + const parts = fullVersion.split("."); + return `${parts[0]}.${parts[1]}`; +} + +export const PROTOCOL_VERSION: string = extractMajorMinor(pkg.version); diff --git a/test/domain-separation.test.mjs b/test/domain-separation.test.mjs index 7ce3a28..e4f3e26 100644 --- a/test/domain-separation.test.mjs +++ b/test/domain-separation.test.mjs @@ -256,9 +256,10 @@ describe("Domain-Separated Signatures", () => { test("domain separators contain protocol version", () => { for (const [name, separator] of Object.entries(DOMAIN_SEPARATORS)) { + // Version format is major.minor (e.g., "0.4" from "0.4.3") assert.ok( - separator.includes("0.4.3") || separator.includes("v"), - `${name} separator should contain version` + separator.includes("0.4") || /\d+\.\d+/.test(separator), + `${name} separator should contain version (major.minor format)` ); } }); From 8c2135fa25153c81434d13f7a9499f03c1ee24c1 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 18 Mar 2026 23:49:12 +0800 Subject: [PATCH 3/8] fix: add Agent Card verification helper and round-trip test Addresses Codex Issue #1 (High Priority): Missing verification helper for Agent Cards. Changes: - Add verifyAgentCard() helper to card.ts - Implements AgentWire-compliant JWS verification flow - Reconstructs domain-separated payload - Verifies signature over JWS signing input format - Handles base64url encoding from jose library - Export verifyAgentCard from index.ts - Add comprehensive round-trip test: - Sign with buildSignedAgentCard() - Verify with verifyAgentCard() - Test failure with wrong public key - Test failure with tampered card The helper enables third-party implementations to verify Agent Card signatures without manually implementing domain-separator logic. Verification: All 122 tests passing (added 1 new test) Co-Authored-By: Claude Sonnet 4.5 --- packages/agent-world-sdk/src/card.ts | 79 ++++++++++++++++++++++++++- packages/agent-world-sdk/src/index.ts | 2 +- test/domain-separation.test.mjs | 47 ++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/packages/agent-world-sdk/src/card.ts b/packages/agent-world-sdk/src/card.ts index c74108e..6f25319 100644 --- a/packages/agent-world-sdk/src/card.ts +++ b/packages/agent-world-sdk/src/card.ts @@ -10,7 +10,12 @@ */ import { FlattenedSign } from "jose"; import { createPrivateKey } from "node:crypto"; -import { canonicalize, DOMAIN_SEPARATORS } from "./crypto.js"; +import nacl from "tweetnacl"; +import { + canonicalize, + DOMAIN_SEPARATORS, + verifyWithDomainSeparator, +} from "./crypto.js"; import { deriveDidKey, toPublicKeyMultibase } from "./identity.js"; import { PROTOCOL_VERSION } from "./version.js"; import type { Identity } from "./types.js"; @@ -127,3 +132,75 @@ export async function buildSignedAgentCard( }; return JSON.stringify(canonicalize(signedCard)); } + +/** + * Verify an Agent Card JWS signature. + * + * Reconstructs the domain-separated payload and verifies the EdDSA signature + * using the AGENT_CARD domain separator. The card must have been signed with + * buildSignedAgentCard(). + * + * This helper function implements the AgentWire-compliant JWS verification flow: + * 1. Extract the signatures field and protected header from the card + * 2. Strip signatures to get the unsigned card + * 3. Canonicalize the unsigned card + * 4. Prepend DOMAIN_SEPARATORS.AGENT_CARD + * 5. Reconstruct JWS signing input: BASE64URL(protected) + '.' + BASE64URL(payload) + * 6. Verify the Ed25519 signature over the JWS signing input + * + * @param cardJson - The signed Agent Card JSON string + * @param expectedPublicKeyB64 - Base64-encoded Ed25519 public key to verify against + * @returns true if signature is valid, false otherwise + */ +export function verifyAgentCard( + cardJson: string, + expectedPublicKeyB64: string +): boolean { + try { + const card = JSON.parse(cardJson); + + // Extract signature entry + const signatures = card.signatures; + if (!signatures || signatures.length === 0) { + return false; + } + + const jwsProtected = signatures[0].protected; + const jwsSignature = signatures[0].signature; + + // Remove signatures field to get unsigned card + const { signatures: _, ...unsignedCard } = card; + + // Reconstruct domain-separated payload + const canonicalCard = JSON.stringify(canonicalize(unsignedCard)); + const domainPrefix = Buffer.from(DOMAIN_SEPARATORS.AGENT_CARD, "utf8"); + const cardBytes = Buffer.from(canonicalCard, "utf8"); + const payload = Buffer.concat([domainPrefix, cardBytes]); + + // Convert payload to base64url for JWS signing input + const payloadBase64url = Buffer.from(payload) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + + // Reconstruct JWS signing input: protected + '.' + payload + const jwsSigningInput = Buffer.from( + jwsProtected + "." + payloadBase64url, + "utf8" + ); + + // Verify signature (JWS signatures are base64url encoded) + const signatureBytes = Buffer.from(jwsSignature, "base64url"); + const publicKeyBytes = Buffer.from(expectedPublicKeyB64, "base64"); + + // Verify the Ed25519 signature over the JWS signing input + return nacl.sign.detached.verify( + jwsSigningInput, + signatureBytes, + publicKeyBytes + ); + } catch { + return false; + } +} diff --git a/packages/agent-world-sdk/src/index.ts b/packages/agent-world-sdk/src/index.ts index 588bdf7..7db2d67 100644 --- a/packages/agent-world-sdk/src/index.ts +++ b/packages/agent-world-sdk/src/index.ts @@ -19,7 +19,7 @@ export { deriveDidKey, toPublicKeyMultibase, } from "./identity.js"; -export { buildSignedAgentCard } from "./card.js"; +export { buildSignedAgentCard, verifyAgentCard } from "./card.js"; export type { AgentCardOpts } from "./card.js"; export { PeerDb } from "./peer-db.js"; export { diff --git a/test/domain-separation.test.mjs b/test/domain-separation.test.mjs index e4f3e26..8b06e2d 100644 --- a/test/domain-separation.test.mjs +++ b/test/domain-separation.test.mjs @@ -386,4 +386,51 @@ describe("Domain-Separated Signatures", () => { assert.ok(err); } }); + + test("Agent Card round-trip: sign and verify", async () => { + const { buildSignedAgentCard, verifyAgentCard } = await import( + "../packages/agent-world-sdk/dist/card.js" + ); + + const identity = { + agentId: "aw:sha256:test123", + pubB64: publicKeyB64, + secretKey: secretKey, + }; + + const cardJson = await buildSignedAgentCard( + { + name: "Test Agent", + cardUrl: "https://example.com/.well-known/agent.json", + }, + identity + ); + + // Should verify with correct public key + const valid = verifyAgentCard(cardJson, publicKeyB64); + assert.ok(valid, "Agent Card should verify with correct public key"); + + // Should fail with wrong public key + const wrongKeypair = nacl.sign.keyPair(); + const wrongPubB64 = Buffer.from(wrongKeypair.publicKey).toString( + "base64" + ); + const invalidWithWrongKey = verifyAgentCard(cardJson, wrongPubB64); + assert.equal( + invalidWithWrongKey, + false, + "Agent Card should fail with wrong public key" + ); + + // Should fail with tampered card + const card = JSON.parse(cardJson); + card.name = "TAMPERED"; + const tamperedJson = JSON.stringify(card); + const invalidTampered = verifyAgentCard(tamperedJson, publicKeyB64); + assert.equal( + invalidTampered, + false, + "Agent Card should fail when tampered" + ); + }); }); From 9917f7cf74a0c51221fcbde372a93b6319592d7f Mon Sep 17 00:00:00 2001 From: Yilin <69336584+Jing-yilin@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:21:55 +0800 Subject: [PATCH 4/8] feat: blockchain-inspired World Ledger for agent activity tracking (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Append-only event ledger with hash chain and world signatures, inspired by blockchain design. ### Core Design | Blockchain Concept | World Ledger Implementation | |---|---| | Genesis block | `world.genesis` entry on world startup | | Transaction | Each agent event: join, action, leave, evict | | Block hash chain | Each entry contains `prevHash` (SHA-256 of previous entry) | | Signed transaction | Entries signed by world's Ed25519 identity | | State = f(events) | Agent summaries derived by replaying event log | | Immutable ledger | JSON Lines file, append-only | ### Changes **New: `WorldLedger` class** (`packages/agent-world-sdk/src/world-ledger.ts`) - Append-only event log persisted as `.jsonl` - Hash chain: each entry references previous entry's SHA-256 - World signature on every entry (domain-separated Ed25519) - `getAgentSummaries()` — derive current state from event replay - `verify()` — validate entire chain integrity - Query with filtering (by agent, event type, time range, limit) **Integration into `world-server.ts`** - Auto-records join/leave/action/evict in ledger - `GET /world/ledger` — query ledger entries with filters - `GET /world/agents` — agent summaries derived from ledger - Ledger exposed on `WorldServer` return value **Types** (`types.ts`) - `LedgerEntry`, `LedgerEvent`, `AgentSummary`, `LedgerQueryOpts` **Tests** (`test/world-ledger.test.mjs`) - 13 new tests: genesis, hash chain, persistence, tamper detection, filtering, agent summaries ### Test Results 144/144 tests pass (131 existing + 13 new) --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/agent-world-sdk/src/index.ts | 5 + packages/agent-world-sdk/src/types.ts | 41 +++ packages/agent-world-sdk/src/world-ledger.ts | 230 +++++++++++++++++ packages/agent-world-sdk/src/world-server.ts | 39 +++ src/identity.ts | 6 +- src/peer-server.ts | 3 +- test/key-rotation.test.mjs | 3 +- test/request-signing.test.mjs | 3 +- test/response-signing.test.mjs | 3 +- test/world-ledger.test.mjs | 255 +++++++++++++++++++ 10 files changed, 583 insertions(+), 5 deletions(-) create mode 100644 packages/agent-world-sdk/src/world-ledger.ts create mode 100644 test/world-ledger.test.mjs diff --git a/packages/agent-world-sdk/src/index.ts b/packages/agent-world-sdk/src/index.ts index 7db2d67..3033403 100644 --- a/packages/agent-world-sdk/src/index.ts +++ b/packages/agent-world-sdk/src/index.ts @@ -29,6 +29,7 @@ export { } from "./bootstrap.js"; export { registerPeerRoutes } from "./peer-protocol.js"; export { createWorldServer } from "./world-server.js"; +export { WorldLedger } from "./world-ledger.js"; export type { Endpoint, PeerRecord, @@ -45,4 +46,8 @@ export type { WorldServer, KeyRotationRequest, KeyRotationIdentity, + LedgerEntry, + LedgerEvent, + AgentSummary, + LedgerQueryOpts, } from "./types.js"; diff --git a/packages/agent-world-sdk/src/types.ts b/packages/agent-world-sdk/src/types.ts index 95ca855..cbe195b 100644 --- a/packages/agent-world-sdk/src/types.ts +++ b/packages/agent-world-sdk/src/types.ts @@ -142,9 +142,50 @@ export interface WorldServer { /** Underlying Fastify instance — register additional routes here */ fastify: import("fastify").FastifyInstance identity: Identity + /** Append-only event ledger for agent activity */ + ledger: import("./world-ledger.js").WorldLedger stop(): Promise } +// ── World Ledger (append-only event log) ─────────────────────────────────────── + +export type LedgerEvent = + | "world.genesis" + | "world.join" + | "world.leave" + | "world.evict" + | "world.action" + +export interface LedgerEntry { + seq: number + prevHash: string + timestamp: number + event: LedgerEvent + agentId: string + alias?: string + data?: Record + hash: string + worldSig: string +} + +export interface AgentSummary { + agentId: string + alias: string + firstSeen: number + lastSeen: number + actions: number + joins: number + online: boolean +} + +export interface LedgerQueryOpts { + agentId?: string + event?: LedgerEvent | LedgerEvent[] + since?: number + until?: number + limit?: number +} + // ── Key rotation (AgentWorld v0.2 §6.10/§10.4) ──────────────────────────────── export interface KeyRotationIdentity { diff --git a/packages/agent-world-sdk/src/world-ledger.ts b/packages/agent-world-sdk/src/world-ledger.ts new file mode 100644 index 0000000..3be61bc --- /dev/null +++ b/packages/agent-world-sdk/src/world-ledger.ts @@ -0,0 +1,230 @@ +import fs from "fs" +import path from "path" +import crypto from "node:crypto" +import { signWithDomainSeparator, verifyWithDomainSeparator, DOMAIN_SEPARATORS } from "./crypto.js" +import type { Identity } from "./types.js" +import type { LedgerEntry, LedgerEvent, AgentSummary, LedgerQueryOpts } from "./types.js" + +const ZERO_HASH = "0".repeat(64) +const LEDGER_DOMAIN = `AgentWorld-Ledger-${DOMAIN_SEPARATORS.MESSAGE.split("-").slice(-1)[0].replace("\0", "")}` +const LEDGER_SEPARATOR = `AgentWorld-Ledger-${DOMAIN_SEPARATORS.MESSAGE.split("-")[2]}` + +/** + * Append-only event ledger for World Agent activity. + * + * Blockchain-inspired design: + * - Each entry references the previous entry's hash (hash chain) + * - Entries are signed by the world's identity (tamper-evident) + * - State is derived from replaying the event log + * - Persisted as JSON Lines (.jsonl) — one entry per line + */ +export class WorldLedger { + private entries: LedgerEntry[] = [] + private filePath: string + private identity: Identity + private worldId: string + /** Number of raw lines that failed to parse on load (0 = clean) */ + public corruptedLines = 0 + + constructor(dataDir: string, worldId: string, identity: Identity) { + this.filePath = path.join(dataDir, "world-ledger.jsonl") + this.identity = identity + this.worldId = worldId + this.load() + } + + private load(): void { + if (!fs.existsSync(this.filePath)) { + this.writeGenesis() + return + } + + const lines = fs.readFileSync(this.filePath, "utf8").trim().split("\n").filter(Boolean) + let corrupted = 0 + for (const line of lines) { + try { + this.entries.push(JSON.parse(line) as LedgerEntry) + } catch { + corrupted++ + } + } + this.corruptedLines = corrupted + + if (corrupted > 0) { + console.warn(`[ledger] WARNING: ${corrupted} corrupted line(s) detected in ${this.filePath}`) + } + + if (this.entries.length === 0) { + this.writeGenesis() + } + } + + private writeGenesis(): void { + const entry = this.buildEntry("world.genesis", this.identity.agentId, undefined, { + worldId: this.worldId, + }) + this.entries.push(entry) + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }) + fs.writeFileSync(this.filePath, JSON.stringify(entry) + "\n") + } + + private lastHash(): string { + if (this.entries.length === 0) return ZERO_HASH + return this.entries[this.entries.length - 1].hash + } + + private buildEntry( + event: LedgerEvent, + agentId: string, + alias?: string, + data?: Record + ): LedgerEntry { + const seq = this.entries.length + const prevHash = this.lastHash() + const timestamp = Date.now() + + const core = { seq, prevHash, timestamp, event, agentId, ...(alias ? { alias } : {}), ...(data ? { data } : {}) } + const hash = crypto.createHash("sha256").update(JSON.stringify(core)).digest("hex") + + const sigPayload = { ...core, hash } + const worldSig = signWithDomainSeparator(LEDGER_SEPARATOR, sigPayload, this.identity.secretKey) + + return { ...core, hash, worldSig } + } + + append(event: LedgerEvent, agentId: string, alias?: string, data?: Record): LedgerEntry { + const entry = this.buildEntry(event, agentId, alias, data) + this.entries.push(entry) + fs.appendFileSync(this.filePath, JSON.stringify(entry) + "\n") + return entry + } + + getEntries(opts?: LedgerQueryOpts): LedgerEntry[] { + let result = this.entries + + if (opts?.agentId) { + result = result.filter(e => e.agentId === opts.agentId) + } + if (opts?.event) { + const events = Array.isArray(opts.event) ? opts.event : [opts.event] + result = result.filter(e => events.includes(e.event)) + } + if (opts?.since) { + result = result.filter(e => e.timestamp >= opts.since!) + } + if (opts?.until) { + result = result.filter(e => e.timestamp <= opts.until!) + } + if (opts?.limit) { + result = result.slice(-opts.limit) + } + return result + } + + /** + * Derive agent summaries from the event log. + * + * @param liveAgentIds Optional set of agent IDs currently in the live session. + * When provided, `online` is true only if the agent is in this set. + * When omitted, `online` is derived from the event log (may be stale after restart). + */ + getAgentSummaries(liveAgentIds?: Set): AgentSummary[] { + const map = new Map() + + for (const entry of this.entries) { + if (entry.event === "world.genesis") continue + const id = entry.agentId + let summary = map.get(id) + if (!summary) { + summary = { agentId: id, alias: "", firstSeen: entry.timestamp, lastSeen: entry.timestamp, actions: 0, joins: 0, online: false } + map.set(id, summary) + } + + if (entry.alias) summary.alias = entry.alias + summary.lastSeen = entry.timestamp + + switch (entry.event) { + case "world.join": + summary.joins++ + summary.online = true + break + case "world.action": + summary.actions++ + break + case "world.leave": + case "world.evict": + summary.online = false + break + } + } + + // If live session info is available, use it as the source of truth for online status + if (liveAgentIds) { + for (const summary of map.values()) { + summary.online = liveAgentIds.has(summary.agentId) + } + } + + return [...map.values()].sort((a, b) => b.lastSeen - a.lastSeen) + } + + /** + * Verify the entire chain's integrity: hash chain + world signatures. + * Returns { ok, errors } where errors lists any broken entries. + */ + verify(): { ok: boolean; errors: Array<{ seq: number; error: string }> } { + const errors: Array<{ seq: number; error: string }> = [] + + // Detect corrupted/dropped lines from load + if (this.corruptedLines > 0) { + errors.push({ seq: -1, error: `${this.corruptedLines} corrupted line(s) dropped during load — possible data loss` }) + } + + for (let i = 0; i < this.entries.length; i++) { + const entry = this.entries[i] + + // Detect seq gaps (entries dropped from middle of chain) + if (entry.seq !== i) { + errors.push({ seq: entry.seq, error: `seq gap: expected ${i}, got ${entry.seq}` }) + } + + // Verify prevHash chain + const expectedPrev = i === 0 ? ZERO_HASH : this.entries[i - 1].hash + if (entry.prevHash !== expectedPrev) { + errors.push({ seq: entry.seq, error: `prevHash mismatch: expected ${expectedPrev.slice(0, 8)}..., got ${entry.prevHash.slice(0, 8)}...` }) + } + + // Verify self-hash + const { hash, worldSig, ...core } = entry + const expectedHash = crypto.createHash("sha256").update(JSON.stringify(core)).digest("hex") + if (hash !== expectedHash) { + errors.push({ seq: entry.seq, error: "hash mismatch" }) + } + + // Verify world signature + const sigPayload = { ...core, hash } + const valid = verifyWithDomainSeparator(LEDGER_SEPARATOR, this.identity.pubB64, sigPayload, worldSig) + if (!valid) { + errors.push({ seq: entry.seq, error: "invalid worldSig" }) + } + } + + return { ok: errors.length === 0, errors } + } + + get length(): number { + return this.entries.length + } + + get head(): LedgerEntry | undefined { + return this.entries[this.entries.length - 1] + } +} diff --git a/packages/agent-world-sdk/src/world-server.ts b/packages/agent-world-sdk/src/world-server.ts index 52f8a44..1e75bd6 100644 --- a/packages/agent-world-sdk/src/world-server.ts +++ b/packages/agent-world-sdk/src/world-server.ts @@ -10,11 +10,13 @@ import { DOMAIN_SEPARATORS, signWithDomainSeparator, } from "./crypto.js"; +import { WorldLedger } from "./world-ledger.js"; import type { WorldConfig, WorldHooks, WorldServer, WorldManifest, + LedgerQueryOpts, } from "./types.js"; const DEFAULT_BOOTSTRAP_URL = @@ -92,6 +94,12 @@ export async function createWorldServer( // Track agents currently in world for idle eviction const agentLastSeen = new Map(); + // Append-only event ledger — blockchain-inspired agent activity log + const ledger = new WorldLedger(dataDir, worldId, identity); + console.log( + `[world] Ledger loaded — ${ledger.length} entries, head=${ledger.head?.hash.slice(0, 8) ?? "none"}` + ); + const fastify = Fastify({ logger: false }); // Register peer protocol routes @@ -127,6 +135,7 @@ export async function createWorldServer( } agentLastSeen.set(agentId, Date.now()); const result = await hooks.onJoin(agentId, data); + ledger.append("world.join", agentId, (data["alias"] ?? data["name"]) as string | undefined); sendReply({ ok: true, worldId, @@ -146,6 +155,7 @@ export async function createWorldServer( agentLastSeen.delete(agentId); if (wasPresent) { await hooks.onLeave(agentId); + ledger.append("world.leave", agentId); console.log( `[world] ${agentId.slice(0, 8)} left — ${ agentLastSeen.size @@ -163,6 +173,7 @@ export async function createWorldServer( } agentLastSeen.set(agentId, Date.now()); const { ok, state } = await hooks.onAction(agentId, data); + ledger.append("world.action", agentId, undefined, { action: data["action"] as string | undefined }); sendReply({ ok, state }); return; } @@ -173,6 +184,32 @@ export async function createWorldServer( }, }); + // World ledger HTTP endpoints + fastify.get("/world/ledger", async (req) => { + const query = req.query as Record; + const opts: LedgerQueryOpts = {}; + if (query.agent_id) opts.agentId = query.agent_id; + if (query.event) opts.event = query.event.split(",") as LedgerQueryOpts["event"]; + if (query.since) opts.since = parseInt(query.since); + if (query.until) opts.until = parseInt(query.until); + if (query.limit) opts.limit = parseInt(query.limit); + return { + ok: true, + worldId, + chainHead: ledger.head?.hash ?? null, + total: ledger.length, + entries: ledger.getEntries(opts), + }; + }); + + fastify.get("/world/agents", async () => { + return { + ok: true, + worldId, + agents: ledger.getAgentSummaries(new Set(agentLastSeen.keys())), + }; + }); + // Allow caller to register additional routes before listen if (setupRoutes) await setupRoutes(fastify); @@ -248,6 +285,7 @@ export async function createWorldServer( if (ts < cutoff) { agentLastSeen.delete(id); await hooks.onLeave(id).catch(() => {}); + ledger.append("world.evict", id, undefined, { reason: "idle" }); console.log(`[world] ${id.slice(0, 8)} evicted (idle)`); } } @@ -282,6 +320,7 @@ export async function createWorldServer( return { fastify, identity, + ledger, async stop() { clearInterval(broadcastTimer); clearInterval(evictionTimer); diff --git a/src/identity.ts b/src/identity.ts index dd55d0e..39ae91d 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -12,8 +12,12 @@ import * as path from "path" import * as os from "os" import { Identity, AwRequestHeaders, AwResponseHeaders } from "./types" +// Protocol version for HTTP signatures and domain separators. +// Uses major.minor from package.json — only changes on breaking protocol updates. +// This MUST match the SDK's PROTOCOL_VERSION to allow cross-node signature verification. // eslint-disable-next-line @typescript-eslint/no-var-requires -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion: string = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") // ── did:key mapping ───────────────────────────────────────────────────────── diff --git a/src/peer-server.ts b/src/peer-server.ts index 08f6561..093a556 100644 --- a/src/peer-server.ts +++ b/src/peer-server.ts @@ -12,7 +12,8 @@ import Fastify, { FastifyInstance } from "fastify" import { P2PMessage, Identity, Endpoint } from "./types" import { verifySignature, agentIdFromPublicKey, verifyHttpRequestHeaders, signHttpResponse as signHttpResponseFn } from "./identity" // eslint-disable-next-line @typescript-eslint/no-var-requires -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion: string = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") import { tofuVerifyAndCache, tofuReplaceKey, getPeersForExchange, upsertDiscoveredPeer, removePeer, getPeer } from "./peer-db" const MAX_MESSAGE_AGE_MS = 5 * 60 * 1000 // 5 minutes diff --git a/test/key-rotation.test.mjs b/test/key-rotation.test.mjs index 2a65ad7..6639c29 100644 --- a/test/key-rotation.test.mjs +++ b/test/key-rotation.test.mjs @@ -8,7 +8,8 @@ const nacl = (await import("tweetnacl")).default import { createRequire } from "node:module" const require = createRequire(import.meta.url) -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const { startPeerServer, stopPeerServer } = await import("../dist/peer-server.js") const { initDb } = await import("../dist/peer-db.js") diff --git a/test/request-signing.test.mjs b/test/request-signing.test.mjs index f4278a8..9196a92 100644 --- a/test/request-signing.test.mjs +++ b/test/request-signing.test.mjs @@ -17,7 +17,8 @@ import crypto from "node:crypto" import { createRequire } from "node:module" const require = createRequire(import.meta.url) -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const nacl = (await import("tweetnacl")).default diff --git a/test/response-signing.test.mjs b/test/response-signing.test.mjs index 56d819e..bc36f07 100644 --- a/test/response-signing.test.mjs +++ b/test/response-signing.test.mjs @@ -14,7 +14,8 @@ import crypto from "node:crypto" import { createRequire } from "node:module" const require = createRequire(import.meta.url) -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const nacl = (await import("tweetnacl")).default diff --git a/test/world-ledger.test.mjs b/test/world-ledger.test.mjs new file mode 100644 index 0000000..fe15d21 --- /dev/null +++ b/test/world-ledger.test.mjs @@ -0,0 +1,255 @@ +import { describe, it, beforeEach, afterEach } from "node:test" +import assert from "node:assert/strict" +import fs from "fs" +import path from "path" +import os from "os" +import { WorldLedger } from "../packages/agent-world-sdk/dist/world-ledger.js" +import { loadOrCreateIdentity } from "../packages/agent-world-sdk/dist/identity.js" + +let tmpDir +let identity + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ledger-test-")) + identity = loadOrCreateIdentity(tmpDir, "test-identity") +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe("WorldLedger", () => { + it("creates genesis entry on first init", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + assert.equal(ledger.length, 1) + const entries = ledger.getEntries() + assert.equal(entries[0].event, "world.genesis") + assert.equal(entries[0].seq, 0) + assert.equal(entries[0].prevHash, "0".repeat(64)) + assert.equal(entries[0].agentId, identity.agentId) + assert.ok(entries[0].data?.worldId, "genesis should contain worldId") + assert.ok(entries[0].hash) + assert.ok(entries[0].worldSig) + }) + + it("appends join/action/leave events with hash chain", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const agentId = "aw:sha256:aabbccdd" + + const joinEntry = ledger.append("world.join", agentId, "TestBot") + assert.equal(joinEntry.seq, 1) + assert.equal(joinEntry.event, "world.join") + assert.equal(joinEntry.agentId, agentId) + assert.equal(joinEntry.alias, "TestBot") + assert.equal(joinEntry.prevHash, ledger.getEntries()[0].hash) + + const actionEntry = ledger.append("world.action", agentId, undefined, { action: "move" }) + assert.equal(actionEntry.seq, 2) + assert.equal(actionEntry.prevHash, joinEntry.hash) + assert.deepEqual(actionEntry.data, { action: "move" }) + + const leaveEntry = ledger.append("world.leave", agentId) + assert.equal(leaveEntry.seq, 3) + assert.equal(leaveEntry.prevHash, actionEntry.hash) + + assert.equal(ledger.length, 4) + }) + + it("persists to disk and reloads on new instance", () => { + const ledger1 = new WorldLedger(tmpDir, "test-world", identity) + ledger1.append("world.join", "aw:sha256:agent1", "Alpha") + ledger1.append("world.action", "aw:sha256:agent1", undefined, { action: "attack" }) + assert.equal(ledger1.length, 3) + + const ledger2 = new WorldLedger(tmpDir, "test-world", identity) + assert.equal(ledger2.length, 3) + const entries = ledger2.getEntries() + assert.equal(entries[0].event, "world.genesis") + assert.equal(entries[1].event, "world.join") + assert.equal(entries[1].alias, "Alpha") + assert.equal(entries[2].event, "world.action") + }) + + it("verify() passes on valid chain", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1", "Bot1") + ledger.append("world.action", "aw:sha256:a1") + ledger.append("world.leave", "aw:sha256:a1") + + const result = ledger.verify() + assert.equal(result.ok, true) + assert.equal(result.errors.length, 0) + }) + + it("verify() detects tampered entry on reload", () => { + const ledger1 = new WorldLedger(tmpDir, "test-world", identity) + ledger1.append("world.join", "aw:sha256:a1", "Bot1") + + // Tamper with the file: change the alias in the second line + const filePath = path.join(tmpDir, "world-ledger.jsonl") + const lines = fs.readFileSync(filePath, "utf8").trim().split("\n") + const entry = JSON.parse(lines[1]) + entry.alias = "TAMPERED" + lines[1] = JSON.stringify(entry) + fs.writeFileSync(filePath, lines.join("\n") + "\n") + + const ledger2 = new WorldLedger(tmpDir, "test-world", identity) + const result = ledger2.verify() + assert.equal(result.ok, false) + assert.ok(result.errors.length > 0) + }) + + it("getAgentSummaries() derives correct state from events", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const a1 = "aw:sha256:agent1" + const a2 = "aw:sha256:agent2" + + ledger.append("world.join", a1, "Alpha") + ledger.append("world.join", a2, "Beta") + ledger.append("world.action", a1, undefined, { action: "move" }) + ledger.append("world.action", a1, undefined, { action: "attack" }) + ledger.append("world.action", a2, undefined, { action: "defend" }) + ledger.append("world.leave", a2) + + const summaries = ledger.getAgentSummaries() + assert.equal(summaries.length, 2) + + const alpha = summaries.find(s => s.agentId === a1) + assert.ok(alpha) + assert.equal(alpha.alias, "Alpha") + assert.equal(alpha.joins, 1) + assert.equal(alpha.actions, 2) + assert.equal(alpha.online, true) + + const beta = summaries.find(s => s.agentId === a2) + assert.ok(beta) + assert.equal(beta.alias, "Beta") + assert.equal(beta.joins, 1) + assert.equal(beta.actions, 1) + assert.equal(beta.online, false) + }) + + it("getAgentSummaries() tracks re-joins", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const a1 = "aw:sha256:agent1" + + ledger.append("world.join", a1, "Alpha") + ledger.append("world.leave", a1) + ledger.append("world.join", a1, "Alpha v2") + + const summaries = ledger.getAgentSummaries() + const alpha = summaries.find(s => s.agentId === a1) + assert.equal(alpha.joins, 2) + assert.equal(alpha.online, true) + assert.equal(alpha.alias, "Alpha v2") + }) + + it("getEntries() supports filtering by agentId", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1", "Alpha") + ledger.append("world.join", "aw:sha256:a2", "Beta") + ledger.append("world.action", "aw:sha256:a1") + + const filtered = ledger.getEntries({ agentId: "aw:sha256:a1" }) + assert.equal(filtered.length, 2) + assert.ok(filtered.every(e => e.agentId === "aw:sha256:a1")) + }) + + it("getEntries() supports filtering by event type", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1") + ledger.append("world.action", "aw:sha256:a1") + ledger.append("world.leave", "aw:sha256:a1") + + const joins = ledger.getEntries({ event: "world.join" }) + assert.equal(joins.length, 1) + + const multi = ledger.getEntries({ event: ["world.join", "world.leave"] }) + assert.equal(multi.length, 2) + }) + + it("getEntries() supports limit (returns last N)", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + for (let i = 0; i < 10; i++) { + ledger.append("world.action", "aw:sha256:a1") + } + const last3 = ledger.getEntries({ limit: 3 }) + assert.equal(last3.length, 3) + assert.equal(last3[0].seq, 8) + }) + + it("head returns the last entry", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const entry = ledger.append("world.join", "aw:sha256:a1", "Alpha") + assert.equal(ledger.head?.hash, entry.hash) + }) + + it("evict event is recorded properly", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1", "Alpha") + ledger.append("world.evict", "aw:sha256:a1", undefined, { reason: "idle" }) + + const summaries = ledger.getAgentSummaries() + const alpha = summaries.find(s => s.agentId === "aw:sha256:a1") + assert.equal(alpha.online, false) + + const evicts = ledger.getEntries({ event: "world.evict" }) + assert.equal(evicts.length, 1) + assert.deepEqual(evicts[0].data, { reason: "idle" }) + }) + + it("each entry hash is unique", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1") + ledger.append("world.join", "aw:sha256:a2") + ledger.append("world.action", "aw:sha256:a1") + + const hashes = ledger.getEntries().map(e => e.hash) + const uniqueHashes = new Set(hashes) + assert.equal(uniqueHashes.size, hashes.length) + }) + + it("verify() detects corrupted/truncated lines on load", () => { + const ledger1 = new WorldLedger(tmpDir, "test-world", identity) + ledger1.append("world.join", "aw:sha256:a1", "Bot1") + assert.equal(ledger1.length, 2) + + // Append a corrupted line to the file + const filePath = path.join(tmpDir, "world-ledger.jsonl") + fs.appendFileSync(filePath, '{"broken":true, invalid json\n') + + const ledger2 = new WorldLedger(tmpDir, "test-world", identity) + assert.equal(ledger2.corruptedLines, 1) + assert.equal(ledger2.length, 2) // corrupted line dropped + + const result = ledger2.verify() + assert.equal(result.ok, false) + assert.ok(result.errors.some(e => e.error.includes("corrupted"))) + }) + + it("getAgentSummaries() uses liveAgentIds to determine online status", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const a1 = "aw:sha256:agent1" + const a2 = "aw:sha256:agent2" + + ledger.append("world.join", a1, "Alpha") + ledger.append("world.join", a2, "Beta") + + // Without liveAgentIds — both online from log + const all = ledger.getAgentSummaries() + assert.equal(all.find(s => s.agentId === a1).online, true) + assert.equal(all.find(s => s.agentId === a2).online, true) + + // With liveAgentIds — only a1 is actually online + const live = new Set([a1]) + const filtered = ledger.getAgentSummaries(live) + assert.equal(filtered.find(s => s.agentId === a1).online, true) + assert.equal(filtered.find(s => s.agentId === a2).online, false) + + // After restart — empty live set + const empty = new Set() + const restarted = ledger.getAgentSummaries(empty) + assert.equal(restarted.find(s => s.agentId === a1).online, false) + assert.equal(restarted.find(s => s.agentId === a2).online, false) + }) +}) From fadd0a5af851c6cf1be51e9c8aa43105980a98d3 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Thu, 19 Mar 2026 13:40:10 +0800 Subject: [PATCH 5/8] refactor!: remove legacy body-signature fallback and all v0.2 spec references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dual-mode signature verification in peer-server.ts — header signatures (X-AgentWorld-*) are now required, no body-only fallback - Migrate key rotation verification to domain-separated signatures - Add DOMAIN_SEPARATORS and signWithDomainSeparator/verifyWithDomainSeparator to DAP plugin identity.ts (matching SDK) - Replace all 'v0.2' references with project's own naming — the AgentWire spec version is only a reference, not our protocol version - Agent Card profiles: 'core/v0.2' → 'core' - Update all tests to use header-signed requests Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .changeset/v02-request-signing.md | 4 +- packages/agent-world-sdk/src/card.ts | 12 +-- packages/agent-world-sdk/src/crypto.ts | 12 +-- packages/agent-world-sdk/src/peer-protocol.ts | 4 +- packages/agent-world-sdk/src/types.ts | 2 +- src/identity.ts | 46 ++++++++++- src/peer-server.ts | 80 ++++++++----------- src/types.ts | 4 +- test/key-rotation.test.mjs | 48 ++++++----- test/request-signing.test.mjs | 60 +++++++------- test/response-signing.test.mjs | 2 +- 11 files changed, 154 insertions(+), 120 deletions(-) diff --git a/.changeset/v02-request-signing.md b/.changeset/v02-request-signing.md index 2fe1576..22d5c46 100644 --- a/.changeset/v02-request-signing.md +++ b/.changeset/v02-request-signing.md @@ -2,6 +2,6 @@ "@resciencelab/dap": minor --- -feat: align request signing with AgentWire v0.2 spec +feat: AgentWorld HTTP request signing with X-AgentWorld-* headers -Outbound HTTP requests now include X-AgentWorld-* headers with method/path/authority/Content-Digest binding for cross-endpoint replay resistance. Server verifies v0.2 header signatures when present, falling back to legacy body-only signatures for backward compatibility. +Outbound HTTP requests include X-AgentWorld-* headers with method/path/authority/Content-Digest binding for cross-endpoint replay resistance. Header signatures are required — no legacy body-only fallback. diff --git a/packages/agent-world-sdk/src/card.ts b/packages/agent-world-sdk/src/card.ts index 6f25319..925252d 100644 --- a/packages/agent-world-sdk/src/card.ts +++ b/packages/agent-world-sdk/src/card.ts @@ -1,5 +1,5 @@ /** - * AgentWorld v0.2 Agent Card builder. + * AgentWorld Agent Card builder. * * Builds and JWS-signs a standard A2A-compatible Agent Card with an * `extensions.agentworld` block. The card is served at /.well-known/agent.json. @@ -40,16 +40,16 @@ export interface AgentCardOpts { cardUrl: string; /** A2A JSON-RPC endpoint URL (optional) */ rpcUrl?: string; - /** AgentWorld profiles to declare. Defaults to ["core/v0.2"] */ + /** AgentWorld profiles to declare. Defaults to ["core"] */ profiles?: string[]; /** Conformance node class. Defaults to "CoreNode" */ nodeClass?: string; - /** Capabilities advertised in conformance block. Defaults to standard core/v0.2 set. */ + /** Capabilities advertised in conformance block. */ capabilities?: string[]; } /** - * Build and JWS-sign an AgentWorld v0.2 Agent Card. + * Build and JWS-sign an AgentWorld Agent Card. * * Returns the canonical JSON string that MUST be served verbatim as * `application/json`. The JWS signature covers @@ -61,7 +61,7 @@ export async function buildSignedAgentCard( opts: AgentCardOpts, identity: Identity ): Promise { - const profiles = opts.profiles ?? ["core/v0.2"]; + const profiles = opts.profiles ?? ["core"]; const nodeClass = opts.nodeClass ?? "CoreNode"; const did = deriveDidKey(identity.pubB64); const publicKeyMultibase = toPublicKeyMultibase(identity.pubB64); @@ -97,7 +97,7 @@ export async function buildSignedAgentCard( nodeClass, profiles: profiles.map((id) => ({ id, - required: id === "core/v0.2", + required: id === "core", })), capabilities: opts.capabilities ?? [ "signed-card-jws", diff --git a/packages/agent-world-sdk/src/crypto.ts b/packages/agent-world-sdk/src/crypto.ts index 46de400..cc564fd 100644 --- a/packages/agent-world-sdk/src/crypto.ts +++ b/packages/agent-world-sdk/src/crypto.ts @@ -130,7 +130,7 @@ export function signPayload(payload: unknown, secretKey: Uint8Array): string { return Buffer.from(sig).toString("base64"); } -// ── AgentWorld v0.2 HTTP header signing ─────────────────────────────────────── +// ── AgentWorld HTTP header signing ───────────────────────────────────────────── const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; @@ -173,7 +173,7 @@ function buildRequestSigningInput(opts: { } /** - * Produce AgentWorld v0.2 HTTP request signing headers. + * Produce AgentWorld HTTP request signing headers. * Include alongside Content-Type in outbound fetch calls. */ export function signHttpRequest( @@ -211,7 +211,7 @@ export function signHttpRequest( } /** - * Verify AgentWorld v0.2 HTTP request headers. + * Verify AgentWorld HTTP request headers. * Returns { ok: true } if valid, { ok: false, error } otherwise. */ export function verifyHttpRequestHeaders( @@ -269,7 +269,7 @@ export function verifyHttpRequestHeaders( : { ok: false, error: "Invalid X-AgentWorld-Signature" }; } -// ── AgentWorld v0.2 HTTP response signing ───────────────────────────────────── +// ── AgentWorld HTTP response signing ─────────────────────────────────────────── export interface AwResponseHeaders { "X-AgentWorld-Version": string; @@ -298,7 +298,7 @@ function buildResponseSigningInput(opts: { } /** - * Produce AgentWorld v0.2 HTTP response signing headers. + * Produce AgentWorld HTTP response signing headers. * Add to Fastify reply before sending the body. */ export function signHttpResponse( @@ -332,7 +332,7 @@ export function signHttpResponse( } /** - * Verify AgentWorld v0.2 HTTP response headers from an inbound response. + * Verify AgentWorld HTTP response headers from an inbound response. * Returns { ok: true } if valid, { ok: false, error } otherwise. */ export function verifyHttpResponseHeaders( diff --git a/packages/agent-world-sdk/src/peer-protocol.ts b/packages/agent-world-sdk/src/peer-protocol.ts index 0c4af21..2fd5ebb 100644 --- a/packages/agent-world-sdk/src/peer-protocol.ts +++ b/packages/agent-world-sdk/src/peer-protocol.ts @@ -63,7 +63,7 @@ export function registerPeerRoutes( } ); - // Sign all /peer/* JSON responses (P2a — AgentWorld v0.2 response signing) + // Sign all /peer/* JSON responses fastify.addHook("onSend", async (_req, reply, payload) => { if (typeof payload !== "string") return payload; const url = (_req.url ?? "").split("?")[0]; @@ -230,7 +230,7 @@ export function registerPeerRoutes( } }); - // POST /peer/key-rotation — AgentWorld v0.2 §6.10/§10.4 + // POST /peer/key-rotation fastify.post("/peer/key-rotation", async (req, reply) => { const rot = req.body as unknown as KeyRotationRequest; diff --git a/packages/agent-world-sdk/src/types.ts b/packages/agent-world-sdk/src/types.ts index cbe195b..58fe32c 100644 --- a/packages/agent-world-sdk/src/types.ts +++ b/packages/agent-world-sdk/src/types.ts @@ -186,7 +186,7 @@ export interface LedgerQueryOpts { limit?: number } -// ── Key rotation (AgentWorld v0.2 §6.10/§10.4) ──────────────────────────────── +// ── Key rotation ────────────────────────────────────────────────────────────── export interface KeyRotationIdentity { agentId: string diff --git a/src/identity.ts b/src/identity.ts index 39ae91d..5626bfd 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -129,7 +129,51 @@ export function verifySignature( } } -// ── AgentWorld v0.2 HTTP header signing (§6.6/§6.7) ──────────────────────── +// ── Domain-Separated Signatures ───────────────────────────────────────────── + +export const DOMAIN_SEPARATORS = { + HTTP_REQUEST: `AgentWorld-Req-${PROTOCOL_VERSION}\0`, + HTTP_RESPONSE: `AgentWorld-Res-${PROTOCOL_VERSION}\0`, + AGENT_CARD: `AgentWorld-Card-${PROTOCOL_VERSION}\0`, + KEY_ROTATION: `AgentWorld-Rotation-${PROTOCOL_VERSION}\0`, + ANNOUNCE: `AgentWorld-Announce-${PROTOCOL_VERSION}\0`, + MESSAGE: `AgentWorld-Message-${PROTOCOL_VERSION}\0`, + WORLD_STATE: `AgentWorld-WorldState-${PROTOCOL_VERSION}\0`, +} as const + +export function signWithDomainSeparator( + domainSeparator: string, + payload: unknown, + secretKey: Uint8Array +): string { + const canonicalJson = JSON.stringify(canonicalize(payload)) + const domainPrefix = Buffer.from(domainSeparator, "utf8") + const payloadBytes = Buffer.from(canonicalJson, "utf8") + const message = Buffer.concat([domainPrefix, payloadBytes]) + const sig = nacl.sign.detached(message, secretKey) + return Buffer.from(sig).toString("base64") +} + +export function verifyWithDomainSeparator( + domainSeparator: string, + publicKeyB64: string, + payload: unknown, + signatureB64: string +): boolean { + try { + const canonicalJson = JSON.stringify(canonicalize(payload)) + const domainPrefix = Buffer.from(domainSeparator, "utf8") + const payloadBytes = Buffer.from(canonicalJson, "utf8") + const message = Buffer.concat([domainPrefix, payloadBytes]) + const pubKey = Buffer.from(publicKeyB64, "base64") + const sig = Buffer.from(signatureB64, "base64") + return nacl.sign.detached.verify(message, sig, pubKey) + } catch { + return false + } +} + +// ── AgentWorld HTTP header signing ────────────────────────────────────────── const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000 diff --git a/src/peer-server.ts b/src/peer-server.ts index 093a556..469469f 100644 --- a/src/peer-server.ts +++ b/src/peer-server.ts @@ -10,7 +10,7 @@ */ import Fastify, { FastifyInstance } from "fastify" import { P2PMessage, Identity, Endpoint } from "./types" -import { verifySignature, agentIdFromPublicKey, verifyHttpRequestHeaders, signHttpResponse as signHttpResponseFn } from "./identity" +import { agentIdFromPublicKey, verifyHttpRequestHeaders, signHttpResponse as signHttpResponseFn, DOMAIN_SEPARATORS, verifyWithDomainSeparator } from "./identity" // eslint-disable-next-line @typescript-eslint/no-var-requires const pkgVersion: string = require("../package.json").version const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") @@ -68,7 +68,7 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti server = Fastify({ logger: false }) - // Preserve raw body string for Content-Digest verification (v0.2 §6.6) + // Preserve raw body string for Content-Digest verification server.decorateRequest("rawBody", "") server.addContentTypeParser( "application/json", @@ -83,7 +83,7 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti } ) - // Sign all /peer/* JSON responses (P2a — AgentWorld v0.2 response signing) + // Sign all /peer/* JSON responses server.addHook("onSend", async (_req, reply, payload) => { if (!_identity || typeof payload !== "string") return payload const url = ((_req as any).url ?? "").split("?")[0] as string @@ -106,26 +106,18 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti return reply.code(400).send({ error: "Missing 'from' or 'publicKey'" }) } - // Dual-mode: prefer v0.2 header signature, fall back to body signature - const awSig = req.headers["x-agentworld-signature"] - if (awSig) { - const rawBody = (req as any).rawBody as string - const authority = (req.headers["host"] as string) ?? "localhost" - const reqPath = req.url.split("?")[0] - const result = verifyHttpRequestHeaders( - req.headers as Record, - req.method, reqPath, authority, rawBody, ann.publicKey - ) - if (!result.ok) return reply.code(403).send({ error: result.error }) - const headerFrom = req.headers["x-agentworld-from"] as string - if (headerFrom !== ann.from) { - return reply.code(400).send({ error: "X-AgentWorld-From does not match body 'from'" }) - } - } else { - const { signature, ...signable } = ann - if (!verifySignature(ann.publicKey, signable as Record, signature)) { - return reply.code(403).send({ error: "Invalid announcement signature" }) - } + // Verify X-AgentWorld-* header signature + const rawBody = (req as any).rawBody as string + const authority = (req.headers["host"] as string) ?? "localhost" + const reqPath = req.url.split("?")[0] + const result = verifyHttpRequestHeaders( + req.headers as Record, + req.method, reqPath, authority, rawBody, ann.publicKey + ) + if (!result.ok) return reply.code(403).send({ error: result.error }) + const headerFrom = req.headers["x-agentworld-from"] as string + if (headerFrom !== ann.from) { + return reply.code(400).send({ error: "X-AgentWorld-From does not match body 'from'" }) } const agentId: string = ann.from @@ -173,26 +165,18 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti return reply.code(400).send({ error: "Missing 'from' or 'publicKey'" }) } - // Dual-mode: prefer v0.2 header signature, fall back to body signature - const awSig = req.headers["x-agentworld-signature"] - if (awSig) { - const rawBody = (req as any).rawBody as string - const authority = (req.headers["host"] as string) ?? "localhost" - const reqPath = req.url.split("?")[0] - const result = verifyHttpRequestHeaders( - req.headers as Record, - req.method, reqPath, authority, rawBody, raw.publicKey - ) - if (!result.ok) return reply.code(403).send({ error: result.error }) - const headerFrom = req.headers["x-agentworld-from"] as string - if (headerFrom !== raw.from) { - return reply.code(400).send({ error: "X-AgentWorld-From does not match body 'from'" }) - } - } else { - const sigData = canonical(raw) - if (!verifySignature(raw.publicKey, sigData, raw.signature)) { - return reply.code(403).send({ error: "Invalid Ed25519 signature" }) - } + // Verify X-AgentWorld-* header signature + const rawBody = (req as any).rawBody as string + const authority = (req.headers["host"] as string) ?? "localhost" + const reqPath = req.url.split("?")[0] + const result = verifyHttpRequestHeaders( + req.headers as Record, + req.method, reqPath, authority, rawBody, raw.publicKey + ) + if (!result.ok) return reply.code(403).send({ error: result.error }) + const headerFrom = req.headers["x-agentworld-from"] as string + if (headerFrom !== raw.from) { + return reply.code(400).send({ error: "X-AgentWorld-From does not match body 'from'" }) } const agentId: string = raw.from @@ -237,7 +221,7 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti return { ok: true } }) - // TODO: v0.2 transport-level header signing for /peer/key-rotation is deferred — + // TODO: transport-level header signing for /peer/key-rotation is deferred — // rotation uses its own dual-signature proof structure (signedByOld + signedByNew) server.post("/peer/key-rotation", async (req, reply) => { const rot = req.body as any @@ -278,11 +262,11 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti timestamp, } - if (!verifySignature(oldPublicKeyB64, signable, rot.proofs.signedByOld.signature)) { + if (!verifyWithDomainSeparator(DOMAIN_SEPARATORS.KEY_ROTATION, oldPublicKeyB64, signable, rot.proofs.signedByOld.signature)) { return reply.code(403).send({ error: "Invalid signatureByOldKey" }) } - if (!verifySignature(newPublicKeyB64, signable, rot.proofs.signedByNew.signature)) { + if (!verifyWithDomainSeparator(DOMAIN_SEPARATORS.KEY_ROTATION, newPublicKeyB64, signable, rot.proofs.signedByNew.signature)) { return reply.code(403).send({ error: "Invalid signatureByNewKey" }) } @@ -339,8 +323,8 @@ export function handleUdpMessage(data: Buffer, from: string): boolean { return false } - const sigData = canonical(raw) - if (!verifySignature(raw.publicKey, sigData, raw.signature)) { + const { signature, ...signable } = raw + if (!verifyWithDomainSeparator(DOMAIN_SEPARATORS.MESSAGE, raw.publicKey, signable, signature)) { return false } diff --git a/src/types.ts b/src/types.ts index 69ca4f4..715f7d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,7 +79,7 @@ export interface PluginConfig { tofu_ttl_days?: number } -// ── AgentWorld v0.2 HTTP signing headers (§6.6/§6.7) ────────────────────────── +// ── AgentWorld HTTP signing headers ──────────────────────────────────────────── export interface AwRequestHeaders { "X-AgentWorld-Version": string @@ -99,7 +99,7 @@ export interface AwResponseHeaders { "X-AgentWorld-Signature": string } -// ── Key rotation (AgentWorld v0.2 §6.10/§10.4) ──────────────────────────────── +// ── Key rotation ────────────────────────────────────────────────────────────── export interface KeyRotationIdentity { agentId: string diff --git a/test/key-rotation.test.mjs b/test/key-rotation.test.mjs index 6639c29..698e73a 100644 --- a/test/key-rotation.test.mjs +++ b/test/key-rotation.test.mjs @@ -13,18 +13,29 @@ const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const { startPeerServer, stopPeerServer } = await import("../dist/peer-server.js") const { initDb } = await import("../dist/peer-db.js") -const { signMessage, agentIdFromPublicKey } = await import("../dist/identity.js") +const { agentIdFromPublicKey, signWithDomainSeparator, DOMAIN_SEPARATORS, signHttpRequest, canonicalize } = await import("../dist/identity.js") function makeKeypair() { const kp = nacl.sign.keyPair() const pubB64 = Buffer.from(kp.publicKey).toString("base64") const privB64 = Buffer.from(kp.secretKey.slice(0, 32)).toString("base64") const agentId = agentIdFromPublicKey(pubB64) - return { publicKey: pubB64, privateKey: privB64, agentId } + return { publicKey: pubB64, privateKey: privB64, secretKey: kp.secretKey, agentId } } -function sign(privB64, payload) { - return signMessage(privB64, payload) +async function sendSignedMessage(port, key, payload) { + const body = JSON.stringify(canonicalize(payload)) + const identity = { agentId: key.agentId, privateKey: key.privateKey, publicKey: key.publicKey } + const awHeaders = signHttpRequest(identity, "POST", `[::1]:${port}`, "/peer/message", body) + return fetch(`http://[::1]:${port}/peer/message`, { + method: "POST", + headers: { "Content-Type": "application/json", ...awHeaders }, + body, + }) +} + +function signRotation(secretKey, payload) { + return signWithDomainSeparator(DOMAIN_SEPARATORS.KEY_ROTATION, payload, secretKey) } function pubToMultibase(pubB64) { @@ -51,10 +62,10 @@ function pubToMultibase(pubB64) { return `z${str}` } -function makeProof(kid, privB64, signable) { +function makeProof(kid, secretKey, signable) { const header = JSON.stringify({ alg: "EdDSA", kid }) const protectedB64 = Buffer.from(header).toString("base64url") - return { protected: protectedB64, signature: sign(privB64, signable) } + return { protected: protectedB64, signature: signRotation(secretKey, signable) } } function makeRotationBody(oldKey, newKey, overrideProofOld) { @@ -73,8 +84,8 @@ function makeRotationBody(oldKey, newKey, overrideProofOld) { newIdentity: { agentId: newKey.agentId, kid: "#identity", publicKeyMultibase: pubToMultibase(newKey.publicKey) }, timestamp: signable.timestamp, proofs: { - signedByOld: makeProof("#identity", overrideProofOld ?? oldKey.privateKey, signable), - signedByNew: makeProof("#identity", newKey.privateKey, signable), + signedByOld: makeProof("#identity", overrideProofOld ?? oldKey.secretKey, signable), + signedByNew: makeProof("#identity", newKey.secretKey, signable), }, } } @@ -95,7 +106,7 @@ describe("key rotation endpoint", () => { fs.rmSync(tmpDir, { recursive: true }) }) - test("accepts valid v0.2 key rotation", async () => { + test("accepts valid key rotation", async () => { const oldKey = makeKeypair() const newKey = makeKeypair() const resp = await fetch(`http://[::1]:${port}/peer/key-rotation`, { @@ -115,7 +126,7 @@ describe("key rotation endpoint", () => { const resp = await fetch(`http://[::1]:${port}/peer/key-rotation`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(makeRotationBody(oldKey, newKey, wrongKey.privateKey)), + body: JSON.stringify(makeRotationBody(oldKey, newKey, wrongKey.secretKey)), }) assert.equal(resp.status, 403) }) @@ -139,8 +150,8 @@ describe("key rotation endpoint", () => { newIdentity: { agentId: newKey.agentId, kid: "#identity", publicKeyMultibase: pubToMultibase(newKey.publicKey) }, timestamp: signable.timestamp, proofs: { - signedByOld: makeProof("#identity", oldKey.privateKey, signable), - signedByNew: makeProof("#identity", newKey.privateKey, signable), + signedByOld: makeProof("#identity", oldKey.secretKey, signable), + signedByNew: makeProof("#identity", newKey.secretKey, signable), }, } const resp = await fetch(`http://[::1]:${port}/peer/key-rotation`, { @@ -174,19 +185,16 @@ describe("key rotation endpoint", () => { const attackerKey = makeKeypair() const newKey = makeKeypair() - // Establish TOFU for tofuKey by sending a message + // Establish TOFU for tofuKey by sending a -signed message const msgPayload = { from: tofuKey.agentId, publicKey: tofuKey.publicKey, event: "ping", content: "hello", timestamp: Date.now(), + signature: signWithDomainSeparator(DOMAIN_SEPARATORS.MESSAGE, { from: tofuKey.agentId, publicKey: tofuKey.publicKey, event: "ping", content: "hello", timestamp: Date.now() }, tofuKey.secretKey), } - await fetch(`http://[::1]:${port}/peer/message`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...msgPayload, signature: sign(tofuKey.privateKey, msgPayload) }), - }) + await sendSignedMessage(port, tofuKey, msgPayload) // Attacker claims tofuKey.agentId but provides attackerKey as oldPublicKey. // agentIdFromPublicKey(attackerKey) !== tofuKey.agentId → server rejects 400. @@ -209,8 +217,8 @@ describe("key rotation endpoint", () => { newIdentity: { agentId: newKey.agentId, kid: "#identity", publicKeyMultibase: pubToMultibase(newKey.publicKey) }, timestamp: signable.timestamp, proofs: { - signedByOld: makeProof("#identity", attackerKey.privateKey, signable), - signedByNew: makeProof("#identity", newKey.privateKey, signable), + signedByOld: makeProof("#identity", attackerKey.secretKey, signable), + signedByNew: makeProof("#identity", newKey.secretKey, signable), }, } const resp = await fetch(`http://[::1]:${port}/peer/key-rotation`, { diff --git a/test/request-signing.test.mjs b/test/request-signing.test.mjs index 9196a92..4a432a5 100644 --- a/test/request-signing.test.mjs +++ b/test/request-signing.test.mjs @@ -1,10 +1,10 @@ /** - * AgentWorld v0.2 request signing — round-trip tests + * AgentWorld request signing — round-trip tests * * Verifies that: * 1. sendP2PMessage includes X-AgentWorld-* headers - * 2. Server verifies v0.2 header signatures correctly - * 3. Server still accepts legacy body-only signed messages + * 2. Server verifies header signatures correctly + * 3. Server rejects legacy body-only signed messages (header signatures required) * 4. Content-Digest mismatch is rejected * 5. Timestamp skew is rejected via headers */ @@ -45,9 +45,19 @@ function makeIdentity() { return { publicKey: pubB64, privateKey: privB64, agentId } } +function sendSignedMsg(port, identity, payload) { + const body = JSON.stringify(canonicalize(payload)) + const awHeaders = signHttpRequest(identity, "POST", `[::1]:${port}`, "/peer/message", body) + return fetch(`http://[::1]:${port}/peer/message`, { + method: "POST", + headers: { "Content-Type": "application/json", ...awHeaders }, + body, + }) +} + let selfKey, senderKey, dataDir -describe("v0.2 request signing", () => { +describe("request signing", () => { before(async () => { selfKey = makeIdentity() senderKey = makeIdentity() @@ -148,14 +158,14 @@ describe("v0.2 request signing", () => { assert.match(result.error, /skew window/) }) - test("sendP2PMessage delivers with v0.2 headers (server accepts)", async () => { + test("sendP2PMessage delivers with headers (server accepts)", async () => { const result = await sendP2PMessage( - senderKey, "::1", "chat", "hello via v0.2", PORT, 5000 + senderKey, "::1", "chat", "hello via ", PORT, 5000 ) assert.ok(result.ok, `Send failed: ${result.error}`) }) - test("server accepts legacy body-only signed message (no v0.2 headers)", async () => { + test("server rejects legacy body-only signed message (no headers)", async () => { const timestamp = Date.now() const payload = { from: senderKey.agentId, @@ -172,12 +182,10 @@ describe("v0.2 request signing", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify(msg), }) - assert.equal(resp.status, 200) - const body = await resp.json() - assert.ok(body.ok) + assert.equal(resp.status, 403) }) - test("server rejects v0.2 request with tampered body", async () => { + test("server rejects request with tampered body", async () => { const original = JSON.stringify({ from: senderKey.agentId, publicKey: senderKey.publicKey, @@ -207,7 +215,7 @@ describe("v0.2 request signing", () => { assert.match(body.error, /Content-Digest mismatch/) }) - test("server rejects v0.2 request signed with wrong key", async () => { + test("server rejects request signed with wrong key", async () => { const otherKey = makeIdentity() const msgBody = JSON.stringify({ from: senderKey.agentId, @@ -227,7 +235,7 @@ describe("v0.2 request signing", () => { assert.equal(resp.status, 403) }) - test("announce with v0.2 headers is accepted", async () => { + test("announce with headers is accepted", async () => { const timestamp = Date.now() const payload = { from: senderKey.agentId, @@ -253,23 +261,18 @@ describe("v0.2 request signing", () => { assert.ok(result.ok || result.peers) }) - test("response includes v0.2 signing headers", async () => { + test("response includes signing headers", async () => { const timestamp = Date.now() - const payload = { + const msg = { from: senderKey.agentId, publicKey: senderKey.publicKey, event: "chat", content: "check response headers", timestamp, + signature: "placeholder", } - const signature = signMessage(senderKey.privateKey, payload) - const msg = { ...payload, signature } - const resp = await fetch(`http://[::1]:${PORT}/peer/message`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(msg), - }) + const resp = await sendSignedMsg(PORT, senderKey, msg) assert.equal(resp.status, 200) assert.ok(resp.headers.get("x-agentworld-signature")) assert.ok(resp.headers.get("x-agentworld-from")) @@ -292,21 +295,16 @@ describe("v0.2 request signing", () => { test("verifyHttpResponseHeaders validates server response", async () => { const timestamp = Date.now() - const payload = { + const msg = { from: senderKey.agentId, publicKey: senderKey.publicKey, event: "chat", content: "verify response", timestamp, + signature: "placeholder", } - const signature = signMessage(senderKey.privateKey, payload) - const msg = { ...payload, signature } - const resp = await fetch(`http://[::1]:${PORT}/peer/message`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(msg), - }) + const resp = await sendSignedMsg(PORT, senderKey, msg) assert.equal(resp.status, 200) const body = await resp.text() const respHeaders = {} @@ -315,7 +313,7 @@ describe("v0.2 request signing", () => { assert.ok(result.ok, `Response header verification failed: ${result.error}`) }) - test("server rejects v0.2 request with mismatched from header vs body", async () => { + test("server rejects request with mismatched from header vs body", async () => { const otherKey = makeIdentity() const msgBody = JSON.stringify({ from: senderKey.agentId, diff --git a/test/response-signing.test.mjs b/test/response-signing.test.mjs index bc36f07..3c5f7d8 100644 --- a/test/response-signing.test.mjs +++ b/test/response-signing.test.mjs @@ -1,5 +1,5 @@ /** - * P2a — AgentWorld v0.2 response signing + * P2a — AgentWorld response signing * * Verifies that /peer/* endpoints include X-AgentWorld-Signature, * X-AgentWorld-From, Content-Digest and other required headers, and that From dd12b1ec16fb12fda4a942210ec487d3955d8cf8 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Thu, 19 Mar 2026 16:42:53 +0800 Subject: [PATCH 6/8] fix: align domain-separated signing across SDK, plugin, and UDP transport - [P1] Plugin signHttpRequest/verifyHttpRequestHeaders now use DOMAIN_SEPARATORS.HTTP_REQUEST (matching SDK) - [P1] Plugin signHttpResponse/verifyHttpResponseHeaders now use DOMAIN_SEPARATORS.HTTP_RESPONSE (matching SDK) - [P1] peer-client buildSignedMessage uses DOMAIN_SEPARATORS.MESSAGE so QUIC/UDP datagrams pass server verification - [P2] World ledger filename includes worldId (world-ledger-.jsonl) preventing data collision across multiple worlds Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/agent-world-sdk/src/world-ledger.ts | 3 ++- src/identity.ts | 18 ++++++------------ src/peer-client.ts | 6 ++++-- test/response-signing.test.mjs | 6 ++++-- test/world-ledger.test.mjs | 4 ++-- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/agent-world-sdk/src/world-ledger.ts b/packages/agent-world-sdk/src/world-ledger.ts index 3be61bc..4c6d5e4 100644 --- a/packages/agent-world-sdk/src/world-ledger.ts +++ b/packages/agent-world-sdk/src/world-ledger.ts @@ -27,7 +27,8 @@ export class WorldLedger { public corruptedLines = 0 constructor(dataDir: string, worldId: string, identity: Identity) { - this.filePath = path.join(dataDir, "world-ledger.jsonl") + const safeId = worldId.replace(/[^a-zA-Z0-9_-]/g, "_") + this.filePath = path.join(dataDir, `world-ledger-${safeId}.jsonl`) this.identity = identity this.worldId = worldId this.load() diff --git a/src/identity.ts b/src/identity.ts index 5626bfd..2271fe5 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -226,17 +226,14 @@ export function signHttpRequest( const signingInput = buildRequestSigningInput({ v: PROTOCOL_VERSION, from: identity.agentId, kid, ts, method, authority, path: reqPath, contentDigest, }) - const sig = nacl.sign.detached( - Buffer.from(JSON.stringify(canonicalize(signingInput))), - privFull.secretKey - ) + const signature = signWithDomainSeparator(DOMAIN_SEPARATORS.HTTP_REQUEST, signingInput, privFull.secretKey) return { "X-AgentWorld-Version": PROTOCOL_VERSION, "X-AgentWorld-From": identity.agentId, "X-AgentWorld-KeyId": kid, "X-AgentWorld-Timestamp": ts, "Content-Digest": contentDigest, - "X-AgentWorld-Signature": Buffer.from(sig).toString("base64"), + "X-AgentWorld-Signature": signature, } } @@ -275,7 +272,7 @@ export function verifyHttpRequestHeaders( const signingInput = buildRequestSigningInput({ v: ver, from, kid, ts, method, authority, path: reqPath, contentDigest: cd, }) - const ok = verifySignature(publicKeyB64, signingInput, sig) + const ok = verifyWithDomainSeparator(DOMAIN_SEPARATORS.HTTP_REQUEST, publicKeyB64, signingInput, sig) return ok ? { ok: true } : { ok: false, error: "Invalid X-AgentWorld-Signature" } } @@ -291,17 +288,14 @@ export function signHttpResponse( const signingInput = buildResponseSigningInput({ v: PROTOCOL_VERSION, from: identity.agentId, kid, ts, status, contentDigest, }) - const sig = nacl.sign.detached( - Buffer.from(JSON.stringify(canonicalize(signingInput))), - privFull.secretKey - ) + const signature = signWithDomainSeparator(DOMAIN_SEPARATORS.HTTP_RESPONSE, signingInput, privFull.secretKey) return { "X-AgentWorld-Version": PROTOCOL_VERSION, "X-AgentWorld-From": identity.agentId, "X-AgentWorld-KeyId": kid, "X-AgentWorld-Timestamp": ts, "Content-Digest": contentDigest, - "X-AgentWorld-Signature": Buffer.from(sig).toString("base64"), + "X-AgentWorld-Signature": signature, } } @@ -336,7 +330,7 @@ export function verifyHttpResponseHeaders( } const signingInput = buildResponseSigningInput({ v: ver, from, kid, ts, status, contentDigest: cd }) - const ok = verifySignature(publicKeyB64, signingInput, sig) + const ok = verifyWithDomainSeparator(DOMAIN_SEPARATORS.HTTP_RESPONSE, publicKeyB64, signingInput, sig) return ok ? { ok: true } : { ok: false, error: "Invalid X-AgentWorld-Signature" } } diff --git a/src/peer-client.ts b/src/peer-client.ts index e418627..2e2f9a7 100644 --- a/src/peer-client.ts +++ b/src/peer-client.ts @@ -5,8 +5,9 @@ * 1. QUIC/UDP transport (if available) * 2. HTTP over TCP (direct fallback) */ +import * as nacl from "tweetnacl" import { P2PMessage, Identity, Endpoint } from "./types" -import { signMessage, signHttpRequest } from "./identity" +import { signWithDomainSeparator, DOMAIN_SEPARATORS, signHttpRequest } from "./identity" import { Transport } from "./transport" function buildSignedMessage(identity: Identity, event: string, content: string): P2PMessage { @@ -18,7 +19,8 @@ function buildSignedMessage(identity: Identity, event: string, content: string): content, timestamp, } - const signature = signMessage(identity.privateKey, payload as Record) + const privFull = nacl.sign.keyPair.fromSeed(Buffer.from(identity.privateKey, "base64")) + const signature = signWithDomainSeparator(DOMAIN_SEPARATORS.MESSAGE, payload, privFull.secretKey) return { ...payload, signature } } diff --git a/test/response-signing.test.mjs b/test/response-signing.test.mjs index 3c5f7d8..e250792 100644 --- a/test/response-signing.test.mjs +++ b/test/response-signing.test.mjs @@ -21,7 +21,7 @@ const nacl = (await import("tweetnacl")).default const { startPeerServer, stopPeerServer } = await import("../dist/peer-server.js") const { initDb } = await import("../dist/peer-db.js") -const { agentIdFromPublicKey, signMessage } = await import("../dist/identity.js") +const { agentIdFromPublicKey, DOMAIN_SEPARATORS } = await import("../dist/identity.js") const PORT = 18110 @@ -63,7 +63,9 @@ function verifyResponseSig(headers, status, body, publicKeyB64) { const signingInput = canonicalize({ v: PROTOCOL_VERSION, from, kid, ts, status, contentDigest: cd }) const pubBytes = Buffer.from(publicKeyB64, "base64") const sigBytes = Buffer.from(sig, "base64") - const msg = Buffer.from(JSON.stringify(signingInput)) + const prefix = Buffer.from(DOMAIN_SEPARATORS.HTTP_RESPONSE) + const payload = Buffer.from(JSON.stringify(signingInput)) + const msg = Buffer.concat([prefix, payload]) const valid = nacl.sign.detached.verify(msg, sigBytes, pubBytes) return { ok: valid } } diff --git a/test/world-ledger.test.mjs b/test/world-ledger.test.mjs index fe15d21..52f761a 100644 --- a/test/world-ledger.test.mjs +++ b/test/world-ledger.test.mjs @@ -86,7 +86,7 @@ describe("WorldLedger", () => { ledger1.append("world.join", "aw:sha256:a1", "Bot1") // Tamper with the file: change the alias in the second line - const filePath = path.join(tmpDir, "world-ledger.jsonl") + const filePath = path.join(tmpDir, "world-ledger-test-world.jsonl") const lines = fs.readFileSync(filePath, "utf8").trim().split("\n") const entry = JSON.parse(lines[1]) entry.alias = "TAMPERED" @@ -215,7 +215,7 @@ describe("WorldLedger", () => { assert.equal(ledger1.length, 2) // Append a corrupted line to the file - const filePath = path.join(tmpDir, "world-ledger.jsonl") + const filePath = path.join(tmpDir, "world-ledger-test-world.jsonl") fs.appendFileSync(filePath, '{"broken":true, invalid json\n') const ledger2 = new WorldLedger(tmpDir, "test-world", identity) From 21e86527ca370a9cbe28296f034c9bac84d80b67 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Thu, 19 Mar 2026 16:52:07 +0800 Subject: [PATCH 7/8] fix: collision-resistant ledger filenames via SHA-256 hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use SHA-256(worldId) truncated to 16 hex chars for ledger filename, preventing collisions between IDs that differ only in special chars - No legacy migration — breaking change is acceptable during development - Add test for filename collision resistance Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/agent-world-sdk/src/world-ledger.ts | 4 ++-- test/world-ledger.test.mjs | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/agent-world-sdk/src/world-ledger.ts b/packages/agent-world-sdk/src/world-ledger.ts index 4c6d5e4..9677f30 100644 --- a/packages/agent-world-sdk/src/world-ledger.ts +++ b/packages/agent-world-sdk/src/world-ledger.ts @@ -27,8 +27,8 @@ export class WorldLedger { public corruptedLines = 0 constructor(dataDir: string, worldId: string, identity: Identity) { - const safeId = worldId.replace(/[^a-zA-Z0-9_-]/g, "_") - this.filePath = path.join(dataDir, `world-ledger-${safeId}.jsonl`) + const hash = crypto.createHash("sha256").update(worldId).digest("hex").slice(0, 16) + this.filePath = path.join(dataDir, `world-ledger-${hash}.jsonl`) this.identity = identity this.worldId = worldId this.load() diff --git a/test/world-ledger.test.mjs b/test/world-ledger.test.mjs index 52f761a..ff7544b 100644 --- a/test/world-ledger.test.mjs +++ b/test/world-ledger.test.mjs @@ -1,5 +1,6 @@ import { describe, it, beforeEach, afterEach } from "node:test" import assert from "node:assert/strict" +import crypto from "node:crypto" import fs from "fs" import path from "path" import os from "os" @@ -86,7 +87,8 @@ describe("WorldLedger", () => { ledger1.append("world.join", "aw:sha256:a1", "Bot1") // Tamper with the file: change the alias in the second line - const filePath = path.join(tmpDir, "world-ledger-test-world.jsonl") + const hash = crypto.createHash("sha256").update("test-world").digest("hex").slice(0, 16) + const filePath = path.join(tmpDir, `world-ledger-${hash}.jsonl`) const lines = fs.readFileSync(filePath, "utf8").trim().split("\n") const entry = JSON.parse(lines[1]) entry.alias = "TAMPERED" @@ -215,7 +217,8 @@ describe("WorldLedger", () => { assert.equal(ledger1.length, 2) // Append a corrupted line to the file - const filePath = path.join(tmpDir, "world-ledger-test-world.jsonl") + const hash = crypto.createHash("sha256").update("test-world").digest("hex").slice(0, 16) + const filePath = path.join(tmpDir, `world-ledger-${hash}.jsonl`) fs.appendFileSync(filePath, '{"broken":true, invalid json\n') const ledger2 = new WorldLedger(tmpDir, "test-world", identity) @@ -252,4 +255,14 @@ describe("WorldLedger", () => { assert.equal(restarted.find(s => s.agentId === a1).online, false) assert.equal(restarted.find(s => s.agentId === a2).online, false) }) + + it("uses collision-resistant filenames for different worldIds", () => { + const l1 = new WorldLedger(tmpDir, "foo/bar", identity) + const l2 = new WorldLedger(tmpDir, "foo:bar", identity) + l1.append("world.join", "aw:sha256:a1", "Alpha") + + // l2 should have only its own genesis — not l1's join event + assert.equal(l2.length, 1) + assert.equal(l2.getEntries()[0].event, "world.genesis") + }) }) From 0a68af09e5d4145cf85052a0b3899480d4d7d366 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Thu, 19 Mar 2026 16:56:36 +0800 Subject: [PATCH 8/8] chore: update changeset to cover full PR scope Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .changeset/v02-request-signing.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.changeset/v02-request-signing.md b/.changeset/v02-request-signing.md index 22d5c46..727092e 100644 --- a/.changeset/v02-request-signing.md +++ b/.changeset/v02-request-signing.md @@ -1,7 +1,13 @@ --- "@resciencelab/dap": minor +"@resciencelab/agent-world-sdk": minor --- -feat: AgentWorld HTTP request signing with X-AgentWorld-* headers +feat: domain-separated signing, header-only auth, world ledger -Outbound HTTP requests include X-AgentWorld-* headers with method/path/authority/Content-Digest binding for cross-endpoint replay resistance. Header signatures are required — no legacy body-only fallback. +- DAP plugin HTTP signing/verification aligned with SDK domain separators (HTTP_REQUEST, HTTP_RESPONSE) +- QUIC/UDP buildSignedMessage uses DOMAIN_SEPARATORS.MESSAGE (matching server verification) +- Key rotation uses DOMAIN_SEPARATORS.KEY_ROTATION +- Header signatures (X-AgentWorld-*) required on announce/message — no legacy body-only fallback +- Blockchain-inspired World Ledger: append-only event log with SHA-256 hash chain, Ed25519-signed entries, JSON Lines persistence, /world/ledger + /world/agents HTTP endpoints +- Collision-resistant ledger filenames via SHA-256(worldId)