Skip to content
Merged
75 changes: 75 additions & 0 deletions .changeset/domain-separated-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
"@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)
- **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

### 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

## 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
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.
10 changes: 8 additions & 2 deletions .changeset/v02-request-signing.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
---
"@resciencelab/dap": minor
"@resciencelab/agent-world-sdk": minor
---

feat: align request signing with AgentWire v0.2 spec
feat: domain-separated signing, header-only auth, world ledger

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.
- 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)
4 changes: 2 additions & 2 deletions packages/agent-world-sdk/package-lock.json

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

130 changes: 89 additions & 41 deletions packages/agent-world-sdk/src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,73 @@
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<BootstrapNode[]> {
export async function fetchBootstrapNodes(
url = DEFAULT_BOOTSTRAP_URL
): Promise<BootstrapNode[]> {
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(
addr: string,
httpPort: number,
opts: AnnounceOpts
): Promise<void> {
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<string, unknown> = {
from: identity.agentId,
Expand All @@ -50,29 +77,48 @@ 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, {
alias: peer.alias,
endpoints: peer.endpoints,
capabilities: peer.capabilities,
lastSeen: peer.lastSeen,
})
});
}
}
} catch {
Expand All @@ -81,25 +127,27 @@ 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;
}

/**
* Announce to all bootstrap nodes once, then schedule repeating discovery.
* 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);
}
Loading