Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/v02-request-signing.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions packages/agent-world-sdk/src/world-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
173 changes: 172 additions & 1 deletion src/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -121,6 +125,173 @@ 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: {
v: string; from: string; kid: string; ts: string
method: string; authority: string; path: string; contentDigest: string
}): Record<string, string> {
return {
v: opts.v,
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: {
v: string; from: string; kid: string; ts: string
status: number; contentDigest: string
}): Record<string, unknown> {
return {
v: opts.v,
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({
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
)
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<string, string | string[] | undefined>,
method: string,
reqPath: string,
authority: string,
body: string,
publicKeyB64: string
): { ok: boolean; error?: string } {
const h: Record<string, string | string[] | undefined> = {}
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 (!ver || !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({
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" }
}

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({
v: PROTOCOL_VERSION, 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<string, string | null>,
status: number,
body: string,
publicKeyB64: string
): { ok: boolean; error?: string } {
const h: Record<string, string | null> = {}
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 (!ver || !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({ 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" }
}

// ── Utility ─────────────────────────────────────────────────────────────────

/**
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})` }] }
}
Expand Down
23 changes: 13 additions & 10 deletions src/peer-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 7 additions & 6 deletions src/peer-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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)
Expand Down
Loading