From 634703b76a2847ede275cfd28a889238c543f5d3 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 18 Mar 2026 18:16:40 +0800 Subject: [PATCH 1/5] feat: align request signing with AgentWire v0.2 spec - Add v0.2 HTTP header signing to outbound requests (peer-client, peer-discovery) with method/path/authority/Content-Digest binding for cross-endpoint replay resistance - Add dual-mode verification on server: prefer v0.2 header signature, fall back to legacy body-only signature for backward compatibility - Add rawBody parser to preserve original body for Content-Digest verification - Refactor response signing to use shared signHttpResponse from identity module - Add AwRequestHeaders/AwResponseHeaders type interfaces - Add 12 new tests covering round-trip signing, tamper detection, replay resistance, timestamp skew rejection, and legacy backward compatibility --- package-lock.json | 4 +- src/identity.ts | 171 +++++++++++++++++++- src/peer-client.ts | 23 +-- src/peer-discovery.ts | 13 +- src/peer-server.ts | 122 +++++++------- src/types.ts | 20 +++ test/request-signing.test.mjs | 279 +++++++++++++++++++++++++++++++++ test/response-signing.test.mjs | 4 +- 8 files changed, 555 insertions(+), 81 deletions(-) create mode 100644 test/request-signing.test.mjs diff --git a/package-lock.json b/package-lock.json index fa852ba..945fa61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@resciencelab/dap", - "version": "0.3.2", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@resciencelab/dap", - "version": "0.3.2", + "version": "0.4.3", "license": "MIT", "dependencies": { "@noble/hashes": "^1.3.3", diff --git a/src/identity.ts b/src/identity.ts index 43f5e48..bc07161 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -6,10 +6,14 @@ */ import * as nacl from "tweetnacl" import { sha256 } from "@noble/hashes/sha256" +import { createHash } from "node:crypto" import * as fs from "fs" import * as path from "path" import * as os from "os" -import { Identity } from "./types" +import { Identity, AwRequestHeaders, AwResponseHeaders } from "./types" + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { version: PROTOCOL_VERSION } = require("../package.json") // ── did:key mapping ───────────────────────────────────────────────────────── @@ -121,6 +125,171 @@ export function verifySignature( } } +// ── AgentWorld v0.2 HTTP header signing (§6.6/§6.7) ──────────────────────── + +const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000 + +export function computeContentDigest(body: string): string { + const hash = createHash("sha256").update(Buffer.from(body, "utf8")).digest("base64") + return `sha-256=:${hash}:` +} + +function buildRequestSigningInput(opts: { + from: string; kid: string; ts: string + method: string; authority: string; path: string; contentDigest: string +}): Record { + return { + v: PROTOCOL_VERSION, + from: opts.from, + kid: opts.kid, + ts: opts.ts, + method: opts.method.toUpperCase(), + authority: opts.authority, + path: opts.path, + contentDigest: opts.contentDigest, + } +} + +function buildResponseSigningInput(opts: { + from: string; kid: string; ts: string + status: number; contentDigest: string +}): Record { + return { + v: PROTOCOL_VERSION, + from: opts.from, + kid: opts.kid, + ts: opts.ts, + status: opts.status, + contentDigest: opts.contentDigest, + } +} + +export function signHttpRequest( + identity: Identity, + method: string, + authority: string, + reqPath: string, + body: string +): AwRequestHeaders { + const privFull = nacl.sign.keyPair.fromSeed(Buffer.from(identity.privateKey, "base64")) + const ts = new Date().toISOString() + const kid = "#identity" + const contentDigest = computeContentDigest(body) + const signingInput = buildRequestSigningInput({ + from: identity.agentId, kid, ts, method, authority, path: reqPath, contentDigest, + }) + const sig = nacl.sign.detached( + Buffer.from(JSON.stringify(canonicalize(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"), + } +} + +export function verifyHttpRequestHeaders( + headers: Record, + method: string, + reqPath: string, + authority: string, + body: string, + publicKeyB64: string +): { ok: boolean; error?: string } { + 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 + + if (!sig || !from || !kid || !ts || !cd) { + return { ok: false, error: "Missing required AgentWorld headers" } + } + + 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" } + } + + const expectedDigest = computeContentDigest(body) + if (cd !== expectedDigest) { + return { ok: false, error: "Content-Digest mismatch" } + } + + const signingInput = buildRequestSigningInput({ + from, kid, ts, method, authority, path: reqPath, contentDigest: cd, + }) + const ok = verifySignature(publicKeyB64, signingInput, sig) + return ok ? { ok: true } : { ok: false, error: "Invalid X-AgentWorld-Signature" } +} + +export function signHttpResponse( + identity: Identity, + status: number, + body: string +): AwResponseHeaders { + const privFull = nacl.sign.keyPair.fromSeed(Buffer.from(identity.privateKey, "base64")) + 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))), + 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"), + } +} + +export function verifyHttpResponseHeaders( + headers: Record, + status: number, + body: string, + publicKeyB64: string +): { ok: boolean; error?: string } { + 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"] + + if (!sig || !from || !kid || !ts || !cd) { + return { ok: false, error: "Missing required AgentWorld response headers" } + } + + 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" } + } + + const expectedDigest = computeContentDigest(body) + if (cd !== expectedDigest) { + 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" } +} + // ── Utility ───────────────────────────────────────────────────────────────── /** diff --git a/src/peer-client.ts b/src/peer-client.ts index 32cd8b1..e418627 100644 --- a/src/peer-client.ts +++ b/src/peer-client.ts @@ -6,7 +6,7 @@ * 2. HTTP over TCP (direct fallback) */ import { P2PMessage, Identity, Endpoint } from "./types" -import { signMessage } from "./identity" +import { signMessage, signHttpRequest } from "./identity" import { Transport } from "./transport" function buildSignedMessage(identity: Identity, event: string, content: string): P2PMessage { @@ -24,27 +24,30 @@ function buildSignedMessage(identity: Identity, event: string, content: string): async function sendViaHttp( msg: P2PMessage, + identity: Identity, targetAddr: string, port: number, timeoutMs: number, + urlPath: string = "/peer/message", ): Promise<{ ok: boolean; error?: string }> { const isIpv6 = targetAddr.includes(":") && !targetAddr.includes(".") - const url = isIpv6 - ? `http://[${targetAddr}]:${port}/peer/message` - : `http://${targetAddr}:${port}/peer/message` + const host = isIpv6 ? `[${targetAddr}]:${port}` : `${targetAddr}:${port}` + const url = `http://${host}${urlPath}` + const body = JSON.stringify(msg) + const awHeaders = signHttpRequest(identity, "POST", host, urlPath, body) try { const ctrl = new AbortController() const timer = setTimeout(() => ctrl.abort(), timeoutMs) const resp = await fetch(url, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(msg), + headers: { "Content-Type": "application/json", ...awHeaders }, + body, signal: ctrl.signal, }) clearTimeout(timer) if (!resp.ok) { - const body = await resp.text().catch(() => "") - return { ok: false, error: `HTTP ${resp.status}: ${body}` } + const text = await resp.text().catch(() => "") + return { ok: false, error: `HTTP ${resp.status}: ${text}` } } return { ok: true } } catch (err: any) { @@ -104,11 +107,11 @@ export async function sendP2PMessage( .filter((e) => e.transport === "tcp") .sort((a, b) => a.priority - b.priority)[0] if (httpEp) { - return sendViaHttp(msg, httpEp.address, httpEp.port || port, timeoutMs) + return sendViaHttp(msg, identity, httpEp.address, httpEp.port || port, timeoutMs) } } - return sendViaHttp(msg, targetAddr, port, timeoutMs) + return sendViaHttp(msg, identity, targetAddr, port, timeoutMs) } export async function broadcastLeave( diff --git a/src/peer-discovery.ts b/src/peer-discovery.ts index 391fb10..dbdaabf 100644 --- a/src/peer-discovery.ts +++ b/src/peer-discovery.ts @@ -10,7 +10,7 @@ */ import { Identity, Endpoint } from "./types" -import { signMessage, agentIdFromPublicKey } from "./identity" +import { signMessage, agentIdFromPublicKey, signHttpRequest } from "./identity" import { listPeers, upsertDiscoveredPeer, getPeersForExchange, pruneStale } from "./peer-db" const BOOTSTRAP_JSON_URL = @@ -121,17 +121,18 @@ export async function announceToNode( const announcement = { ...payload, signature } const isIpv6 = targetAddr.includes(":") && !targetAddr.includes(".") - const url = isIpv6 - ? `http://[${targetAddr}]:${port}/peer/announce` - : `http://${targetAddr}:${port}/peer/announce` + const host = isIpv6 ? `[${targetAddr}]:${port}` : `${targetAddr}:${port}` + const url = `http://${host}/peer/announce` + const reqBody = JSON.stringify(announcement) + const awHeaders = signHttpRequest(identity, "POST", host, "/peer/announce", reqBody) try { const ctrl = new AbortController() const timer = setTimeout(() => ctrl.abort(), EXCHANGE_TIMEOUT_MS) const resp = await fetch(url, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(announcement), + headers: { "Content-Type": "application/json", ...awHeaders }, + body: reqBody, signal: ctrl.signal, }) clearTimeout(timer) diff --git a/src/peer-server.ts b/src/peer-server.ts index 3890a1b..ee817fd 100644 --- a/src/peer-server.ts +++ b/src/peer-server.ts @@ -9,10 +9,8 @@ * application layer via Ed25519 signatures, not at the network layer. */ import Fastify, { FastifyInstance } from "fastify" -import { createHash } from "node:crypto" -import * as nacl from "tweetnacl" -import { P2PMessage, Endpoint } from "./types" -import { verifySignature, agentIdFromPublicKey, canonicalize } from "./identity" +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") import { tofuVerifyAndCache, tofuReplaceKey, getPeersForExchange, upsertDiscoveredPeer, removePeer, getPeer } from "./peer-db" @@ -25,8 +23,7 @@ let server: FastifyInstance | null = null const _inbox: (P2PMessage & { verified: boolean; receivedAt: number })[] = [] const _handlers: MessageHandler[] = [] -// Identity for response signing (set at startup) -let _signingKey: { agentId: string; secretKey: Uint8Array } | null = null +let _identity: Identity | null = null interface SelfMeta { agentId?: string @@ -41,7 +38,7 @@ export interface PeerServerOptions { /** If true, disables startup delays for tests */ testMode?: boolean /** Identity for response signing (optional) */ - identity?: { agentId: string; publicKey: string; privateKey: string } + identity?: Identity } export function setSelfMeta(meta: SelfMeta): void { @@ -63,58 +60,37 @@ function canonical(msg: P2PMessage): Record { } } -function computeContentDigest(body: string): string { - const hash = createHash("sha256").update(Buffer.from(body, "utf8")).digest("base64") - return `sha-256=:${hash}:` -} - -function signResponse(status: number, bodyStr: string): Record | null { - if (!_signingKey) return null - const ts = new Date().toISOString() - const kid = "#identity" - const contentDigest = computeContentDigest(bodyStr) - const signingInput = canonicalize({ - v: PROTOCOL_VERSION, - from: _signingKey.agentId, - kid, - ts, - status, - contentDigest, - }) - const sig = nacl.sign.detached( - Buffer.from(JSON.stringify(signingInput)), - _signingKey.secretKey - ) - return { - "X-AgentWorld-Version": PROTOCOL_VERSION, - "X-AgentWorld-From": _signingKey.agentId, - "X-AgentWorld-KeyId": kid, - "X-AgentWorld-Timestamp": ts, - "Content-Digest": contentDigest, - "X-AgentWorld-Signature": Buffer.from(sig).toString("base64"), - } -} - export async function startPeerServer(port: number = 8099, opts?: PeerServerOptions): Promise { if (opts?.identity) { - const privBytes = Buffer.from(opts.identity.privateKey, "base64") - const fullKey = nacl.sign.keyPair.fromSeed(privBytes) - _signingKey = { agentId: opts.identity.agentId, secretKey: fullKey.secretKey } + _identity = opts.identity } server = Fastify({ logger: false }) + // Preserve raw body string for Content-Digest verification (v0.2 §6.6) + server.decorateRequest("rawBody", "") + server.addContentTypeParser( + "application/json", + { parseAs: "string" }, + (req, body, done) => { + try { + ;(req as any).rawBody = body as string + done(null, JSON.parse(body as string)) + } catch (err) { + done(err as Error, undefined) + } + } + ) + // Sign all /peer/* JSON responses (P2a — AgentWorld v0.2 response signing) server.addHook("onSend", async (_req, reply, payload) => { - if (!_signingKey || typeof payload !== "string") return payload + if (!_identity || typeof payload !== "string") return payload const url = ((_req as any).url ?? "").split("?")[0] as string 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 = signResponse(reply.statusCode, payload) - if (hdrs) { - for (const [k, v] of Object.entries(hdrs)) reply.header(k, v) - } + const hdrs = signHttpResponseFn(_identity, reply.statusCode, payload) + for (const [k, v] of Object.entries(hdrs)) reply.header(k, v) return payload }) @@ -125,16 +101,29 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti server.post("/peer/announce", async (req, reply) => { const ann = req.body as any - const { signature, ...signable } = ann - if (!verifySignature(ann.publicKey, signable as Record, signature)) { - return reply.code(403).send({ error: "Invalid announcement signature" }) + if (!ann?.publicKey || !ann?.from) { + return reply.code(400).send({ error: "Missing 'from' or 'publicKey'" }) } - const agentId: string = ann.from - if (!agentId) { - return reply.code(400).send({ error: "Missing 'from' (agentId)" }) + // 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 result = verifyHttpRequestHeaders( + req.headers as Record, + req.method, req.url, authority, rawBody, ann.publicKey + ) + if (!result.ok) return reply.code(403).send({ error: result.error }) + } else { + const { signature, ...signable } = ann + if (!verifySignature(ann.publicKey, signable as Record, signature)) { + return reply.code(403).send({ error: "Invalid announcement signature" }) + } } + const agentId: string = ann.from + const knownPeer = getPeer(agentId) if (!knownPeer?.publicKey && agentIdFromPublicKey(ann.publicKey) !== agentId) { return reply.code(400).send({ error: "agentId does not match publicKey" }) @@ -174,16 +163,29 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti server.post("/peer/message", async (req, reply) => { const raw = req.body as any - const sigData = canonical(raw) - if (!verifySignature(raw.publicKey, sigData, raw.signature)) { - return reply.code(403).send({ error: "Invalid Ed25519 signature" }) + if (!raw?.publicKey || !raw?.from) { + return reply.code(400).send({ error: "Missing 'from' or 'publicKey'" }) } - const agentId: string = raw.from - if (!agentId) { - return reply.code(400).send({ error: "Missing 'from' (agentId)" }) + // 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 result = verifyHttpRequestHeaders( + req.headers as Record, + req.method, req.url, authority, rawBody, raw.publicKey + ) + if (!result.ok) return reply.code(403).send({ error: result.error }) + } else { + const sigData = canonical(raw) + if (!verifySignature(raw.publicKey, sigData, raw.signature)) { + return reply.code(403).send({ error: "Invalid Ed25519 signature" }) + } } + const agentId: string = raw.from + const knownPeer = getPeer(agentId) if (!knownPeer?.publicKey && agentIdFromPublicKey(raw.publicKey) !== agentId) { return reply.code(400).send({ error: "agentId does not match publicKey" }) @@ -292,7 +294,7 @@ export async function stopPeerServer(): Promise { await server.close() server = null } - _signingKey = null + _identity = null } export function getInbox(): typeof _inbox { diff --git a/src/types.ts b/src/types.ts index 4378098..69ca4f4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,6 +79,26 @@ export interface PluginConfig { tofu_ttl_days?: number } +// ── AgentWorld v0.2 HTTP signing headers (§6.6/§6.7) ────────────────────────── + +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 +} + +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 +} + // ── Key rotation (AgentWorld v0.2 §6.10/§10.4) ──────────────────────────────── export interface KeyRotationIdentity { diff --git a/test/request-signing.test.mjs b/test/request-signing.test.mjs new file mode 100644 index 0000000..469c256 --- /dev/null +++ b/test/request-signing.test.mjs @@ -0,0 +1,279 @@ +/** + * AgentWorld v0.2 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 + * 4. Content-Digest mismatch is rejected + * 5. Timestamp skew is rejected via headers + */ +import { test, describe, before, after } from "node:test" +import assert from "node:assert/strict" +import * as os from "node:os" +import * as fs from "node:fs" +import * as path from "node:path" +import crypto from "node:crypto" + +import { createRequire } from "node:module" +const require = createRequire(import.meta.url) +const { version: PROTOCOL_VERSION } = require("../package.json") + +const nacl = (await import("tweetnacl")).default + +const { startPeerServer, stopPeerServer } = await import("../dist/peer-server.js") +const { initDb, flushDb } = await import("../dist/peer-db.js") +const { + agentIdFromPublicKey, + signMessage, + canonicalize, + signHttpRequest, + verifyHttpRequestHeaders, + computeContentDigest, +} = await import("../dist/identity.js") +const { sendP2PMessage } = await import("../dist/peer-client.js") + +const PORT = 18115 + +function makeIdentity() { + 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 } +} + +let selfKey, senderKey, dataDir + +describe("v0.2 request signing", () => { + before(async () => { + selfKey = makeIdentity() + senderKey = makeIdentity() + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "dap-reqsign-")) + initDb(dataDir) + await startPeerServer(PORT, { identity: selfKey, testMode: true }) + }) + + after(async () => { + await stopPeerServer() + flushDb() + fs.rmSync(dataDir, { recursive: true, force: true }) + }) + + test("signHttpRequest produces all 6 required headers", () => { + const body = JSON.stringify({ test: true }) + const headers = signHttpRequest(senderKey, "POST", "localhost:8099", "/peer/message", body) + assert.ok(headers["X-AgentWorld-Version"]) + assert.ok(headers["X-AgentWorld-From"]) + assert.ok(headers["X-AgentWorld-KeyId"]) + assert.ok(headers["X-AgentWorld-Timestamp"]) + assert.ok(headers["Content-Digest"]) + assert.ok(headers["X-AgentWorld-Signature"]) + assert.equal(headers["X-AgentWorld-Version"], PROTOCOL_VERSION) + assert.equal(headers["X-AgentWorld-From"], senderKey.agentId) + assert.equal(headers["X-AgentWorld-KeyId"], "#identity") + }) + + test("signHttpRequest + verifyHttpRequestHeaders round-trip", () => { + const body = JSON.stringify({ from: senderKey.agentId, content: "hello" }) + const headers = signHttpRequest(senderKey, "POST", "example.com:8099", "/peer/message", body) + const result = verifyHttpRequestHeaders( + headers, "POST", "/peer/message", "example.com:8099", body, senderKey.publicKey + ) + assert.ok(result.ok, `Verification failed: ${result.error}`) + }) + + test("verifyHttpRequestHeaders rejects tampered body", () => { + const body = JSON.stringify({ from: senderKey.agentId, content: "hello" }) + const headers = signHttpRequest(senderKey, "POST", "example.com:8099", "/peer/message", body) + const tampered = JSON.stringify({ from: senderKey.agentId, content: "tampered" }) + const result = verifyHttpRequestHeaders( + headers, "POST", "/peer/message", "example.com:8099", tampered, senderKey.publicKey + ) + assert.equal(result.ok, false) + assert.match(result.error, /Content-Digest mismatch/) + }) + + test("verifyHttpRequestHeaders rejects wrong public key", () => { + const body = JSON.stringify({ from: senderKey.agentId, content: "hello" }) + const headers = signHttpRequest(senderKey, "POST", "example.com:8099", "/peer/message", body) + const otherKey = makeIdentity() + const result = verifyHttpRequestHeaders( + headers, "POST", "/peer/message", "example.com:8099", body, otherKey.publicKey + ) + assert.equal(result.ok, false) + assert.match(result.error, /Invalid X-AgentWorld-Signature/) + }) + + test("verifyHttpRequestHeaders rejects wrong path (replay to different endpoint)", () => { + const body = JSON.stringify({ from: senderKey.agentId, content: "hello" }) + const headers = signHttpRequest(senderKey, "POST", "example.com:8099", "/peer/message", body) + const result = verifyHttpRequestHeaders( + headers, "POST", "/peer/announce", "example.com:8099", body, senderKey.publicKey + ) + assert.equal(result.ok, false) + assert.match(result.error, /Invalid X-AgentWorld-Signature/) + }) + + test("verifyHttpRequestHeaders rejects expired timestamp", () => { + const body = JSON.stringify({ test: true }) + const contentDigest = computeContentDigest(body) + const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() + const signingInput = canonicalize({ + v: PROTOCOL_VERSION, + from: senderKey.agentId, + kid: "#identity", + ts, + method: "POST", + authority: "example.com:8099", + path: "/peer/message", + contentDigest, + }) + const kp = nacl.sign.keyPair.fromSeed(Buffer.from(senderKey.privateKey, "base64")) + const sig = nacl.sign.detached(Buffer.from(JSON.stringify(signingInput)), kp.secretKey) + const headers = { + "X-AgentWorld-Version": PROTOCOL_VERSION, + "X-AgentWorld-From": senderKey.agentId, + "X-AgentWorld-KeyId": "#identity", + "X-AgentWorld-Timestamp": ts, + "Content-Digest": contentDigest, + "X-AgentWorld-Signature": Buffer.from(sig).toString("base64"), + } + const result = verifyHttpRequestHeaders( + headers, "POST", "/peer/message", "example.com:8099", body, senderKey.publicKey + ) + assert.equal(result.ok, false) + assert.match(result.error, /skew window/) + }) + + test("sendP2PMessage delivers with v0.2 headers (server accepts)", async () => { + const result = await sendP2PMessage( + senderKey, "::1", "chat", "hello via v0.2", PORT, 5000 + ) + assert.ok(result.ok, `Send failed: ${result.error}`) + }) + + test("server accepts legacy body-only signed message (no v0.2 headers)", async () => { + const timestamp = Date.now() + const payload = { + from: senderKey.agentId, + publicKey: senderKey.publicKey, + event: "chat", + content: "legacy message", + timestamp, + } + 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), + }) + assert.equal(resp.status, 200) + const body = await resp.json() + assert.ok(body.ok) + }) + + test("server rejects v0.2 request with tampered body", async () => { + const original = JSON.stringify({ + from: senderKey.agentId, + publicKey: senderKey.publicKey, + event: "chat", + content: "original", + timestamp: Date.now(), + signature: "unused", + }) + const awHeaders = signHttpRequest(senderKey, "POST", `[::1]:${PORT}`, "/peer/message", original) + + const tampered = JSON.stringify({ + from: senderKey.agentId, + publicKey: senderKey.publicKey, + event: "chat", + content: "tampered!", + timestamp: Date.now(), + signature: "unused", + }) + + const resp = await fetch(`http://[::1]:${PORT}/peer/message`, { + method: "POST", + headers: { "Content-Type": "application/json", ...awHeaders }, + body: tampered, + }) + assert.equal(resp.status, 403) + const body = await resp.json() + assert.match(body.error, /Content-Digest mismatch/) + }) + + test("server rejects v0.2 request signed with wrong key", async () => { + const otherKey = makeIdentity() + const msgBody = JSON.stringify({ + from: senderKey.agentId, + publicKey: senderKey.publicKey, + event: "chat", + content: "wrong signer", + timestamp: Date.now(), + signature: "unused", + }) + const awHeaders = signHttpRequest(otherKey, "POST", `[::1]:${PORT}`, "/peer/message", msgBody) + + const resp = await fetch(`http://[::1]:${PORT}/peer/message`, { + method: "POST", + headers: { "Content-Type": "application/json", ...awHeaders }, + body: msgBody, + }) + assert.equal(resp.status, 403) + }) + + test("announce with v0.2 headers is accepted", async () => { + const timestamp = Date.now() + const payload = { + from: senderKey.agentId, + publicKey: senderKey.publicKey, + alias: "test-node", + endpoints: [], + capabilities: [], + timestamp, + peers: [], + } + const signature = signMessage(senderKey.privateKey, payload) + const announcement = { ...payload, signature } + const body = JSON.stringify(announcement) + const awHeaders = signHttpRequest(senderKey, "POST", `[::1]:${PORT}`, "/peer/announce", body) + + const resp = await fetch(`http://[::1]:${PORT}/peer/announce`, { + method: "POST", + headers: { "Content-Type": "application/json", ...awHeaders }, + body, + }) + assert.equal(resp.status, 200) + const result = await resp.json() + assert.ok(result.ok || result.peers) + }) + + test("response includes v0.2 signing headers", async () => { + const timestamp = Date.now() + const payload = { + from: senderKey.agentId, + publicKey: senderKey.publicKey, + event: "chat", + content: "check response headers", + timestamp, + } + 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), + }) + assert.equal(resp.status, 200) + assert.ok(resp.headers.get("x-agentworld-signature")) + assert.ok(resp.headers.get("x-agentworld-from")) + assert.ok(resp.headers.get("x-agentworld-version")) + assert.ok(resp.headers.get("x-agentworld-keyid")) + assert.ok(resp.headers.get("x-agentworld-timestamp")) + assert.ok(resp.headers.get("content-digest")) + }) +}) diff --git a/test/response-signing.test.mjs b/test/response-signing.test.mjs index 76aadbf..56d819e 100644 --- a/test/response-signing.test.mjs +++ b/test/response-signing.test.mjs @@ -113,8 +113,8 @@ describe("P2a — response signing on /peer/* endpoints", () => { body: JSON.stringify({ bad: "payload" }), }) const body = await resp.text() - assert.equal(resp.status, 403) - const result = verifyResponseSig(resp.headers, 403, body, selfKey.publicKey) + assert.equal(resp.status, 400) + const result = verifyResponseSig(resp.headers, 400, body, selfKey.publicKey) assert.ok(result.ok, `Error response signature invalid: ${JSON.stringify(result)}`) }) }) From f99e28dfa8384c345dba2fcccaca43003640bd80 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 18 Mar 2026 18:27:12 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20version=20binding,=20query-string=20path,=20from=20?= =?UTF-8?q?cross-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C1: Strip query string from req.url before v0.2 signature verification - C2: Use X-AgentWorld-Version from header in verification signing input instead of hardcoded local PROTOCOL_VERSION (enables rolling upgrades) - W2: Cross-check X-AgentWorld-From header matches body 'from' field - W3: Add TODO for key-rotation dual-mode verification (deferred) - N3: Add empty body Content-Digest test - N4: Add verifyHttpResponseHeaders round-trip test - Add from-mismatch rejection test --- src/identity.ts | 22 +++++++------ src/peer-server.ts | 16 ++++++++-- test/request-signing.test.mjs | 60 +++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/src/identity.ts b/src/identity.ts index bc07161..dd55d0e 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -135,11 +135,11 @@ export function computeContentDigest(body: string): string { } function buildRequestSigningInput(opts: { - from: string; kid: string; ts: string + v: string; from: string; kid: string; ts: string method: string; authority: string; path: string; contentDigest: string }): Record { return { - v: PROTOCOL_VERSION, + v: opts.v, from: opts.from, kid: opts.kid, ts: opts.ts, @@ -151,11 +151,11 @@ function buildRequestSigningInput(opts: { } function buildResponseSigningInput(opts: { - from: string; kid: string; ts: string + v: string; from: string; kid: string; ts: string status: number; contentDigest: string }): Record { return { - v: PROTOCOL_VERSION, + v: opts.v, from: opts.from, kid: opts.kid, ts: opts.ts, @@ -176,7 +176,7 @@ export function signHttpRequest( const kid = "#identity" const contentDigest = computeContentDigest(body) const signingInput = buildRequestSigningInput({ - from: identity.agentId, kid, ts, method, authority, path: reqPath, contentDigest, + v: PROTOCOL_VERSION, from: identity.agentId, kid, ts, method, authority, path: reqPath, contentDigest, }) const sig = nacl.sign.detached( Buffer.from(JSON.stringify(canonicalize(signingInput))), @@ -203,13 +203,14 @@ export function verifyHttpRequestHeaders( const h: Record = {} for (const [k, v] of Object.entries(headers)) h[k.toLowerCase()] = v + const ver = h["x-agentworld-version"] 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) { + if (!ver || !sig || !from || !kid || !ts || !cd) { return { ok: false, error: "Missing required AgentWorld headers" } } @@ -224,7 +225,7 @@ export function verifyHttpRequestHeaders( } const signingInput = buildRequestSigningInput({ - from, kid, ts, method, authority, path: reqPath, contentDigest: cd, + v: ver, from, kid, ts, method, authority, path: reqPath, contentDigest: cd, }) const ok = verifySignature(publicKeyB64, signingInput, sig) return ok ? { ok: true } : { ok: false, error: "Invalid X-AgentWorld-Signature" } @@ -240,7 +241,7 @@ export function signHttpResponse( const kid = "#identity" const contentDigest = computeContentDigest(body) const signingInput = buildResponseSigningInput({ - from: identity.agentId, kid, ts, status, contentDigest, + v: PROTOCOL_VERSION, from: identity.agentId, kid, ts, status, contentDigest, }) const sig = nacl.sign.detached( Buffer.from(JSON.stringify(canonicalize(signingInput))), @@ -265,13 +266,14 @@ export function verifyHttpResponseHeaders( const h: Record = {} for (const [k, v] of Object.entries(headers)) h[k.toLowerCase()] = v + const ver = h["x-agentworld-version"] 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) { + if (!ver || !sig || !from || !kid || !ts || !cd) { return { ok: false, error: "Missing required AgentWorld response headers" } } @@ -285,7 +287,7 @@ export function verifyHttpResponseHeaders( return { ok: false, error: "Content-Digest mismatch" } } - const signingInput = buildResponseSigningInput({ from, kid, ts, status, contentDigest: cd }) + const signingInput = buildResponseSigningInput({ v: ver, from, kid, ts, status, contentDigest: cd }) const ok = verifySignature(publicKeyB64, signingInput, sig) return ok ? { ok: true } : { ok: false, error: "Invalid X-AgentWorld-Signature" } } diff --git a/src/peer-server.ts b/src/peer-server.ts index ee817fd..08f6561 100644 --- a/src/peer-server.ts +++ b/src/peer-server.ts @@ -110,11 +110,16 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti 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, req.url, authority, rawBody, ann.publicKey + 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)) { @@ -172,11 +177,16 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti 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, req.url, authority, rawBody, raw.publicKey + 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)) { @@ -226,6 +236,8 @@ 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 — + // rotation uses its own dual-signature proof structure (signedByOld + signedByNew) server.post("/peer/key-rotation", async (req, reply) => { const rot = req.body as any diff --git a/test/request-signing.test.mjs b/test/request-signing.test.mjs index 469c256..f4278a8 100644 --- a/test/request-signing.test.mjs +++ b/test/request-signing.test.mjs @@ -29,6 +29,7 @@ const { canonicalize, signHttpRequest, verifyHttpRequestHeaders, + verifyHttpResponseHeaders, computeContentDigest, } = await import("../dist/identity.js") const { sendP2PMessage } = await import("../dist/peer-client.js") @@ -276,4 +277,63 @@ describe("v0.2 request signing", () => { assert.ok(resp.headers.get("x-agentworld-timestamp")) assert.ok(resp.headers.get("content-digest")) }) + + test("computeContentDigest handles empty body", () => { + const digest = computeContentDigest("") + assert.ok(digest.startsWith("sha-256=:")) + assert.ok(digest.endsWith(":")) + const inner = digest.slice("sha-256=:".length, -1) + assert.ok(inner.length > 0, "digest should not be empty") + // SHA-256 of empty string is well-known + const expected = crypto.createHash("sha256").update("").digest("base64") + assert.equal(inner, expected) + }) + + test("verifyHttpResponseHeaders validates server response", async () => { + const timestamp = Date.now() + const payload = { + from: senderKey.agentId, + publicKey: senderKey.publicKey, + event: "chat", + content: "verify response", + timestamp, + } + 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), + }) + assert.equal(resp.status, 200) + const body = await resp.text() + const respHeaders = {} + for (const [k, v] of resp.headers.entries()) respHeaders[k] = v + const result = verifyHttpResponseHeaders(respHeaders, 200, body, selfKey.publicKey) + assert.ok(result.ok, `Response header verification failed: ${result.error}`) + }) + + test("server rejects v0.2 request with mismatched from header vs body", async () => { + const otherKey = makeIdentity() + const msgBody = JSON.stringify({ + from: senderKey.agentId, + publicKey: senderKey.publicKey, + event: "chat", + content: "mismatched from", + timestamp: Date.now(), + signature: "unused", + }) + // Sign with senderKey but the body says from=senderKey while header will say from=otherKey + const awHeaders = signHttpRequest(otherKey, "POST", `[::1]:${PORT}`, "/peer/message", msgBody) + + const resp = await fetch(`http://[::1]:${PORT}/peer/message`, { + method: "POST", + headers: { "Content-Type": "application/json", ...awHeaders }, + body: msgBody, + }) + // Should fail because header signature was signed with otherKey's publicKey + // but body says publicKey=senderKey.publicKey, and verification uses body's publicKey + assert.ok(resp.status === 403 || resp.status === 400) + }) }) From 83940cf29f3ad3282e75bf328b90afef5fdfdefe Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 18 Mar 2026 18:36:06 +0800 Subject: [PATCH 3/5] fix: restore default port 8099 for p2p_send_message tool Reverts the peerPort default introduced in 209b7da. Remote peers almost always listen on the network default 8099; using the local peerPort caused connection refusals when the gateway ran on a non-standard port. --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 9be7c8c..c3e2b2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -435,7 +435,7 @@ export default function register(api: any) { return { content: [{ type: "text", text: "Error: P2P service not started yet." }] } } const event = params.event ?? "chat" - const result = await sendP2PMessage(identity, params.agent_id, event, params.message, params.port ?? peerPort, 10_000, buildSendOpts(params.agent_id)) + const result = await sendP2PMessage(identity, params.agent_id, event, params.message, params.port ?? 8099, 10_000, buildSendOpts(params.agent_id)) if (result.ok) { return { content: [{ type: "text", text: `Message delivered to ${params.agent_id} (event: ${event})` }] } } From 882b00d64d4cee345b2ce4285d693f04636a48df Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 18 Mar 2026 18:48:32 +0800 Subject: [PATCH 4/5] fix(sdk): inject host block when resolved manifest type is hosted buildManifest() previously only injected hostAgentId/hostCardUrl/ hostEndpoints when config.worldType === 'hosted'. If the manifest was marked hosted via hooks (manifest.type = 'hosted') while worldType was left at its default, joiners received a hosted manifest with no host connection details. Now checks result.type instead. --- packages/agent-world-sdk/src/world-server.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/agent-world-sdk/src/world-server.ts b/packages/agent-world-sdk/src/world-server.ts index 47026b0..16ac022 100644 --- a/packages/agent-world-sdk/src/world-server.ts +++ b/packages/agent-world-sdk/src/world-server.ts @@ -56,8 +56,7 @@ export async function createWorldServer( theme: manifest?.theme ?? worldTheme, } - if (worldType === "hosted" && hostAgentId) { - result.type = "hosted" + if (result.type === "hosted" && hostAgentId) { result.host = { agentId: hostAgentId, cardUrl: hostCardUrl, From 2fc64254aca8e09f2bef4db60bbd498900bc319a Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 18 Mar 2026 18:49:22 +0800 Subject: [PATCH 5/5] chore: add changeset for v0.2 request signing --- .changeset/v02-request-signing.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/v02-request-signing.md diff --git a/.changeset/v02-request-signing.md b/.changeset/v02-request-signing.md new file mode 100644 index 0000000..2fe1576 --- /dev/null +++ b/.changeset/v02-request-signing.md @@ -0,0 +1,7 @@ +--- +"@resciencelab/dap": minor +--- + +feat: align request signing with AgentWire v0.2 spec + +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.