diff --git a/apps/cli/src/AGENTS.md b/apps/cli/src/AGENTS.md index ec3d3d6..3071789 100644 --- a/apps/cli/src/AGENTS.md +++ b/apps/cli/src/AGENTS.md @@ -54,3 +54,4 @@ - For OpenClaw setup flow, cover self-setup behavior, config patch idempotency, and missing-file validation. - For registry invite flow, cover admin-auth create path, public redeem path, config persistence failures, and command exit-code behavior. - Keep tests deterministic by mocking network and filesystem dependencies. +- File-copy/integration-style skill installer tests must set explicit per-test timeouts (for example `15_000ms`) to avoid false failures under loaded CI/pre-push environments. diff --git a/apps/cli/src/commands/AGENTS.md b/apps/cli/src/commands/AGENTS.md index 31ba0da..f8088f1 100644 --- a/apps/cli/src/commands/AGENTS.md +++ b/apps/cli/src/commands/AGENTS.md @@ -113,12 +113,15 @@ ## Pair Command Rules - `pair start ` must call proxy `/pair/start` with `Authorization: Claw ` and signed PoP headers from local agent `secret.key`. - `pair start` must rely on local Claw agent auth + PoP headers only; ownership is validated server-side via proxy-to-registry internal service auth. +- `pair start` must load/create a local X25519 identity (`agents//e2ee-identity.json`) and include `initiatorE2ee.{keyId,x25519PublicKey}` in the request. - `pair start --qr` must generate a one-time local PNG QR containing the returned ticket and print the filesystem path. - `pair start --qr` must sweep expired QR artifacts in `~/.clawdentity/pairing` before writing a new file. - `pair confirm ` must call proxy `/pair/confirm` with `Authorization: Claw ` and signed PoP headers from local agent `secret.key`. - `pair confirm` must accept either `--qr-file ` (primary) or `--ticket ` (fallback), never both. +- `pair confirm` must include local `responderE2ee.{keyId,x25519PublicKey}` in the confirm request. - `pair confirm --qr-file` must delete the consumed QR file after successful confirm (best effort, non-fatal on cleanup failure). - `pair status --ticket ` must poll `/pair/status` and persist peers locally when status transitions to `confirmed`. +- Pair persistence must store peer E2EE bundle under `peers..e2ee` so connector runtime can encrypt outbound traffic without additional bootstrap. - After peer persistence, pair flows must best-effort sync OpenClaw transform peer snapshot (`hooks/transforms/clawdentity-peers.json`) when `~/.clawdentity/openclaw-relay.json` provides `relayTransformPeersPath`, so relay delivery works without manual file copying. - `pair start --wait` should use `/pair/status` polling and auto-save the responder peer locally so reverse pairing is not required. - `pair` commands must resolve proxy URL automatically from CLI config/registry metadata, with `CLAWDENTITY_PROXY_URL` env override support. diff --git a/apps/cli/src/commands/openclaw.ts b/apps/cli/src/commands/openclaw.ts index 53402d8..a78d932 100644 --- a/apps/cli/src/commands/openclaw.ts +++ b/apps/cli/src/commands/openclaw.ts @@ -196,12 +196,18 @@ type PeerEntry = { proxyUrl: string; agentName?: string; humanName?: string; + e2ee?: PeerE2eeBundle; }; type PeersConfig = { peers: Record; }; +type PeerE2eeBundle = { + keyId: string; + x25519PublicKey: string; +}; + export type OpenclawInviteResult = { code: string; did: string; @@ -366,6 +372,42 @@ function parseOptionalProfileName( return parseNonEmptyString(value, label); } +function parsePeerE2eeBundle( + value: unknown, + alias: string, +): PeerE2eeBundle | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + throw createCliError( + "CLI_OPENCLAW_INVALID_PEERS_CONFIG", + "Peer e2ee config must be an object", + { alias }, + ); + } + + const keyId = parseNonEmptyString(value.keyId, `Peer ${alias} e2ee.keyId`); + const x25519PublicKey = parseNonEmptyString( + value.x25519PublicKey, + `Peer ${alias} e2ee.x25519PublicKey`, + ); + + try { + if (decodeBase64url(x25519PublicKey).length !== 32) { + throw new Error("invalid key length"); + } + } catch { + throw createCliError( + "CLI_OPENCLAW_INVALID_PEERS_CONFIG", + "Peer e2ee x25519PublicKey is invalid", + { alias }, + ); + } + + return { keyId, x25519PublicKey }; +} + function parsePeerAlias(value: unknown): string { const alias = parseNonEmptyString(value, "peer alias"); if (alias.length > 128) { @@ -996,13 +1038,18 @@ async function loadPeersConfig(peersPath: string): Promise { const proxyUrl = parseProxyUrl(value.proxyUrl); const agentName = parseOptionalProfileName(value.agentName, "agentName"); const humanName = parseOptionalProfileName(value.humanName, "humanName"); + const e2ee = parsePeerE2eeBundle(value.e2ee, normalizedAlias); - if (agentName === undefined && humanName === undefined) { + if ( + agentName === undefined && + humanName === undefined && + e2ee === undefined + ) { peers[normalizedAlias] = { did, proxyUrl }; continue; } - peers[normalizedAlias] = { did, proxyUrl, agentName, humanName }; + peers[normalizedAlias] = { did, proxyUrl, agentName, humanName, e2ee }; } return { peers }; diff --git a/apps/cli/src/commands/pair.test.ts b/apps/cli/src/commands/pair.test.ts index 042bb37..bea901a 100644 --- a/apps/cli/src/commands/pair.test.ts +++ b/apps/cli/src/commands/pair.test.ts @@ -33,6 +33,16 @@ const RESPONDER_PROFILE = { humanName: "Ira", }; +const INITIATOR_E2EE = { + keyId: "01HF7YAT31JZHSMW1CG6Q6MHB7", + x25519PublicKey: Buffer.alloc(32, 7).toString("base64url"), +}; + +const RESPONDER_E2EE = { + keyId: "01HF7YAT31JZHSMW1CG6Q6MHB8", + x25519PublicKey: Buffer.alloc(32, 8).toString("base64url"), +}; + const createPairFixture = async (): Promise => { const keypair = await generateEd25519Keypair(); const encoded = encodeEd25519KeypairBase64url(keypair); @@ -118,6 +128,7 @@ describe("pair command helpers", () => { { initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, ticket: "clwpair1_eyJ2IjoxfQ", expiresAt: "2026-02-18T00:00:00.000Z", }, @@ -164,8 +175,8 @@ describe("pair command helpers", () => { expect(unlinkImpl).toHaveBeenCalledWith( "/tmp/.clawdentity/pairing/alpha-pair-1699999000.png", ); - expect(writeFileImpl).toHaveBeenCalledTimes(1); - expect(mkdirImpl).toHaveBeenCalledTimes(1); + expect(writeFileImpl).toHaveBeenCalledTimes(2); + expect(mkdirImpl).toHaveBeenCalledTimes(2); const [, init] = fetchImpl.mock.calls[1] as [string, RequestInit]; expect(init?.method).toBe("POST"); const headers = new Headers(init?.headers); @@ -191,6 +202,7 @@ describe("pair command helpers", () => { { initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, ticket: "clwpair1_eyJ2IjoxfQ", expiresAt: "2026-02-18T00:00:00.000Z", }, @@ -234,6 +246,7 @@ describe("pair command helpers", () => { { initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, ticket: "clwpair1_eyJ2IjoxfQ", expiresAt: "2026-02-18T00:00:00.000Z", }, @@ -280,6 +293,7 @@ describe("pair command helpers", () => { { initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, ticket: "clwpair1_eyJ2IjoxfQ", expiresAt: "2026-02-18T00:00:00.000Z", }, @@ -373,6 +387,7 @@ describe("pair command helpers", () => { status: "pending", initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, expiresAt: "2026-02-18T00:00:00.000Z", }, { status: 200 }, @@ -426,8 +441,10 @@ describe("pair command helpers", () => { paired: true, initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, responderAgentDid: "did:claw:agent:01HBBB22222222222222222222", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, }, { status: 201 }, ); @@ -477,8 +494,8 @@ describe("pair command helpers", () => { expect(String(init?.body ?? "")).toContain("responderProfile"); expect(unlinkImpl).toHaveBeenCalledTimes(1); expect(unlinkImpl).toHaveBeenCalledWith("/tmp/pair.png"); - expect(writeFileImpl).toHaveBeenCalledTimes(1); - expect(chmodImpl).toHaveBeenCalledTimes(1); + expect(writeFileImpl).toHaveBeenCalledTimes(2); + expect(chmodImpl).toHaveBeenCalledTimes(2); }); it("syncs OpenClaw relay peers snapshot after pair confirm", async () => { @@ -533,8 +550,10 @@ describe("pair command helpers", () => { paired: true, initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, responderAgentDid: "did:claw:agent:01HBBB22222222222222222222", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, }, { status: 201 }, ); @@ -577,8 +596,8 @@ describe("pair command helpers", () => { expect.any(String), "utf8", ); - expect(mkdirImpl).toHaveBeenCalledTimes(2); - expect(chmodImpl).toHaveBeenCalledTimes(2); + expect(mkdirImpl).toHaveBeenCalledTimes(3); + expect(chmodImpl).toHaveBeenCalledTimes(3); }); it("checks pending pair status without persisting peers", async () => { @@ -605,6 +624,7 @@ describe("pair command helpers", () => { status: "pending", initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, expiresAt: "2026-02-18T00:00:00.000Z", }, { status: 200 }, @@ -658,14 +678,17 @@ describe("pair command helpers", () => { status: "pending", initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, expiresAt: "2026-02-18T00:00:00.000Z", }, { status: "confirmed", initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, responderAgentDid: "did:claw:agent:01HBBB22222222222222222222", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, expiresAt: "2026-02-18T00:00:00.000Z", confirmedAt: "2026-02-18T00:00:05.000Z", }, @@ -809,6 +832,7 @@ describe("pair command output", () => { { initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, ticket: "clwpair1_eyJ2IjoxfQ", expiresAt: "2026-02-18T00:00:00.000Z", }, @@ -865,8 +889,10 @@ describe("pair command output", () => { paired: true, initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, responderAgentDid: "did:claw:agent:01HBBB22222222222222222222", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, }, { status: 201 }, ); @@ -928,6 +954,7 @@ describe("pair command output", () => { status: "pending", initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, expiresAt: "2026-02-18T00:00:00.000Z", }, { status: 200 }, diff --git a/apps/cli/src/commands/pair.ts b/apps/cli/src/commands/pair.ts index 56c7ffe..995e5d7 100644 --- a/apps/cli/src/commands/pair.ts +++ b/apps/cli/src/commands/pair.ts @@ -8,8 +8,14 @@ import { writeFile, } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { decodeBase64url, parseDid } from "@clawdentity/protocol"; -import { AppError, createLogger, signHttpRequest } from "@clawdentity/sdk"; +import { decodeBase64url, generateUlid, parseDid } from "@clawdentity/protocol"; +import { + AppError, + createLogger, + encodeX25519KeypairBase64url, + generateX25519Keypair, + signHttpRequest, +} from "@clawdentity/sdk"; import { Command } from "commander"; import jsQR from "jsqr"; import { PNG } from "pngjs"; @@ -32,6 +38,7 @@ const SECRET_KEY_FILE_NAME = "secret.key"; const PAIRING_QR_DIR_NAME = "pairing"; const PEERS_FILE_NAME = "peers.json"; const OPENCLAW_RELAY_RUNTIME_FILE_NAME = "openclaw-relay.json"; +const E2EE_IDENTITY_FILE_NAME = "e2ee-identity.json"; const PAIR_START_PATH = "/pair/start"; const PAIR_CONFIRM_PATH = "/pair/confirm"; @@ -89,6 +96,7 @@ type PairCommandDependencies = PairRequestOptions; type PairStartResult = { initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; ticket: string; expiresAt: string; proxyUrl: string; @@ -99,8 +107,10 @@ type PairConfirmResult = { paired: boolean; initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; responderAgentDid: string; responderProfile: PeerProfile; + responderE2ee: PeerE2eeBundle; proxyUrl: string; peerAlias?: string; }; @@ -109,14 +119,22 @@ type PairStatusResult = { status: "pending" | "confirmed"; initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; responderAgentDid?: string; responderProfile?: PeerProfile; + responderE2ee?: PeerE2eeBundle; expiresAt: string; confirmedAt?: string; proxyUrl: string; peerAlias?: string; }; +type PairE2eeIdentity = { + keyId: string; + x25519PublicKey: string; + x25519SecretKey: string; +}; + type RegistryErrorEnvelope = { error?: { code?: string; @@ -129,6 +147,7 @@ type PeerEntry = { proxyUrl: string; agentName?: string; humanName?: string; + e2ee?: PeerE2eeBundle; }; type PeersConfig = { @@ -145,6 +164,11 @@ type PeerProfile = { humanName: string; }; +type PeerE2eeBundle = { + keyId: string; + x25519PublicKey: string; +}; + const isRecord = (value: unknown): value is Record => { return typeof value === "object" && value !== null; }; @@ -219,6 +243,35 @@ function parsePeerProfile(payload: unknown): PeerProfile { }; } +function parsePeerE2eeBundle( + payload: unknown, + code: string, + message: string, +): PeerE2eeBundle { + if (!isRecord(payload)) { + throw createCliError(code, message); + } + + const keyId = parseNonEmptyString(payload.keyId); + const x25519PublicKey = parseNonEmptyString(payload.x25519PublicKey); + if (keyId.length === 0 || x25519PublicKey.length === 0) { + throw createCliError(code, message); + } + + try { + if (decodeBase64url(x25519PublicKey).length !== 32) { + throw new Error("invalid length"); + } + } catch { + throw createCliError(code, message); + } + + return { + keyId, + x25519PublicKey, + }; +} + function parsePairingTicket(value: unknown): string { let ticket = parseNonEmptyString(value); while (ticket.startsWith("`")) { @@ -442,6 +495,120 @@ function resolvePeersConfigPath(getConfigDirImpl: typeof getConfigDir): string { return join(getConfigDirImpl(), PEERS_FILE_NAME); } +function resolveAgentE2eeIdentityPath(input: { + getConfigDirImpl: typeof getConfigDir; + agentName: string; +}): string { + return join( + input.getConfigDirImpl(), + AGENTS_DIR_NAME, + assertValidAgentName(input.agentName), + E2EE_IDENTITY_FILE_NAME, + ); +} + +function parsePairE2eeIdentity(payload: unknown): PairE2eeIdentity { + if (!isRecord(payload)) { + throw createCliError( + "CLI_PAIR_E2EE_IDENTITY_INVALID", + "Local E2EE identity file is invalid. Delete it and rerun pairing.", + ); + } + + const keyId = parseNonEmptyString(payload.keyId); + const x25519PublicKey = parseNonEmptyString(payload.x25519PublicKey); + const x25519SecretKey = parseNonEmptyString(payload.x25519SecretKey); + if ( + keyId.length === 0 || + x25519PublicKey.length === 0 || + x25519SecretKey.length === 0 + ) { + throw createCliError( + "CLI_PAIR_E2EE_IDENTITY_INVALID", + "Local E2EE identity file is invalid. Delete it and rerun pairing.", + ); + } + + try { + if (decodeBase64url(x25519PublicKey).length !== 32) { + throw new Error("invalid public key length"); + } + if (decodeBase64url(x25519SecretKey).length !== 32) { + throw new Error("invalid secret key length"); + } + } catch { + throw createCliError( + "CLI_PAIR_E2EE_IDENTITY_INVALID", + "Local E2EE identity file is invalid. Delete it and rerun pairing.", + ); + } + + return { + keyId, + x25519PublicKey, + x25519SecretKey, + }; +} + +async function loadOrCreateLocalPairE2eeIdentity(input: { + agentName: string; + getConfigDirImpl: typeof getConfigDir; + readFileImpl: typeof readFile; + writeFileImpl: typeof writeFile; + chmodImpl: typeof chmod; + mkdirImpl: typeof mkdir; + nowSecondsImpl: () => number; +}): Promise { + const identityPath = resolveAgentE2eeIdentityPath({ + getConfigDirImpl: input.getConfigDirImpl, + agentName: input.agentName, + }); + + try { + const raw = await input.readFileImpl(identityPath, "utf8"); + return parsePairE2eeIdentity(JSON.parse(raw)); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "ENOENT") { + // Create a new identity when absent. + } else if (error instanceof SyntaxError) { + throw createCliError( + "CLI_PAIR_E2EE_IDENTITY_INVALID", + "Local E2EE identity file is invalid. Delete it and rerun pairing.", + ); + } else if (error instanceof AppError) { + throw error; + } else { + throw error; + } + } + + const generated = generateX25519Keypair(); + const encoded = encodeX25519KeypairBase64url(generated); + const identity: PairE2eeIdentity = { + keyId: generateUlid(input.nowSecondsImpl() * 1000), + x25519PublicKey: encoded.publicKey, + x25519SecretKey: encoded.secretKey, + }; + + await input.mkdirImpl(dirname(identityPath), { recursive: true }); + await input.writeFileImpl( + identityPath, + `${JSON.stringify({ version: 1, ...identity }, null, 2)}\n`, + "utf8", + ); + try { + await input.chmodImpl(identityPath, FILE_MODE); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== "ENOENT") { + throw error; + } + } + + return identity; +} + function parsePeerEntry(value: unknown): PeerEntry { if (!isRecord(value)) { throw createCliError( @@ -472,6 +639,13 @@ function parsePeerEntry(value: unknown): PeerEntry { if (humanNameRaw.length > 0) { entry.humanName = parseProfileName(humanNameRaw, "humanName"); } + if (value.e2ee !== undefined) { + entry.e2ee = parsePeerE2eeBundle( + value.e2ee, + "CLI_PAIR_PEERS_CONFIG_INVALID", + "Peer entry is invalid", + ); + } return entry; } @@ -951,6 +1125,7 @@ function parsePairStartResponse( const initiatorAgentDid = parseNonEmptyString(payload.initiatorAgentDid); const expiresAt = parseNonEmptyString(payload.expiresAt); let initiatorProfile: PeerProfile; + let initiatorE2ee: PeerE2eeBundle; if (initiatorAgentDid.length === 0 || expiresAt.length === 0) { throw createCliError( @@ -960,6 +1135,11 @@ function parsePairStartResponse( } try { initiatorProfile = parsePeerProfile(payload.initiatorProfile); + initiatorE2ee = parsePeerE2eeBundle( + payload.initiatorE2ee, + "CLI_PAIR_START_INVALID_RESPONSE", + "Pair start response is invalid", + ); } catch { throw createCliError( "CLI_PAIR_START_INVALID_RESPONSE", @@ -971,6 +1151,7 @@ function parsePairStartResponse( ticket, initiatorAgentDid, initiatorProfile, + initiatorE2ee, expiresAt, }; } @@ -990,6 +1171,8 @@ function parsePairConfirmResponse( const responderAgentDid = parseNonEmptyString(payload.responderAgentDid); let initiatorProfile: PeerProfile; let responderProfile: PeerProfile; + let initiatorE2ee: PeerE2eeBundle; + let responderE2ee: PeerE2eeBundle; if ( !paired || @@ -1004,6 +1187,16 @@ function parsePairConfirmResponse( try { initiatorProfile = parsePeerProfile(payload.initiatorProfile); responderProfile = parsePeerProfile(payload.responderProfile); + initiatorE2ee = parsePeerE2eeBundle( + payload.initiatorE2ee, + "CLI_PAIR_CONFIRM_INVALID_RESPONSE", + "Pair confirm response is invalid", + ); + responderE2ee = parsePeerE2eeBundle( + payload.responderE2ee, + "CLI_PAIR_CONFIRM_INVALID_RESPONSE", + "Pair confirm response is invalid", + ); } catch { throw createCliError( "CLI_PAIR_CONFIRM_INVALID_RESPONSE", @@ -1017,6 +1210,8 @@ function parsePairConfirmResponse( responderAgentDid, initiatorProfile, responderProfile, + initiatorE2ee, + responderE2ee, }; } @@ -1043,6 +1238,7 @@ function parsePairStatusResponse( const expiresAt = parseNonEmptyString(payload.expiresAt); const confirmedAt = parseNonEmptyString(payload.confirmedAt); let initiatorProfile: PeerProfile; + let initiatorE2ee: PeerE2eeBundle; if (initiatorAgentDid.length === 0 || expiresAt.length === 0) { throw createCliError( @@ -1059,6 +1255,11 @@ function parsePairStatusResponse( } try { initiatorProfile = parsePeerProfile(payload.initiatorProfile); + initiatorE2ee = parsePeerE2eeBundle( + payload.initiatorE2ee, + "CLI_PAIR_STATUS_INVALID_RESPONSE", + "Pair status response is invalid", + ); } catch { throw createCliError( "CLI_PAIR_STATUS_INVALID_RESPONSE", @@ -1067,6 +1268,7 @@ function parsePairStatusResponse( } let responderProfile: PeerProfile | undefined; + let responderE2ee: PeerE2eeBundle | undefined; if (payload.responderProfile !== undefined) { try { responderProfile = parsePeerProfile(payload.responderProfile); @@ -1083,14 +1285,29 @@ function parsePairStatusResponse( "Pair status response is invalid", ); } + if (payload.responderE2ee !== undefined) { + responderE2ee = parsePeerE2eeBundle( + payload.responderE2ee, + "CLI_PAIR_STATUS_INVALID_RESPONSE", + "Pair status response is invalid", + ); + } + if (statusRaw === "confirmed" && responderE2ee === undefined) { + throw createCliError( + "CLI_PAIR_STATUS_INVALID_RESPONSE", + "Pair status response is invalid", + ); + } return { status: statusRaw, initiatorAgentDid, initiatorProfile, + initiatorE2ee, responderAgentDid: responderAgentDid.length > 0 ? responderAgentDid : undefined, responderProfile, + responderE2ee, expiresAt, confirmedAt: confirmedAt.length > 0 ? confirmedAt : undefined, }; @@ -1337,6 +1554,7 @@ async function persistPairedPeer(input: { ticket: string; peerDid: string; peerProfile: PeerProfile; + peerE2ee: PeerE2eeBundle; dependencies: PairRequestOptions; }): Promise { const getConfigDirImpl = input.dependencies.getConfigDirImpl ?? getConfigDir; @@ -1360,6 +1578,7 @@ async function persistPairedPeer(input: { proxyUrl: peerProxyUrl, agentName: input.peerProfile.agentName, humanName: input.peerProfile.humanName, + e2ee: input.peerE2ee, }; await savePeersConfig({ config: peersConfig, @@ -1409,11 +1628,30 @@ export async function startPairing( normalizedAgentName, dependencies, ); + const readFileImpl = dependencies.readFileImpl ?? readFile; + const writeFileImpl = dependencies.writeFileImpl ?? writeFile; + const chmodImpl = dependencies.chmodImpl ?? chmod; + const mkdirImpl = dependencies.mkdirImpl ?? mkdir; + const getConfigDirImpl = dependencies.getConfigDirImpl ?? getConfigDir; + const localE2eeIdentity = await loadOrCreateLocalPairE2eeIdentity({ + agentName: normalizedAgentName, + getConfigDirImpl, + readFileImpl, + writeFileImpl, + chmodImpl, + mkdirImpl, + nowSecondsImpl, + }); + const initiatorE2ee: PeerE2eeBundle = { + keyId: localE2eeIdentity.keyId, + x25519PublicKey: localE2eeIdentity.x25519PublicKey, + }; const requestUrl = toProxyRequestUrl(proxyUrl, PAIR_START_PATH); const requestBody = JSON.stringify({ ttlSeconds, initiatorProfile, + initiatorE2ee, }); const bodyBytes = new TextEncoder().encode(requestBody); @@ -1534,11 +1772,29 @@ export async function confirmPairing( normalizedAgentName, dependencies, ); + const writeFileImpl = dependencies.writeFileImpl ?? writeFile; + const chmodImpl = dependencies.chmodImpl ?? chmod; + const mkdirImpl = dependencies.mkdirImpl ?? mkdir; + const getConfigDirImpl = dependencies.getConfigDirImpl ?? getConfigDir; + const localE2eeIdentity = await loadOrCreateLocalPairE2eeIdentity({ + agentName: normalizedAgentName, + getConfigDirImpl, + readFileImpl, + writeFileImpl, + chmodImpl, + mkdirImpl, + nowSecondsImpl, + }); + const responderE2ee: PeerE2eeBundle = { + keyId: localE2eeIdentity.keyId, + x25519PublicKey: localE2eeIdentity.x25519PublicKey, + }; const requestUrl = toProxyRequestUrl(proxyUrl, PAIR_CONFIRM_PATH); const requestBody = JSON.stringify({ ticket, responderProfile, + responderE2ee, }); const bodyBytes = new TextEncoder().encode(requestBody); @@ -1581,6 +1837,7 @@ export async function confirmPairing( ticket, peerDid: parsed.initiatorAgentDid, peerProfile: parsed.initiatorProfile, + peerE2ee: parsed.initiatorE2ee, dependencies, }); @@ -1697,6 +1954,12 @@ async function getPairingStatusOnce( : callerAgentDid === responderAgentDid ? parsed.initiatorProfile : undefined; + const peerE2ee = + callerAgentDid === parsed.initiatorAgentDid + ? parsed.responderE2ee + : callerAgentDid === responderAgentDid + ? parsed.initiatorE2ee + : undefined; if (!peerDid) { throw createCliError( "CLI_PAIR_STATUS_FORBIDDEN", @@ -1709,11 +1972,18 @@ async function getPairingStatusOnce( "Pair status response is invalid", ); } + if (!peerE2ee) { + throw createCliError( + "CLI_PAIR_STATUS_INVALID_RESPONSE", + "Pair status response is invalid", + ); + } peerAlias = await persistPairedPeer({ ticket, peerDid, peerProfile, + peerE2ee, dependencies, }); } diff --git a/apps/cli/src/install-skill-mode.test.ts b/apps/cli/src/install-skill-mode.test.ts index 2dd2086..9ec2e4f 100644 --- a/apps/cli/src/install-skill-mode.test.ts +++ b/apps/cli/src/install-skill-mode.test.ts @@ -147,7 +147,7 @@ describe("installOpenclawSkillArtifacts", () => { } finally { sandbox.cleanup(); } - }); + }, 15_000); it("fails with actionable error when required artifact is missing", async () => { const sandbox = createSkillSandbox(); diff --git a/apps/proxy/src/AGENTS.md b/apps/proxy/src/AGENTS.md index e3f39f7..af3595a 100644 --- a/apps/proxy/src/AGENTS.md +++ b/apps/proxy/src/AGENTS.md @@ -63,13 +63,16 @@ - Keep AIT verification resilient to routine key rotation: retry once with a forced keyset refresh on `UNKNOWN_AIT_KID` before rejecting. - Keep CRL verification resilient to routine key rotation: retry once with a forced keyset refresh on `UNKNOWN_CRL_KID` before dependency-failure mapping. - Keep `/hooks/agent` input contract strict: require `Content-Type: application/json` and reject malformed JSON with explicit client errors. +- Keep `/hooks/agent` payload contract strict: accept only `claw_e2ee_v1` envelopes validated via protocol parser; reject plaintext/non-envelope JSON with `400 PROXY_HOOK_E2EE_REQUIRED`. - Keep agent-access validation centralized in `auth-middleware.ts` and call registry `POST /v1/agents/auth/validate`; treat non-`204` non-`401` responses as dependency failures (`503`). - Keep relay delivery failure mapping explicit for `/hooks/agent`: DO delivery/RPC failures -> `502`, unavailable DO namespace -> `503`. - Keep relay delivery semantics asynchronous and durable: `/hooks/agent` accepts queued deliveries with `202` (`state=queued`) when recipient connector is offline. - Keep relay queue saturation explicit: reject new deliveries with `507 PROXY_RELAY_QUEUE_FULL`; do not evict queued messages implicitly. - Keep relay retries inside `agent-relay-session.ts` with bounded backoff (`RELAY_RETRY_*`) and per-agent queue caps/TTL (`RELAY_QUEUE_*`); do not add ad-hoc retry loops in route handlers. -- Keep identity message injection explicit and default-on (`INJECT_IDENTITY_INTO_MESSAGE=true`); operators can disable it when unchanged forwarding is required. +- Keep proxy relay semantics metadata-only: proxy routes/auth may inspect headers and routing claims, but message bodies forwarded through `/hooks/agent` must remain opaque ciphertext envelopes. - Keep Durable Object trust routes explicit in `proxy-trust-store.ts`/`proxy-trust-state.ts` and use route constants from one source (`TRUST_STORE_ROUTES`) to avoid drift. - Index pairing tickets by ticket `kid` in both in-memory and Durable Object stores; persist the original full ticket string alongside each entry and require exact ticket match on confirm. -- Keep identity augmentation logic in small pure helpers (`sanitizeIdentityField`, `buildIdentityBlock`, payload mutation helper) inside `agent-hook-route.ts`; avoid spreading identity-format logic into `server.ts`. -- When identity injection is enabled, sanitize identity fields (strip control chars, normalize whitespace, enforce max lengths) and mutate only string `message` fields. +- Keep pairing E2EE contract strict: + - `/pair/start` requires `initiatorE2ee.{keyId,x25519PublicKey}` + - `/pair/confirm` requires `responderE2ee.{keyId,x25519PublicKey}` + - `/pair/status` returns stored `initiatorE2ee` and `responderE2ee` bundles diff --git a/apps/proxy/src/agent-hook-route.test.ts b/apps/proxy/src/agent-hook-route.test.ts index da7718c..180e556 100644 --- a/apps/proxy/src/agent-hook-route.test.ts +++ b/apps/proxy/src/agent-hook-route.test.ts @@ -39,19 +39,17 @@ import { parseProxyConfig } from "./config.js"; import type { ProxyTrustStore } from "./proxy-trust-store.js"; import { createProxyApp } from "./server.js"; -function hasDisallowedControlCharacter(value: string): boolean { - for (const char of value) { - const code = char.charCodeAt(0); - if ((code >= 0 && code <= 8) || code === 11 || code === 12) { - return true; - } - if ((code >= 14 && code <= 31) || code === 127) { - return true; - } - } - - return false; -} +const E2EE_PAYLOAD = { + kind: "claw_e2ee_v1", + alg: "X25519_XCHACHA20POLY1305_HKDF_SHA256", + sessionId: "01HF7YAT31JZHSMW1CG6Q6MHB7", + epoch: 1, + counter: 0, + nonce: Buffer.alloc(24, 7).toString("base64url"), + ciphertext: Buffer.from("ciphertext").toString("base64url"), + senderE2eePub: Buffer.alloc(32, 8).toString("base64url"), + sentAt: "2026-02-16T20:00:00.000Z", +}; function createRelayHarness(input?: { deliverResult?: RelayDeliveryResult; @@ -161,9 +159,7 @@ describe("POST /hooks/agent", () => { [RELAY_RECIPIENT_AGENT_DID_HEADER]: "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7", }, - body: JSON.stringify({ - event: "agent.started", - }), + body: JSON.stringify(E2EE_PAYLOAD), }); expect(response.status).toBe(202); @@ -177,7 +173,7 @@ describe("POST /hooks/agent", () => { expect(relayInput.recipientAgentDid).toBe( "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7", ); - expect(relayInput.payload).toEqual({ event: "agent.started" }); + expect(relayInput.payload).toEqual(E2EE_PAYLOAD); expect(typeof relayInput.requestId).toBe("string"); expect(relayInput.requestId.length).toBeGreaterThan(0); @@ -214,7 +210,7 @@ describe("POST /hooks/agent", () => { [RELAY_RECIPIENT_AGENT_DID_HEADER]: "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7", }, - body: JSON.stringify({ event: "agent.started" }), + body: JSON.stringify(E2EE_PAYLOAD), }); expect(response.status).toBe(202); @@ -234,7 +230,7 @@ describe("POST /hooks/agent", () => { [RELAY_RECIPIENT_AGENT_DID_HEADER]: "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB8", }, - body: JSON.stringify({ event: "agent.started" }), + body: JSON.stringify(E2EE_PAYLOAD), }); expect(response.status).toBe(403); @@ -243,77 +239,13 @@ describe("POST /hooks/agent", () => { expect(relayHarness.fetchRpc).not.toHaveBeenCalled(); }); - it("prepends sanitized identity block when message injection is enabled", async () => { + it("rejects non-e2ee payloads", async () => { const relayHarness = createRelayHarness(); const app = createHookRouteApp({ relayNamespace: relayHarness.namespace, - injectIdentityIntoMessage: true, }); const response = await app.request("/hooks/agent", { - method: "POST", - headers: { - "content-type": "application/json", - [RELAY_RECIPIENT_AGENT_DID_HEADER]: - "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7", - }, - body: JSON.stringify({ - message: "Summarize this payload", - }), - }); - - expect(response.status).toBe(202); - const [relayInput] = relayHarness.receivedInputs; - const forwardedPayload = relayInput.payload as { - message: string; - }; - - expect(forwardedPayload.message).toBe( - [ - "[Clawdentity Identity]", - "agentDid: did:claw:agent:alpha", - "ownerDid: did:claw:owner:alpha", - "issuer: https://registry.example.com", - "aitJti: ait-jti-alpha", - "", - "Summarize this payload", - ].join("\n"), - ); - }); - - it("keeps payload unchanged when message injection is enabled but auth is missing", async () => { - const relayHarness = createRelayHarness(); - const app = createHookRouteApp({ - relayNamespace: relayHarness.namespace, - injectIdentityIntoMessage: true, - }); - const rawPayload = { - message: "No auth context here", - event: "agent.started", - }; - - const response = await app.request("/hooks/agent", { - method: "POST", - headers: { - "content-type": "application/json", - [RELAY_RECIPIENT_AGENT_DID_HEADER]: - "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7", - "x-test-missing-auth": "1", - }, - body: JSON.stringify(rawPayload), - }); - - expect(response.status).toBe(500); - }); - - it("keeps payload unchanged when message is missing or non-string", async () => { - const relayHarness = createRelayHarness(); - const app = createHookRouteApp({ - relayNamespace: relayHarness.namespace, - injectIdentityIntoMessage: true, - }); - - await app.request("/hooks/agent", { method: "POST", headers: { "content-type": "application/json", @@ -325,60 +257,12 @@ describe("POST /hooks/agent", () => { }), }); - await app.request("/hooks/agent", { - method: "POST", - headers: { - "content-type": "application/json", - [RELAY_RECIPIENT_AGENT_DID_HEADER]: - "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7", - }, - body: JSON.stringify({ - message: { nested: true }, - }), - }); - - const [firstRelayInput, secondRelayInput] = relayHarness.receivedInputs; - - expect(firstRelayInput.payload).toEqual({ event: "agent.started" }); - expect(secondRelayInput.payload).toEqual({ message: { nested: true } }); - }); - - it("sanitizes identity fields and enforces length limits", async () => { - const relayHarness = createRelayHarness(); - const app = createHookRouteApp({ - relayNamespace: relayHarness.namespace, - injectIdentityIntoMessage: true, - }); - - const response = await app.request("/hooks/agent", { - method: "POST", - headers: { - "content-type": "application/json", - [RELAY_RECIPIENT_AGENT_DID_HEADER]: - "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7", - "x-test-dirty-auth": "1", - }, - body: JSON.stringify({ - message: "Hello world", - }), - }); - - expect(response.status).toBe(202); - const [relayInput] = relayHarness.receivedInputs; - - const forwardedPayload = relayInput.payload as { - message: string; + expect(response.status).toBe(400); + const body = (await response.json()) as { + error: { code: string; message: string }; }; - expect(forwardedPayload.message).toContain("[Clawdentity Identity]"); - - const identityBlock = forwardedPayload.message.split("\n\n")[0]; - expect(hasDisallowedControlCharacter(identityBlock)).toBe(false); - - const identityLines = identityBlock.split("\n"); - expect(identityLines[1].length).toBeLessThanOrEqual(171); - expect(identityLines[2].length).toBeLessThanOrEqual(171); - expect(identityLines[3].length).toBeLessThanOrEqual(208); - expect(identityLines[4].length).toBeLessThanOrEqual(72); + expect(body.error.code).toBe("PROXY_HOOK_E2EE_REQUIRED"); + expect(body.error.message).toBe("Payload must be a valid E2EE envelope"); }); it("rejects non-json content types", async () => { @@ -444,7 +328,7 @@ describe("POST /hooks/agent", () => { headers: { "content-type": "application/json", }, - body: JSON.stringify({ event: "agent.started" }), + body: JSON.stringify(E2EE_PAYLOAD), }); expect(response.status).toBe(400); @@ -464,7 +348,7 @@ describe("POST /hooks/agent", () => { "content-type": "application/json", [RELAY_RECIPIENT_AGENT_DID_HEADER]: "did:claw:human:not-agent", }, - body: JSON.stringify({ event: "agent.started" }), + body: JSON.stringify(E2EE_PAYLOAD), }); expect(response.status).toBe(400); @@ -484,7 +368,7 @@ describe("POST /hooks/agent", () => { [RELAY_RECIPIENT_AGENT_DID_HEADER]: "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7", }, - body: JSON.stringify({ event: "agent.started" }), + body: JSON.stringify(E2EE_PAYLOAD), }); expect(response.status).toBe(503); @@ -505,7 +389,7 @@ describe("POST /hooks/agent", () => { [RELAY_RECIPIENT_AGENT_DID_HEADER]: "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7", }, - body: JSON.stringify({ event: "agent.started" }), + body: JSON.stringify(E2EE_PAYLOAD), }); expect(response.status).toBe(502); @@ -535,7 +419,7 @@ describe("POST /hooks/agent", () => { [RELAY_RECIPIENT_AGENT_DID_HEADER]: "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7", }, - body: JSON.stringify({ event: "agent.started" }), + body: JSON.stringify(E2EE_PAYLOAD), }); expect(response.status).toBe(202); @@ -571,7 +455,7 @@ describe("POST /hooks/agent", () => { [RELAY_RECIPIENT_AGENT_DID_HEADER]: "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7", }, - body: JSON.stringify({ event: "agent.started" }), + body: JSON.stringify(E2EE_PAYLOAD), }); expect(response.status).toBe(507); diff --git a/apps/proxy/src/agent-hook-route.ts b/apps/proxy/src/agent-hook-route.ts index d578653..af9ad11 100644 --- a/apps/proxy/src/agent-hook-route.ts +++ b/apps/proxy/src/agent-hook-route.ts @@ -1,5 +1,6 @@ import { parseDid, + parseEncryptedRelayPayloadV1, RELAY_RECIPIENT_AGENT_DID_HEADER, } from "@clawdentity/protocol"; import { AppError, type Logger } from "@clawdentity/sdk"; @@ -14,11 +15,6 @@ import type { ProxyRequestVariables } from "./auth-middleware.js"; import type { ProxyTrustStore } from "./proxy-trust-store.js"; import { assertTrustedPair } from "./trust-policy.js"; -const MAX_AGENT_DID_LENGTH = 160; -const MAX_OWNER_DID_LENGTH = 160; -const MAX_ISSUER_LENGTH = 200; -const MAX_AIT_JTI_LENGTH = 64; - export { RELAY_RECIPIENT_AGENT_DID_HEADER } from "@clawdentity/protocol"; export type AgentHookRuntimeOptions = { @@ -50,64 +46,6 @@ function isJsonContentType(contentTypeHeader: string | undefined): boolean { return mediaType.trim().toLowerCase() === "application/json"; } -function stripControlChars(value: string): string { - let result = ""; - for (const char of value) { - const code = char.charCodeAt(0); - if ((code >= 0 && code <= 31) || code === 127) { - continue; - } - result += char; - } - - return result; -} - -function sanitizeIdentityField(value: string, maxLength: number): string { - const sanitized = stripControlChars(value).replaceAll(/\s+/g, " ").trim(); - - if (sanitized.length === 0) { - return "unknown"; - } - - return sanitized.slice(0, maxLength); -} - -function buildIdentityBlock( - auth: NonNullable, -): string { - return [ - "[Clawdentity Identity]", - `agentDid: ${sanitizeIdentityField(auth.agentDid, MAX_AGENT_DID_LENGTH)}`, - `ownerDid: ${sanitizeIdentityField(auth.ownerDid, MAX_OWNER_DID_LENGTH)}`, - `issuer: ${sanitizeIdentityField(auth.issuer, MAX_ISSUER_LENGTH)}`, - `aitJti: ${sanitizeIdentityField(auth.aitJti, MAX_AIT_JTI_LENGTH)}`, - ].join("\n"); -} - -function injectIdentityBlockIntoPayload( - payload: unknown, - auth: ProxyRequestVariables["auth"], -): unknown { - if (auth === undefined || typeof payload !== "object" || payload === null) { - return payload; - } - - if (!("message" in payload)) { - return payload; - } - - const message = (payload as { message?: unknown }).message; - if (typeof message !== "string") { - return payload; - } - - return { - ...(payload as Record), - message: `${buildIdentityBlock(auth)}\n\n${message}`, - }; -} - function parseRecipientAgentDid(c: ProxyContext): string { const recipientHeader = c.req.header(RELAY_RECIPIENT_AGENT_DID_HEADER); if ( @@ -156,7 +94,6 @@ function resolveDefaultSessionNamespace( export function createAgentHookHandler( options: CreateAgentHookHandlerOptions, ): (c: ProxyContext) => Promise { - const injectIdentityIntoMessage = options.injectIdentityIntoMessage ?? false; const now = options.now ?? (() => new Date()); const resolveSessionNamespace = options.resolveSessionNamespace ?? resolveDefaultSessionNamespace; @@ -183,8 +120,16 @@ export function createAgentHookHandler( }); } - if (injectIdentityIntoMessage) { - payload = injectIdentityBlockIntoPayload(payload, c.get("auth")); + let encryptedPayload: ReturnType; + try { + encryptedPayload = parseEncryptedRelayPayloadV1(payload); + } catch { + throw new AppError({ + code: "PROXY_HOOK_E2EE_REQUIRED", + message: "Payload must be a valid E2EE envelope", + status: 400, + expose: true, + }); } const auth = c.get("auth"); @@ -217,7 +162,7 @@ export function createAgentHookHandler( requestId, senderAgentDid: auth.agentDid, recipientAgentDid, - payload, + payload: encryptedPayload, }; const relaySession = sessionNamespace.get( diff --git a/apps/proxy/src/auth-middleware.test.ts b/apps/proxy/src/auth-middleware.test.ts index 55a78ac..c30a838 100644 --- a/apps/proxy/src/auth-middleware.test.ts +++ b/apps/proxy/src/auth-middleware.test.ts @@ -21,6 +21,17 @@ const NOW_MS = Date.now(); const NOW_SECONDS = Math.floor(NOW_MS / 1000); const ISSUER = "https://registry.clawdentity.com"; const BODY_JSON = JSON.stringify({ message: "hello" }); +const E2EE_BODY_JSON = JSON.stringify({ + kind: "claw_e2ee_v1", + alg: "X25519_XCHACHA20POLY1305_HKDF_SHA256", + sessionId: "01HF7YAT31JZHSMW1CG6Q6MHB7", + epoch: 1, + counter: 0, + nonce: Buffer.alloc(24, 7).toString("base64url"), + ciphertext: Buffer.from("ciphertext").toString("base64url"), + senderE2eePub: Buffer.alloc(32, 8).toString("base64url"), + sentAt: "2026-02-16T20:00:00.000Z", +}); const KNOWN_PEER_DID = "did:claw:agent:known-peer"; type AuthHarnessOptions = { @@ -319,6 +330,10 @@ describe("proxy auth middleware", () => { agentName: "beta", humanName: "Ira", }, + responderE2ee: { + keyId: "resp-key-1", + x25519PublicKey: "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA", + }, }); const headers = await harness.createSignedHeaders({ body: requestBody, @@ -677,6 +692,7 @@ describe("proxy auth middleware", () => { validateStatus: 204, }); const headers = await harness.createSignedHeaders({ + body: E2EE_BODY_JSON, pathWithQuery: "/hooks/agent", nonce: "nonce-hooks-agent-access-valid", }); @@ -687,7 +703,7 @@ describe("proxy auth middleware", () => { "x-claw-agent-access": "clw_agt_validtoken", [RELAY_RECIPIENT_AGENT_DID_HEADER]: harness.claims.sub, }, - body: BODY_JSON, + body: E2EE_BODY_JSON, }); expect(response.status).toBe(202); diff --git a/apps/proxy/src/pairing-route.test.ts b/apps/proxy/src/pairing-route.test.ts index 0daf2a6..06509c6 100644 --- a/apps/proxy/src/pairing-route.test.ts +++ b/apps/proxy/src/pairing-route.test.ts @@ -1,4 +1,8 @@ -import { generateUlid, makeAgentDid } from "@clawdentity/protocol"; +import { + encodeBase64url, + generateUlid, + makeAgentDid, +} from "@clawdentity/protocol"; import { describe, expect, it, vi } from "vitest"; import { createPairingTicket, @@ -17,6 +21,18 @@ const RESPONDER_PROFILE = { agentName: "beta", humanName: "Ira", }; +const INITIATOR_E2EE = { + keyId: "init-key-1", + x25519PublicKey: encodeBase64url( + Uint8Array.from({ length: 32 }, (_value, index) => index + 1), + ), +}; +const RESPONDER_E2EE = { + keyId: "resp-key-1", + x25519PublicKey: encodeBase64url( + Uint8Array.from({ length: 32 }, (_value, index) => index + 2), + ), +}; vi.mock("./auth-middleware.js", async () => { const { createMiddleware } = await import("hono/factory"); @@ -137,6 +153,7 @@ describe(`POST ${PAIR_START_PATH}`, () => { }, body: JSON.stringify({ initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, }), }); @@ -198,6 +215,7 @@ describe(`POST ${PAIR_START_PATH}`, () => { }, body: JSON.stringify({ initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, }), }); @@ -229,6 +247,7 @@ describe(`POST ${PAIR_START_PATH}`, () => { }, body: JSON.stringify({ initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, }), }); @@ -254,6 +273,7 @@ describe(`POST ${PAIR_START_PATH}`, () => { }, body: JSON.stringify({ initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, }), }); @@ -277,6 +297,7 @@ describe(`POST ${PAIR_CONFIRM_PATH}`, () => { const ticket = await trustStore.createPairingTicket({ initiatorAgentDid: INITIATOR_AGENT_DID, initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "http://localhost", ticket: createdTicket.ticket, expiresAtMs: 1_700_000_900_000, @@ -292,6 +313,7 @@ describe(`POST ${PAIR_CONFIRM_PATH}`, () => { body: JSON.stringify({ ticket: ticket.ticket, responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, }), }); @@ -314,8 +336,10 @@ describe(`POST ${PAIR_CONFIRM_PATH}`, () => { paired: true, initiatorAgentDid: INITIATOR_AGENT_DID, initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, responderAgentDid: RESPONDER_AGENT_DID, responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, }); expect( @@ -346,6 +370,7 @@ describe(`POST ${PAIR_STATUS_PATH}`, () => { const ticket = await trustStore.createPairingTicket({ initiatorAgentDid: INITIATOR_AGENT_DID, initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "http://localhost", ticket: createdTicket.ticket, expiresAtMs: 1_700_000_900_000, @@ -377,6 +402,7 @@ describe(`POST ${PAIR_STATUS_PATH}`, () => { status: "pending", initiatorAgentDid: INITIATOR_AGENT_DID, initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, expiresAt: "2023-11-14T22:28:20.000Z", }); }); @@ -393,6 +419,7 @@ describe(`POST ${PAIR_STATUS_PATH}`, () => { const ticket = await trustStore.createPairingTicket({ initiatorAgentDid: INITIATOR_AGENT_DID, initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "http://localhost", ticket: createdTicket.ticket, expiresAtMs: 1_700_000_900_000, @@ -402,6 +429,7 @@ describe(`POST ${PAIR_STATUS_PATH}`, () => { ticket: ticket.ticket, responderAgentDid: RESPONDER_AGENT_DID, responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, nowMs: 1_700_000_000_200, }); @@ -435,8 +463,10 @@ describe(`POST ${PAIR_STATUS_PATH}`, () => { status: "confirmed", initiatorAgentDid: INITIATOR_AGENT_DID, initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, responderAgentDid: RESPONDER_AGENT_DID, responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, expiresAt: "2023-11-14T22:28:20.000Z", confirmedAt: "2023-11-14T22:13:20.000Z", }); @@ -454,6 +484,7 @@ describe(`POST ${PAIR_STATUS_PATH}`, () => { const ticket = await trustStore.createPairingTicket({ initiatorAgentDid: INITIATOR_AGENT_DID, initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "http://localhost", ticket: createdTicket.ticket, expiresAtMs: 1_700_000_900_000, diff --git a/apps/proxy/src/pairing-route.ts b/apps/proxy/src/pairing-route.ts index 08f5c00..5d53787 100644 --- a/apps/proxy/src/pairing-route.ts +++ b/apps/proxy/src/pairing-route.ts @@ -1,3 +1,4 @@ +import { decodeBase64url } from "@clawdentity/protocol"; import { AppError, createRegistryIdentityClient, @@ -20,6 +21,7 @@ import { parsePairingTicket, } from "./pairing-ticket.js"; import { + type PeerE2eeBundle, type PeerProfile, type ProxyTrustStore, ProxyTrustStoreError, @@ -64,6 +66,7 @@ type CreatePairStatusHandlerOptions = PairStatusRuntimeOptions & { }; const MAX_PROFILE_NAME_LENGTH = 64; +const X25519_PUBLIC_KEY_BYTES = 32; function parseInternalServiceCredentials(input: { serviceId?: string; @@ -191,6 +194,50 @@ function parsePeerProfile(value: unknown, label: string): PeerProfile { }; } +function parsePeerE2eeBundle(value: unknown, label: string): PeerE2eeBundle { + if (typeof value !== "object" || value === null) { + throw new AppError({ + code: "PROXY_PAIR_INVALID_BODY", + message: `${label} is required`, + status: 400, + expose: true, + }); + } + + const payload = value as { keyId?: unknown; x25519PublicKey?: unknown }; + const keyId = parseProfileName(payload.keyId, `${label}.keyId`); + const x25519PublicKey = parseProfileName( + payload.x25519PublicKey, + `${label}.x25519PublicKey`, + ); + + let decodedPublicKey: Uint8Array; + try { + decodedPublicKey = decodeBase64url(x25519PublicKey); + } catch { + throw new AppError({ + code: "PROXY_PAIR_INVALID_BODY", + message: `${label}.x25519PublicKey must be valid base64url`, + status: 400, + expose: true, + }); + } + + if (decodedPublicKey.length !== X25519_PUBLIC_KEY_BYTES) { + throw new AppError({ + code: "PROXY_PAIR_INVALID_BODY", + message: `${label}.x25519PublicKey must decode to ${X25519_PUBLIC_KEY_BYTES} bytes`, + status: 400, + expose: true, + }); + } + + return { + keyId, + x25519PublicKey, + }; +} + async function parseJsonBody(c: PairingRouteContext): Promise { try { return await c.req.json(); @@ -297,12 +344,17 @@ export function createPairStartHandler( const body = (await parseJsonBody(c)) as { ttlSeconds?: unknown; initiatorProfile?: unknown; + initiatorE2ee?: unknown; }; const ttlSeconds = parseTtlSeconds(body.ttlSeconds); const initiatorProfile = parsePeerProfile( body.initiatorProfile, "initiatorProfile", ); + const initiatorE2ee = parsePeerE2eeBundle( + body.initiatorE2ee, + "initiatorE2ee", + ); const internalServiceCredentials = parseInternalServiceCredentials({ serviceId: options.registryInternalServiceId, serviceSecret: options.registryInternalServiceSecret, @@ -353,6 +405,7 @@ export function createPairStartHandler( .createPairingTicket({ initiatorAgentDid: auth.agentDid, initiatorProfile, + initiatorE2ee, issuerProxyUrl, ticket: createdTicket.ticket, expiresAtMs, @@ -373,6 +426,7 @@ export function createPairStartHandler( return c.json({ initiatorAgentDid: pairingTicketResult.initiatorAgentDid, initiatorProfile: pairingTicketResult.initiatorProfile, + initiatorE2ee: pairingTicketResult.initiatorE2ee, ticket: pairingTicketResult.ticket, expiresAt: new Date(pairingTicketResult.expiresAtMs).toISOString(), }); @@ -397,6 +451,7 @@ export function createPairConfirmHandler( const body = (await parseJsonBody(c)) as { ticket?: unknown; responderProfile?: unknown; + responderE2ee?: unknown; }; if (typeof body.ticket !== "string" || body.ticket.trim() === "") { throw new AppError({ @@ -410,6 +465,10 @@ export function createPairConfirmHandler( body.responderProfile, "responderProfile", ); + const responderE2ee = parsePeerE2eeBundle( + body.responderE2ee, + "responderE2ee", + ); const ticket = normalizePairingTicketText(body.ticket); try { @@ -437,6 +496,7 @@ export function createPairConfirmHandler( ticket, responderAgentDid: auth.agentDid, responderProfile, + responderE2ee, nowMs: nowMs(), }) .catch((error: unknown) => { @@ -455,8 +515,10 @@ export function createPairConfirmHandler( paired: true, initiatorAgentDid: confirmedPairingTicket.initiatorAgentDid, initiatorProfile: confirmedPairingTicket.initiatorProfile, + initiatorE2ee: confirmedPairingTicket.initiatorE2ee, responderAgentDid: confirmedPairingTicket.responderAgentDid, responderProfile: confirmedPairingTicket.responderProfile, + responderE2ee: confirmedPairingTicket.responderE2ee, }, 201, ); @@ -517,6 +579,7 @@ export function createPairStatusHandler( initiatorAgentDid: status.initiatorAgentDid, initiatorAgentName: status.initiatorProfile.agentName, initiatorHumanName: status.initiatorProfile.humanName, + initiatorE2eeKeyId: status.initiatorE2ee.keyId, responderAgentDid: status.status === "confirmed" ? status.responderAgentDid : undefined, responderAgentName: @@ -527,6 +590,8 @@ export function createPairStatusHandler( status.status === "confirmed" ? status.responderProfile.humanName : undefined, + responderE2eeKeyId: + status.status === "confirmed" ? status.responderE2ee.keyId : undefined, expiresAt: new Date(status.expiresAtMs).toISOString(), confirmedAt: status.status === "confirmed" @@ -538,10 +603,13 @@ export function createPairStatusHandler( status: status.status, initiatorAgentDid: status.initiatorAgentDid, initiatorProfile: status.initiatorProfile, + initiatorE2ee: status.initiatorE2ee, responderAgentDid: status.status === "confirmed" ? status.responderAgentDid : undefined, responderProfile: status.status === "confirmed" ? status.responderProfile : undefined, + responderE2ee: + status.status === "confirmed" ? status.responderE2ee : undefined, expiresAt: new Date(status.expiresAtMs).toISOString(), confirmedAt: status.status === "confirmed" diff --git a/apps/proxy/src/proxy-trust-state.test.ts b/apps/proxy/src/proxy-trust-state.test.ts index 0a29845..503b28a 100644 --- a/apps/proxy/src/proxy-trust-state.test.ts +++ b/apps/proxy/src/proxy-trust-state.test.ts @@ -17,6 +17,20 @@ const RESPONDER_PROFILE = { humanName: "Ira", }; +const INITIATOR_E2EE = { + keyId: "init-key-1", + x25519PublicKey: encodeBase64url( + Uint8Array.from({ length: 32 }, (_value, index) => index + 1), + ), +}; + +const RESPONDER_E2EE = { + keyId: "resp-key-1", + x25519PublicKey: encodeBase64url( + Uint8Array.from({ length: 32 }, (_value, index) => index + 2), + ), +}; + function tamperTicketNonce(ticket: string): string { const prefix = "clwpair1_"; if (!ticket.startsWith(prefix)) { @@ -131,6 +145,7 @@ describe("ProxyTrustState", () => { makeRequest(TRUST_STORE_ROUTES.createPairingTicket, { initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", ticket: createdTicket.ticket, expiresAtMs: 1_700_000_060_000, @@ -144,6 +159,7 @@ describe("ProxyTrustState", () => { ticket: ticketBody.ticket, responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, nowMs: 1_700_000_000_100, }), ); @@ -158,8 +174,10 @@ describe("ProxyTrustState", () => { ).toEqual({ initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, issuerProxyUrl: "https://proxy-a.example.com", }); @@ -191,8 +209,10 @@ describe("ProxyTrustState", () => { status: "confirmed", initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, expiresAtMs: 1_700_000_060_000, confirmedAtMs: 1_700_000_000_000, }); @@ -210,6 +230,7 @@ describe("ProxyTrustState", () => { makeRequest(TRUST_STORE_ROUTES.createPairingTicket, { initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", ticket: createdTicket.ticket, expiresAtMs: 1_700_000_060_000, @@ -234,6 +255,7 @@ describe("ProxyTrustState", () => { status: "pending", initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", expiresAtMs: 1_700_000_060_000, }); @@ -251,6 +273,7 @@ describe("ProxyTrustState", () => { makeRequest(TRUST_STORE_ROUTES.createPairingTicket, { initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", ticket: createdTicket.ticket, expiresAtMs: 1_700_000_060_123, @@ -280,6 +303,7 @@ describe("ProxyTrustState", () => { makeRequest(TRUST_STORE_ROUTES.createPairingTicket, { initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", ticket: createdTicket.ticket, expiresAtMs: 1_700_000_060_000, @@ -293,6 +317,7 @@ describe("ProxyTrustState", () => { ticket: tamperTicketNonce(ticketBody.ticket), responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, nowMs: 1_700_000_000_100, }), ); diff --git a/apps/proxy/src/proxy-trust-state.ts b/apps/proxy/src/proxy-trust-state.ts index cd1bf5e..b0621be 100644 --- a/apps/proxy/src/proxy-trust-state.ts +++ b/apps/proxy/src/proxy-trust-state.ts @@ -1,3 +1,4 @@ +import { decodeBase64url } from "@clawdentity/protocol"; import { normalizePairingTicketText, PairingTicketParseError, @@ -8,6 +9,7 @@ import { type PairingTicketConfirmInput, type PairingTicketInput, type PairingTicketStatusInput, + type PeerE2eeBundle, type PeerProfile, TRUST_STORE_ROUTES, } from "./proxy-trust-store.js"; @@ -17,6 +19,7 @@ type StoredPairingTicket = { expiresAtMs: number; initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; issuerProxyUrl: string; }; @@ -25,8 +28,10 @@ type StoredConfirmedPairingTicket = { expiresAtMs: number; initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; responderAgentDid: string; responderProfile: PeerProfile; + responderE2ee: PeerE2eeBundle; issuerProxyUrl: string; confirmedAtMs: number; }; @@ -67,6 +72,38 @@ function parsePeerProfile(value: unknown): PeerProfile | undefined { }; } +function parsePeerE2eeBundle(value: unknown): PeerE2eeBundle | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + + const entry = value as { + keyId?: unknown; + x25519PublicKey?: unknown; + }; + if ( + !isNonEmptyString(entry.keyId) || + !isNonEmptyString(entry.x25519PublicKey) + ) { + return undefined; + } + + const keyId = entry.keyId.trim(); + const x25519PublicKey = entry.x25519PublicKey.trim(); + try { + if (decodeBase64url(x25519PublicKey).length !== 32) { + return undefined; + } + } catch { + return undefined; + } + + return { + keyId, + x25519PublicKey, + }; +} + function addPeer( index: AgentPeersIndex, leftAgentDid: string, @@ -163,10 +200,12 @@ export class ProxyTrustState { | Partial | undefined; const initiatorProfile = parsePeerProfile(body?.initiatorProfile); + const initiatorE2ee = parsePeerE2eeBundle(body?.initiatorE2ee); if ( !body || !isNonEmptyString(body.initiatorAgentDid) || !initiatorProfile || + !initiatorE2ee || !isNonEmptyString(body.issuerProxyUrl) || !isNonEmptyString(body.ticket) || typeof body.expiresAtMs !== "number" || @@ -229,6 +268,7 @@ export class ProxyTrustState { ticket, initiatorAgentDid: body.initiatorAgentDid, initiatorProfile, + initiatorE2ee, issuerProxyUrl: parsedTicket.iss, expiresAtMs: normalizedExpiresAtMs, }; @@ -244,6 +284,7 @@ export class ProxyTrustState { expiresAtMs: normalizedExpiresAtMs, initiatorAgentDid: body.initiatorAgentDid, initiatorProfile, + initiatorE2ee, issuerProxyUrl: parsedTicket.iss, }); } @@ -255,11 +296,13 @@ export class ProxyTrustState { | Partial | undefined; const responderProfile = parsePeerProfile(body?.responderProfile); + const responderE2ee = parsePeerE2eeBundle(body?.responderE2ee); if ( !body || !isNonEmptyString(body.ticket) || !isNonEmptyString(body.responderAgentDid) || - !responderProfile + !responderProfile || + !responderE2ee ) { return toErrorResponse({ code: "PROXY_PAIR_CONFIRM_INVALID_BODY", @@ -334,8 +377,10 @@ export class ProxyTrustState { expiresAtMs: stored.expiresAtMs, initiatorAgentDid: stored.initiatorAgentDid, initiatorProfile: stored.initiatorProfile, + initiatorE2ee: stored.initiatorE2ee, responderAgentDid: body.responderAgentDid, responderProfile, + responderE2ee, issuerProxyUrl: stored.issuerProxyUrl, confirmedAtMs: normalizeExpiryToWholeSecond(nowMs), }; @@ -347,8 +392,10 @@ export class ProxyTrustState { return Response.json({ initiatorAgentDid: stored.initiatorAgentDid, initiatorProfile: stored.initiatorProfile, + initiatorE2ee: stored.initiatorE2ee, responderAgentDid: body.responderAgentDid, responderProfile, + responderE2ee, issuerProxyUrl: stored.issuerProxyUrl, }); } @@ -405,6 +452,7 @@ export class ProxyTrustState { ticket: pending.ticket, initiatorAgentDid: pending.initiatorAgentDid, initiatorProfile: pending.initiatorProfile, + initiatorE2ee: pending.initiatorE2ee, issuerProxyUrl: pending.issuerProxyUrl, expiresAtMs: pending.expiresAtMs, }); @@ -429,8 +477,10 @@ export class ProxyTrustState { ticket: confirmed.ticket, initiatorAgentDid: confirmed.initiatorAgentDid, initiatorProfile: confirmed.initiatorProfile, + initiatorE2ee: confirmed.initiatorE2ee, responderAgentDid: confirmed.responderAgentDid, responderProfile: confirmed.responderProfile, + responderE2ee: confirmed.responderE2ee, issuerProxyUrl: confirmed.issuerProxyUrl, expiresAtMs: confirmed.expiresAtMs, confirmedAtMs: confirmed.confirmedAtMs, @@ -657,12 +707,15 @@ export class ProxyTrustState { expiresAtMs?: unknown; initiatorAgentDid?: unknown; initiatorProfile?: unknown; + initiatorE2ee?: unknown; issuerProxyUrl?: unknown; }; const initiatorProfile = parsePeerProfile(entry.initiatorProfile); + const initiatorE2ee = parsePeerE2eeBundle(entry.initiatorE2ee); if ( !isNonEmptyString(entry.initiatorAgentDid) || !initiatorProfile || + !initiatorE2ee || !isNonEmptyString(entry.issuerProxyUrl) || typeof entry.expiresAtMs !== "number" || !Number.isInteger(entry.expiresAtMs) @@ -685,6 +738,7 @@ export class ProxyTrustState { expiresAtMs: entry.expiresAtMs, initiatorAgentDid: entry.initiatorAgentDid, initiatorProfile, + initiatorE2ee, issuerProxyUrl: parsedTicket.iss, }; } @@ -718,19 +772,25 @@ export class ProxyTrustState { expiresAtMs?: unknown; initiatorAgentDid?: unknown; initiatorProfile?: unknown; + initiatorE2ee?: unknown; responderAgentDid?: unknown; responderProfile?: unknown; + responderE2ee?: unknown; issuerProxyUrl?: unknown; confirmedAtMs?: unknown; }; const initiatorProfile = parsePeerProfile(entry.initiatorProfile); + const initiatorE2ee = parsePeerE2eeBundle(entry.initiatorE2ee); const responderProfile = parsePeerProfile(entry.responderProfile); + const responderE2ee = parsePeerE2eeBundle(entry.responderE2ee); if ( !isNonEmptyString(entry.initiatorAgentDid) || !initiatorProfile || + !initiatorE2ee || !isNonEmptyString(entry.responderAgentDid) || !responderProfile || + !responderE2ee || !isNonEmptyString(entry.issuerProxyUrl) || typeof entry.expiresAtMs !== "number" || !Number.isInteger(entry.expiresAtMs) || @@ -755,8 +815,10 @@ export class ProxyTrustState { expiresAtMs: entry.expiresAtMs, initiatorAgentDid: entry.initiatorAgentDid, initiatorProfile, + initiatorE2ee, responderAgentDid: entry.responderAgentDid, responderProfile, + responderE2ee, issuerProxyUrl: parsedTicket.iss, confirmedAtMs: entry.confirmedAtMs, }; diff --git a/apps/proxy/src/proxy-trust-store.test.ts b/apps/proxy/src/proxy-trust-store.test.ts index 90fc80d..eb8e5eb 100644 --- a/apps/proxy/src/proxy-trust-store.test.ts +++ b/apps/proxy/src/proxy-trust-store.test.ts @@ -16,6 +16,20 @@ const RESPONDER_PROFILE = { humanName: "Ira", }; +const INITIATOR_E2EE = { + keyId: "init-key-1", + x25519PublicKey: encodeBase64url( + Uint8Array.from({ length: 32 }, (_value, index) => index + 1), + ), +}; + +const RESPONDER_E2EE = { + keyId: "resp-key-1", + x25519PublicKey: encodeBase64url( + Uint8Array.from({ length: 32 }, (_value, index) => index + 2), + ), +}; + function tamperTicketNonce(ticket: string): string { const prefix = "clwpair1_"; if (!ticket.startsWith(prefix)) { @@ -110,6 +124,7 @@ describe("in-memory proxy trust store", () => { const ticket = await store.createPairingTicket({ initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", ticket: created.ticket, expiresAtMs: 1_700_000_060_000, @@ -120,14 +135,17 @@ describe("in-memory proxy trust store", () => { ticket: ticket.ticket, responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, nowMs: 1_700_000_000_100, }); expect(confirmed).toEqual({ initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, issuerProxyUrl: "https://proxy-a.example.com", }); @@ -136,6 +154,7 @@ describe("in-memory proxy trust store", () => { ticket: ticket.ticket, responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, nowMs: 1_700_000_000_200, }), ).rejects.toMatchObject({ @@ -157,6 +176,7 @@ describe("in-memory proxy trust store", () => { const ticket = await store.createPairingTicket({ initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", ticket: created.ticket, expiresAtMs: 1_700_000_060_000, @@ -173,6 +193,7 @@ describe("in-memory proxy trust store", () => { ticket: ticket.ticket, initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", expiresAtMs: 1_700_000_060_000, }); @@ -181,6 +202,7 @@ describe("in-memory proxy trust store", () => { ticket: ticket.ticket, responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, nowMs: 1_700_000_000_300, }); @@ -194,8 +216,10 @@ describe("in-memory proxy trust store", () => { ticket: ticket.ticket, initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, issuerProxyUrl: "https://proxy-a.example.com", expiresAtMs: 1_700_000_060_000, confirmedAtMs: 1_700_000_000_000, @@ -213,6 +237,7 @@ describe("in-memory proxy trust store", () => { const ticket = await store.createPairingTicket({ initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", ticket: created.ticket, expiresAtMs: 1_700_000_060_123, @@ -232,6 +257,7 @@ describe("in-memory proxy trust store", () => { const ticket = await store.createPairingTicket({ initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", ticket: created.ticket, expiresAtMs: 1_700_000_060_000, @@ -243,6 +269,7 @@ describe("in-memory proxy trust store", () => { ticket: tamperTicketNonce(ticket.ticket), responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, nowMs: 1_700_000_000_100, }), ).rejects.toMatchObject({ @@ -261,6 +288,7 @@ describe("in-memory proxy trust store", () => { const ticket = await store.createPairingTicket({ initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", ticket: created.ticket, expiresAtMs: 1_700_000_001_000, @@ -272,6 +300,7 @@ describe("in-memory proxy trust store", () => { ticket: ticket.ticket, responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, nowMs: 1_700_000_002_000, }), ).rejects.toMatchObject({ @@ -301,6 +330,7 @@ describe("in-memory proxy trust store", () => { const expiredTicket = await store.createPairingTicket({ initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", ticket: expired.ticket, expiresAtMs: 1_700_000_001_000, @@ -315,6 +345,7 @@ describe("in-memory proxy trust store", () => { const validTicket = await store.createPairingTicket({ initiatorAgentDid: "did:claw:agent:alice", initiatorProfile: INITIATOR_PROFILE, + initiatorE2ee: INITIATOR_E2EE, issuerProxyUrl: "https://proxy-a.example.com", ticket: valid.ticket, expiresAtMs: 1_700_000_060_000, @@ -325,6 +356,7 @@ describe("in-memory proxy trust store", () => { ticket: validTicket.ticket, responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, nowMs: 1_700_000_002_000, }); @@ -333,6 +365,7 @@ describe("in-memory proxy trust store", () => { ticket: expiredTicket.ticket, responderAgentDid: "did:claw:agent:bob", responderProfile: RESPONDER_PROFILE, + responderE2ee: RESPONDER_E2EE, nowMs: 1_700_000_002_100, }), ).rejects.toMatchObject({ diff --git a/apps/proxy/src/proxy-trust-store.ts b/apps/proxy/src/proxy-trust-store.ts index cc0be12..c5119a1 100644 --- a/apps/proxy/src/proxy-trust-store.ts +++ b/apps/proxy/src/proxy-trust-store.ts @@ -9,6 +9,7 @@ import { normalizeExpiryToWholeSecond, toPairKey } from "./proxy-trust-keys.js"; export type PairingTicketInput = { initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; issuerProxyUrl: string; ticket: string; expiresAtMs: number; @@ -20,6 +21,7 @@ export type PairingTicketResult = { expiresAtMs: number; initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; issuerProxyUrl: string; }; @@ -27,14 +29,17 @@ export type PairingTicketConfirmInput = { ticket: string; responderAgentDid: string; responderProfile: PeerProfile; + responderE2ee: PeerE2eeBundle; nowMs?: number; }; export type PairingTicketConfirmResult = { initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; responderAgentDid: string; responderProfile: PeerProfile; + responderE2ee: PeerE2eeBundle; issuerProxyUrl: string; }; @@ -49,6 +54,7 @@ export type PairingTicketStatusResult = ticket: string; initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; issuerProxyUrl: string; expiresAtMs: number; } @@ -57,8 +63,10 @@ export type PairingTicketStatusResult = ticket: string; initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; responderAgentDid: string; responderProfile: PeerProfile; + responderE2ee: PeerE2eeBundle; issuerProxyUrl: string; expiresAtMs: number; confirmedAtMs: number; @@ -69,6 +77,11 @@ export type PeerProfile = { humanName: string; }; +export type PeerE2eeBundle = { + keyId: string; + x25519PublicKey: string; +}; + export type PairingInput = { initiatorAgentDid: string; responderAgentDid: string; @@ -255,8 +268,10 @@ export function createInMemoryProxyTrustStore(): ProxyTrustStore { expiresAtMs: number; initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; responderAgentDid: string; responderProfile: PeerProfile; + responderE2ee: PeerE2eeBundle; issuerProxyUrl: string; confirmedAtMs: number; } @@ -268,6 +283,7 @@ export function createInMemoryProxyTrustStore(): ProxyTrustStore { expiresAtMs: number; initiatorAgentDid: string; initiatorProfile: PeerProfile; + initiatorE2ee: PeerE2eeBundle; issuerProxyUrl: string; } >(); @@ -361,8 +377,10 @@ export function createInMemoryProxyTrustStore(): ProxyTrustStore { pair: { initiatorAgentDid: stored.initiatorAgentDid, initiatorProfile: stored.initiatorProfile, + initiatorE2ee: stored.initiatorE2ee, responderAgentDid: input.responderAgentDid, responderProfile: input.responderProfile, + responderE2ee: input.responderE2ee, issuerProxyUrl: stored.issuerProxyUrl, }, ticketKid: parsedTicket.kid, @@ -394,6 +412,7 @@ export function createInMemoryProxyTrustStore(): ProxyTrustStore { ticket: pending.ticket, initiatorAgentDid: pending.initiatorAgentDid, initiatorProfile: pending.initiatorProfile, + initiatorE2ee: pending.initiatorE2ee, issuerProxyUrl: pending.issuerProxyUrl, expiresAtMs: pending.expiresAtMs, }; @@ -415,8 +434,10 @@ export function createInMemoryProxyTrustStore(): ProxyTrustStore { ticket: confirmed.ticket, initiatorAgentDid: confirmed.initiatorAgentDid, initiatorProfile: confirmed.initiatorProfile, + initiatorE2ee: confirmed.initiatorE2ee, responderAgentDid: confirmed.responderAgentDid, responderProfile: confirmed.responderProfile, + responderE2ee: confirmed.responderE2ee, issuerProxyUrl: confirmed.issuerProxyUrl, expiresAtMs: confirmed.expiresAtMs, confirmedAtMs: confirmed.confirmedAtMs, @@ -469,6 +490,7 @@ export function createInMemoryProxyTrustStore(): ProxyTrustStore { ticket, initiatorAgentDid: input.initiatorAgentDid, initiatorProfile: input.initiatorProfile, + initiatorE2ee: input.initiatorE2ee, issuerProxyUrl: parsedTicket.iss, expiresAtMs: normalizedExpiresAtMs, }); @@ -479,6 +501,7 @@ export function createInMemoryProxyTrustStore(): ProxyTrustStore { expiresAtMs: normalizedExpiresAtMs, initiatorAgentDid: input.initiatorAgentDid, initiatorProfile: input.initiatorProfile, + initiatorE2ee: input.initiatorE2ee, issuerProxyUrl: parsedTicket.iss, }; }, @@ -511,8 +534,10 @@ export function createInMemoryProxyTrustStore(): ProxyTrustStore { ticket, initiatorAgentDid: confirmedPair.initiatorAgentDid, initiatorProfile: confirmedPair.initiatorProfile, + initiatorE2ee: confirmedPair.initiatorE2ee, responderAgentDid: confirmedPair.responderAgentDid, responderProfile: confirmedPair.responderProfile, + responderE2ee: confirmedPair.responderE2ee, issuerProxyUrl: confirmedPair.issuerProxyUrl, expiresAtMs, confirmedAtMs, diff --git a/packages/connector/AGENTS.md b/packages/connector/AGENTS.md index 691843e..9280f0b 100644 --- a/packages/connector/AGENTS.md +++ b/packages/connector/AGENTS.md @@ -9,6 +9,7 @@ - Reuse shared protocol validators (`parseDid`, `parseUlid`) instead of duplicating DID/ULID logic. - Keep reconnect and heartbeat behavior deterministic and testable via dependency injection (`webSocketFactory`, `fetchImpl`, clock/random). - Keep local OpenClaw delivery concerns in `src/client.ts`; do not spread HTTP delivery logic across modules. +- Keep runtime message confidentiality end-to-end: encrypt outbound payloads in connector runtime and decrypt inbound payloads only at local replay boundary. - Keep inbound connector delivery durable: acknowledge proxy delivery only after payload persistence to local inbox (`agents//inbound-inbox/index.json`), then replay asynchronously to OpenClaw hook. - Keep local inbox storage portable and inspectable (`index.json` + `events.jsonl`) with atomic index writes (`.tmp` + rename); do not introduce runtime-specific persistence dependencies for connector inbox state. - Keep replay behavior restart-safe: on runtime boot, replay pending inbox entries in background before relying on new WebSocket traffic. @@ -22,4 +23,6 @@ - `src/frames.test.ts` must cover roundtrip serialization and explicit invalid-frame failures. - Client tests must mock WebSocket/fetch and verify heartbeat ack, delivery forwarding, reconnect, and outbound queue flush behavior. - Inbox tests must cover persistence, dedupe by request id, cap enforcement, and replay state transitions (`markReplayFailure`/`markDelivered`). +- E2EE tests must cover identity creation, outbound encryption + inbound decryption roundtrip, and rejection when peer E2EE bundle data is absent/invalid. +- Keep `vitest.config.ts` aliases for `@clawdentity/protocol` and `@clawdentity/sdk` pointed at source entrypoints to avoid stale `dist` export drift in workspace tests. - Keep tests fully offline and deterministic (fake timers where timing matters). diff --git a/packages/connector/src/AGENTS.md b/packages/connector/src/AGENTS.md index ba7f72f..7defccf 100644 --- a/packages/connector/src/AGENTS.md +++ b/packages/connector/src/AGENTS.md @@ -5,6 +5,15 @@ - Keep websocket lifecycle + ack behavior in `client.ts`. - Keep local runtime orchestration (`/v1/outbound`, `/v1/status`, auth refresh, replay loop) in `runtime.ts`. - Keep durable inbound storage logic isolated in `inbound-inbox.ts`. +- Keep connector E2EE session/key management isolated in `e2ee.ts`. + +## E2EE Rules +- Outbound relay requests must be encrypted before sending to proxy; proxy-bound body is the E2EE envelope, not plaintext payload. +- Inbound websocket deliveries must be validated as `claw_e2ee_v1` envelopes before persistence/ack. +- Persist only ciphertext envelopes in inbound inbox (`index.json`); decrypt only during replay right before local OpenClaw hook delivery. +- Peer encryption material is sourced from `peers.json` (`peer.e2ee.keyId` + `peer.e2ee.x25519PublicKey`); missing/invalid peer bundles are hard errors. +- Local connector encryption identity is per-agent (`agents//e2ee-identity.json`) and must be created/read atomically with restrictive file mode. +- Cached E2EE sessions must be invalidated and recreated whenever `peerKeyId` or `localKeyId` changes to avoid stale-chain decryption failures after re-pair/recovery. ## Inbound Durability Rules - Connector must persist inbound relay payloads before sending `deliver_ack accepted=true`. diff --git a/packages/connector/src/e2ee.test.ts b/packages/connector/src/e2ee.test.ts new file mode 100644 index 0000000..c699974 --- /dev/null +++ b/packages/connector/src/e2ee.test.ts @@ -0,0 +1,436 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createLogger } from "@clawdentity/sdk"; +import { afterEach, describe, expect, it } from "vitest"; +import { ConnectorE2eeManager } from "./e2ee.js"; + +const ALPHA_DID = "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB7"; +const BETA_DID = "did:claw:agent:01HF7YAT31JZHSMW1CG6Q6MHB8"; + +type StoredIdentity = { + keyId: string; + x25519PublicKey: string; + x25519SecretKey: string; +}; + +type StoredSession = { + sessionId: string; + peerDid: string; + peerKeyId: string; + localKeyId: string; + epoch: number; + epochStartedAtMs: number; + sendCounter: number; + recvCounter: number; + rootKey: string; + sendChainKey: string; + recvChainKey: string; +}; + +const tempDirs: string[] = []; + +async function createTempConfigDir(): Promise { + const directory = await mkdtemp( + join(tmpdir(), "clawdentity-connector-e2ee-"), + ); + tempDirs.push(directory); + return directory; +} + +async function readIdentity(input: { + configDir: string; + agentName: string; +}): Promise { + const path = join( + input.configDir, + "agents", + input.agentName, + "e2ee-identity.json", + ); + const parsed = JSON.parse(await readFile(path, "utf8")) as { + keyId: string; + x25519PublicKey: string; + x25519SecretKey: string; + }; + return { + keyId: parsed.keyId, + x25519PublicKey: parsed.x25519PublicKey, + x25519SecretKey: parsed.x25519SecretKey, + }; +} + +async function readSession(input: { + configDir: string; + agentName: string; + peerDid: string; +}): Promise { + const path = join( + input.configDir, + "agents", + input.agentName, + "e2ee-sessions.json", + ); + const parsed = JSON.parse(await readFile(path, "utf8")) as { + sessionsByPeerDid?: Record; + }; + return parsed.sessionsByPeerDid?.[input.peerDid]; +} + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map(async (directory) => { + await rm(directory, { recursive: true, force: true }); + }), + ); +}); + +describe("ConnectorE2eeManager", () => { + it("creates and reuses local e2ee identity", async () => { + const configDir = await createTempConfigDir(); + const logger = createLogger({ service: "test", module: "e2ee" }); + const manager = new ConnectorE2eeManager({ + agentDid: ALPHA_DID, + agentName: "alpha", + configDir, + logger, + }); + + await manager.initialize(); + const firstIdentity = await readIdentity({ + configDir, + agentName: "alpha", + }); + + await manager.initialize(); + const secondIdentity = await readIdentity({ + configDir, + agentName: "alpha", + }); + + expect(firstIdentity.keyId).toBe(secondIdentity.keyId); + expect(firstIdentity.x25519PublicKey).toBe(secondIdentity.x25519PublicKey); + }); + + it("encrypts outbound payload and decrypts inbound payload", async () => { + const configDir = await createTempConfigDir(); + const logger = createLogger({ service: "test", module: "e2ee" }); + + const alpha = new ConnectorE2eeManager({ + agentDid: ALPHA_DID, + agentName: "alpha", + configDir, + logger, + }); + const beta = new ConnectorE2eeManager({ + agentDid: BETA_DID, + agentName: "beta", + configDir, + logger, + }); + + await alpha.initialize(); + await beta.initialize(); + + const alphaIdentity = await readIdentity({ configDir, agentName: "alpha" }); + const betaIdentity = await readIdentity({ configDir, agentName: "beta" }); + + await writeFile( + join(configDir, "peers.json"), + `${JSON.stringify( + { + peers: { + alpha: { + did: ALPHA_DID, + proxyUrl: "https://alpha.proxy.example/hooks/agent", + e2ee: { + keyId: alphaIdentity.keyId, + x25519PublicKey: alphaIdentity.x25519PublicKey, + }, + }, + beta: { + did: BETA_DID, + proxyUrl: "https://beta.proxy.example/hooks/agent", + e2ee: { + keyId: betaIdentity.keyId, + x25519PublicKey: betaIdentity.x25519PublicKey, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const plaintext = { + message: "hello peer", + metadata: { + round: 1, + }, + }; + + const envelope = await alpha.encryptOutbound({ + peerAlias: "beta", + peerDid: BETA_DID, + payload: plaintext, + }); + + expect(envelope.kind).toBe("claw_e2ee_v1"); + expect(envelope.ciphertext).not.toContain("hello peer"); + + const decrypted = await beta.decryptInbound({ + fromAgentDid: ALPHA_DID, + toAgentDid: BETA_DID, + payload: envelope, + }); + + expect(decrypted).toEqual(plaintext); + }); + + it("rejects outbound encryption when peer e2ee metadata is missing", async () => { + const configDir = await createTempConfigDir(); + const logger = createLogger({ service: "test", module: "e2ee" }); + const manager = new ConnectorE2eeManager({ + agentDid: ALPHA_DID, + agentName: "alpha", + configDir, + logger, + }); + + await manager.initialize(); + await writeFile( + join(configDir, "peers.json"), + `${JSON.stringify( + { + peers: { + beta: { + did: BETA_DID, + proxyUrl: "https://beta.proxy.example/hooks/agent", + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + await expect( + manager.encryptOutbound({ + peerAlias: "beta", + peerDid: BETA_DID, + payload: { message: "hello" }, + }), + ).rejects.toMatchObject({ + code: "CONNECTOR_E2EE_PEER_NOT_FOUND", + }); + }); + + it("recreates outbound session when peer key id changes", async () => { + const configDir = await createTempConfigDir(); + const logger = createLogger({ service: "test", module: "e2ee" }); + const alpha = new ConnectorE2eeManager({ + agentDid: ALPHA_DID, + agentName: "alpha", + configDir, + logger, + }); + const beta = new ConnectorE2eeManager({ + agentDid: BETA_DID, + agentName: "beta", + configDir, + logger, + }); + + await alpha.initialize(); + await beta.initialize(); + + const alphaIdentity = await readIdentity({ configDir, agentName: "alpha" }); + const betaIdentity = await readIdentity({ configDir, agentName: "beta" }); + + await writeFile( + join(configDir, "peers.json"), + `${JSON.stringify( + { + peers: { + beta: { + did: BETA_DID, + proxyUrl: "https://beta.proxy.example/hooks/agent", + e2ee: { + keyId: betaIdentity.keyId, + x25519PublicKey: betaIdentity.x25519PublicKey, + }, + }, + alpha: { + did: ALPHA_DID, + proxyUrl: "https://alpha.proxy.example/hooks/agent", + e2ee: { + keyId: alphaIdentity.keyId, + x25519PublicKey: alphaIdentity.x25519PublicKey, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const first = await alpha.encryptOutbound({ + peerAlias: "beta", + peerDid: BETA_DID, + payload: { message: "first" }, + }); + const firstSession = await readSession({ + configDir, + agentName: "alpha", + peerDid: BETA_DID, + }); + + await writeFile( + join(configDir, "peers.json"), + `${JSON.stringify( + { + peers: { + beta: { + did: BETA_DID, + proxyUrl: "https://beta.proxy.example/hooks/agent", + e2ee: { + keyId: "beta-key-rotated", + x25519PublicKey: betaIdentity.x25519PublicKey, + }, + }, + alpha: { + did: ALPHA_DID, + proxyUrl: "https://alpha.proxy.example/hooks/agent", + e2ee: { + keyId: alphaIdentity.keyId, + x25519PublicKey: alphaIdentity.x25519PublicKey, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const second = await alpha.encryptOutbound({ + peerAlias: "beta", + peerDid: BETA_DID, + payload: { message: "second" }, + }); + const secondSession = await readSession({ + configDir, + agentName: "alpha", + peerDid: BETA_DID, + }); + + expect(first.counter).toBe(0); + expect(second.counter).toBe(0); + expect(firstSession?.sessionId).toBeDefined(); + expect(secondSession?.sessionId).toBeDefined(); + expect(secondSession?.sessionId).not.toBe(firstSession?.sessionId); + expect(secondSession?.peerKeyId).toBe("beta-key-rotated"); + }); + + it("recreates outbound session when local key id changes", async () => { + const configDir = await createTempConfigDir(); + const logger = createLogger({ service: "test", module: "e2ee" }); + const alpha = new ConnectorE2eeManager({ + agentDid: ALPHA_DID, + agentName: "alpha", + configDir, + logger, + }); + const beta = new ConnectorE2eeManager({ + agentDid: BETA_DID, + agentName: "beta", + configDir, + logger, + }); + + await alpha.initialize(); + await beta.initialize(); + + const alphaIdentity = await readIdentity({ configDir, agentName: "alpha" }); + const betaIdentity = await readIdentity({ configDir, agentName: "beta" }); + + await writeFile( + join(configDir, "peers.json"), + `${JSON.stringify( + { + peers: { + beta: { + did: BETA_DID, + proxyUrl: "https://beta.proxy.example/hooks/agent", + e2ee: { + keyId: betaIdentity.keyId, + x25519PublicKey: betaIdentity.x25519PublicKey, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const first = await alpha.encryptOutbound({ + peerAlias: "beta", + peerDid: BETA_DID, + payload: { message: "first" }, + }); + const firstSession = await readSession({ + configDir, + agentName: "alpha", + peerDid: BETA_DID, + }); + + await writeFile( + join(configDir, "agents", "alpha", "e2ee-identity.json"), + `${JSON.stringify( + { + version: 1, + keyId: "alpha-key-rotated", + x25519PublicKey: alphaIdentity.x25519PublicKey, + x25519SecretKey: alphaIdentity.x25519SecretKey, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const alphaAfterIdentityChange = new ConnectorE2eeManager({ + agentDid: ALPHA_DID, + agentName: "alpha", + configDir, + logger, + }); + await alphaAfterIdentityChange.initialize(); + + const second = await alphaAfterIdentityChange.encryptOutbound({ + peerAlias: "beta", + peerDid: BETA_DID, + payload: { message: "second" }, + }); + const secondSession = await readSession({ + configDir, + agentName: "alpha", + peerDid: BETA_DID, + }); + + expect(first.counter).toBe(0); + expect(second.counter).toBe(0); + expect(firstSession?.sessionId).toBeDefined(); + expect(secondSession?.sessionId).toBeDefined(); + expect(secondSession?.sessionId).not.toBe(firstSession?.sessionId); + expect(secondSession?.localKeyId).toBe("alpha-key-rotated"); + }); +}); diff --git a/packages/connector/src/e2ee.ts b/packages/connector/src/e2ee.ts new file mode 100644 index 0000000..0a17a7b --- /dev/null +++ b/packages/connector/src/e2ee.ts @@ -0,0 +1,911 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { + decodeBase64url, + type EncryptedRelayPayloadV1, + encodeBase64url, + generateUlid, + parseEncryptedRelayPayloadV1, +} from "@clawdentity/protocol"; +import { + AppError, + decodeCanonicalJson, + decryptXChaCha20Poly1305, + deriveX25519SharedSecret, + encodeCanonicalJson, + encodeX25519KeypairBase64url, + encryptXChaCha20Poly1305, + generateX25519Keypair, + hkdfSha256, + type Logger, + sha256, + zeroBytes, +} from "@clawdentity/sdk"; + +const AGENTS_DIR_NAME = "agents"; +const PEERS_FILE_NAME = "peers.json"; +const E2EE_IDENTITY_FILE_NAME = "e2ee-identity.json"; +const E2EE_SESSIONS_FILE_NAME = "e2ee-sessions.json"; +const E2EE_IDENTITY_VERSION = 1; +const E2EE_SESSIONS_VERSION = 1; +const X25519_KEY_BYTES = 32; +const XCHACHA20_NONCE_BYTES = 24; +const SESSION_REKEY_MAX_MESSAGES = 100; +const SESSION_REKEY_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const INFO_ROOT_V1 = "claw/e2ee/root/v1"; +const INFO_CHAIN_AB_V1 = "claw/e2ee/chain/ab/v1"; +const INFO_CHAIN_BA_V1 = "claw/e2ee/chain/ba/v1"; +const INFO_CHAIN_NEXT_V1 = "claw/e2ee/chain/next/v1"; + +type StoredE2eeIdentity = { + version: number; + keyId: string; + x25519PublicKey: string; + x25519SecretKey: string; +}; + +type StoredE2eeSession = { + sessionId: string; + peerDid: string; + peerKeyId: string; + localKeyId: string; + epoch: number; + epochStartedAtMs: number; + sendCounter: number; + recvCounter: number; + rootKey: string; + sendChainKey: string; + recvChainKey: string; +}; + +type StoredE2eeSessionsFile = { + version: number; + sessionsByPeerDid: Record; +}; + +type PeerE2eeBundle = { + did: string; + keyId: string; + x25519PublicKey: string; +}; + +type PeerConfigEntry = { + did?: unknown; + e2ee?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function assertDidEquals( + expectedDid: string, + actualDid: string, + label: string, +): void { + if (expectedDid !== actualDid) { + throw new AppError({ + code: "CONNECTOR_E2EE_PEER_DID_MISMATCH", + message: `${label} does not match configured peer DID`, + status: 400, + expose: true, + }); + } +} + +function parseBase64Key( + value: string, + expectedBytes: number, + code: string, + message: string, +): Uint8Array { + try { + const decoded = decodeBase64url(value); + if (decoded.length !== expectedBytes) { + throw new Error("invalid length"); + } + return decoded; + } catch { + throw new AppError({ + code, + message, + status: 400, + expose: true, + }); + } +} + +function normalizePeerE2eeBundle( + value: unknown, + did: string, +): PeerE2eeBundle | undefined { + if (!isRecord(value)) { + return undefined; + } + + const keyId = + typeof value.keyId === "string" ? value.keyId.trim() : undefined; + const x25519PublicKey = + typeof value.x25519PublicKey === "string" + ? value.x25519PublicKey.trim() + : undefined; + + if (!keyId || !x25519PublicKey) { + return undefined; + } + + try { + if (decodeBase64url(x25519PublicKey).length !== X25519_KEY_BYTES) { + return undefined; + } + } catch { + return undefined; + } + + return { + did, + keyId, + x25519PublicKey, + }; +} + +async function writeJsonAtomic( + targetPath: string, + payload: unknown, +): Promise { + const tmpPath = `${targetPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`; + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + await rename(tmpPath, targetPath); +} + +function toAad(input: { + fromAgentDid: string; + toAgentDid: string; + sessionId: string; + epoch: number; + counter: number; + sentAt: string; +}): Uint8Array { + return new TextEncoder().encode( + [ + input.fromAgentDid, + input.toAgentDid, + input.sessionId, + String(input.epoch), + String(input.counter), + input.sentAt, + ].join("|"), + ); +} + +function toChainInfo( + localDid: string, + peerDid: string, +): { + sendInfo: Uint8Array; + recvInfo: Uint8Array; +} { + const localFirst = localDid.localeCompare(peerDid) < 0; + return { + sendInfo: new TextEncoder().encode( + localFirst ? INFO_CHAIN_AB_V1 : INFO_CHAIN_BA_V1, + ), + recvInfo: new TextEncoder().encode( + localFirst ? INFO_CHAIN_BA_V1 : INFO_CHAIN_AB_V1, + ), + }; +} + +function toNextChainInfo(): Uint8Array { + return new TextEncoder().encode(INFO_CHAIN_NEXT_V1); +} + +function toEpochInfo(epoch: number): Uint8Array { + return new TextEncoder().encode(`claw/e2ee/epoch/v1/${epoch}`); +} + +function toMessageInfo(epoch: number, counter: number): Uint8Array { + return new TextEncoder().encode(`claw/e2ee/msg/v1/${epoch}/${counter}`); +} + +async function deriveDirectionalChains(input: { + rootKey: Uint8Array; + localDid: string; + peerDid: string; +}): Promise<{ sendChainKey: Uint8Array; recvChainKey: Uint8Array }> { + const infos = toChainInfo(input.localDid, input.peerDid); + const sendChainKey = await hkdfSha256({ + ikm: input.rootKey, + salt: zeroBytes(32), + info: infos.sendInfo, + length: 32, + }); + const recvChainKey = await hkdfSha256({ + ikm: input.rootKey, + salt: zeroBytes(32), + info: infos.recvInfo, + length: 32, + }); + return { + sendChainKey, + recvChainKey, + }; +} + +async function deriveInitialRootKey(input: { + localDid: string; + peerDid: string; + localSecretKey: Uint8Array; + peerPublicKey: Uint8Array; +}): Promise { + const sharedSecret = deriveX25519SharedSecret( + input.localSecretKey, + input.peerPublicKey, + ); + const ordered = + input.localDid.localeCompare(input.peerDid) < 0 + ? `${input.localDid}|${input.peerDid}` + : `${input.peerDid}|${input.localDid}`; + const salt = await sha256(new TextEncoder().encode(ordered)); + return hkdfSha256({ + ikm: sharedSecret, + salt, + info: new TextEncoder().encode(INFO_ROOT_V1), + length: 32, + }); +} + +export class ConnectorE2eeManager { + private readonly agentDid: string; + private readonly agentName: string; + private readonly configDir: string; + private readonly logger: Logger; + private readonly nowMs: () => number; + private readonly identityPath: string; + private readonly sessionsPath: string; + private readonly peersPath: string; + private identity?: StoredE2eeIdentity; + private sessionsByPeerDid: Record = {}; + + constructor(input: { + agentDid: string; + agentName: string; + configDir: string; + logger: Logger; + nowMs?: () => number; + }) { + this.agentDid = input.agentDid; + this.agentName = input.agentName; + this.configDir = input.configDir; + this.logger = input.logger; + this.nowMs = input.nowMs ?? Date.now; + this.identityPath = join( + this.configDir, + AGENTS_DIR_NAME, + this.agentName, + E2EE_IDENTITY_FILE_NAME, + ); + this.sessionsPath = join( + this.configDir, + AGENTS_DIR_NAME, + this.agentName, + E2EE_SESSIONS_FILE_NAME, + ); + this.peersPath = join(this.configDir, PEERS_FILE_NAME); + } + + async initialize(): Promise { + this.identity = await this.loadOrCreateIdentity(); + this.sessionsByPeerDid = await this.loadSessions(); + } + + async encryptOutbound(input: { + peerAlias: string; + peerDid: string; + payload: unknown; + }): Promise { + const identity = this.requireIdentity(); + const peer = await this.resolvePeerByAlias(input.peerAlias); + assertDidEquals(input.peerDid, peer.did, "Outbound peer DID"); + + let session = await this.getOrCreateSession({ + peerDid: peer.did, + peerKeyId: peer.keyId, + peerPublicKey: parseBase64Key( + peer.x25519PublicKey, + X25519_KEY_BYTES, + "CONNECTOR_E2EE_PEER_KEY_INVALID", + "Peer E2EE public key is invalid", + ), + }); + + let rekeyPublicKey: string | undefined; + const shouldRekey = + session.sendCounter >= SESSION_REKEY_MAX_MESSAGES || + this.nowMs() - session.epochStartedAtMs >= SESSION_REKEY_MAX_AGE_MS; + if (shouldRekey) { + const outboundRekey = await this.rekeyOutbound({ + session, + peerDid: peer.did, + peerPublicKey: parseBase64Key( + peer.x25519PublicKey, + X25519_KEY_BYTES, + "CONNECTOR_E2EE_PEER_KEY_INVALID", + "Peer E2EE public key is invalid", + ), + }); + session = outboundRekey.session; + rekeyPublicKey = outboundRekey.rekeyPublicKey; + } + + const counter = session.sendCounter; + const sentAt = new Date(this.nowMs()).toISOString(); + const nonce = randomBytes(XCHACHA20_NONCE_BYTES); + const messageKey = await hkdfSha256({ + ikm: parseBase64Key( + session.sendChainKey, + 32, + "CONNECTOR_E2EE_SESSION_INVALID", + "E2EE session state is invalid", + ), + salt: nonce, + info: toMessageInfo(session.epoch, counter), + length: 32, + }); + const aad = toAad({ + fromAgentDid: this.agentDid, + toAgentDid: peer.did, + sessionId: session.sessionId, + epoch: session.epoch, + counter, + sentAt, + }); + const plaintext = encodeCanonicalJson(input.payload); + const ciphertext = encryptXChaCha20Poly1305({ + key: messageKey, + nonce, + plaintext, + aad, + }); + const nextChainKey = await hkdfSha256({ + ikm: parseBase64Key( + session.sendChainKey, + 32, + "CONNECTOR_E2EE_SESSION_INVALID", + "E2EE session state is invalid", + ), + salt: zeroBytes(32), + info: toNextChainInfo(), + length: 32, + }); + session.sendCounter += 1; + session.sendChainKey = encodeBase64url(nextChainKey); + this.sessionsByPeerDid[peer.did] = session; + await this.saveSessions(); + + return { + kind: "claw_e2ee_v1", + alg: "X25519_XCHACHA20POLY1305_HKDF_SHA256", + sessionId: session.sessionId, + epoch: session.epoch, + counter, + nonce: encodeBase64url(nonce), + ciphertext: encodeBase64url(ciphertext), + senderE2eePub: identity.x25519PublicKey, + rekeyPublicKey, + sentAt, + }; + } + + async decryptInbound(input: { + fromAgentDid: string; + toAgentDid: string; + payload: unknown; + }): Promise { + this.requireIdentity(); + assertDidEquals(this.agentDid, input.toAgentDid, "Inbound recipient DID"); + + let envelope: EncryptedRelayPayloadV1; + try { + envelope = parseEncryptedRelayPayloadV1(input.payload); + } catch { + throw new AppError({ + code: "CONNECTOR_E2EE_INVALID_PAYLOAD", + message: "Inbound payload is not a valid E2EE envelope", + status: 400, + expose: true, + }); + } + + const peer = await this.resolvePeerByDid(input.fromAgentDid); + if (peer.x25519PublicKey !== envelope.senderE2eePub) { + throw new AppError({ + code: "CONNECTOR_E2EE_PEER_KEY_MISMATCH", + message: "Inbound sender E2EE key does not match configured peer key", + status: 400, + expose: true, + }); + } + + let session = await this.getOrCreateSession({ + peerDid: peer.did, + peerKeyId: peer.keyId, + peerPublicKey: parseBase64Key( + peer.x25519PublicKey, + X25519_KEY_BYTES, + "CONNECTOR_E2EE_PEER_KEY_INVALID", + "Peer E2EE public key is invalid", + ), + }); + + if (envelope.epoch > session.epoch) { + if (envelope.epoch !== session.epoch + 1 || !envelope.rekeyPublicKey) { + throw new AppError({ + code: "CONNECTOR_E2EE_REKEY_REQUIRED", + message: + "Inbound envelope requires missing or invalid rekey metadata", + status: 400, + expose: true, + }); + } + session = await this.rekeyInbound({ + session, + peerDid: peer.did, + rekeyPublicKey: envelope.rekeyPublicKey, + nextEpoch: envelope.epoch, + }); + } + + if (envelope.epoch < session.epoch) { + throw new AppError({ + code: "CONNECTOR_E2EE_REPLAY_DETECTED", + message: "Inbound envelope epoch is stale", + status: 400, + expose: true, + }); + } + + if (envelope.counter !== session.recvCounter) { + throw new AppError({ + code: "CONNECTOR_E2EE_COUNTER_MISMATCH", + message: "Inbound envelope counter is out of sequence", + status: 400, + expose: true, + }); + } + + const nonce = parseBase64Key( + envelope.nonce, + XCHACHA20_NONCE_BYTES, + "CONNECTOR_E2EE_INVALID_PAYLOAD", + "Inbound envelope nonce is invalid", + ); + let ciphertext: Uint8Array; + try { + ciphertext = decodeBase64url(envelope.ciphertext); + } catch { + throw new AppError({ + code: "CONNECTOR_E2EE_INVALID_PAYLOAD", + message: "Inbound envelope ciphertext is invalid", + status: 400, + expose: true, + }); + } + const recvChainKey = parseBase64Key( + session.recvChainKey, + 32, + "CONNECTOR_E2EE_SESSION_INVALID", + "E2EE session state is invalid", + ); + const messageKey = await hkdfSha256({ + ikm: recvChainKey, + salt: nonce, + info: toMessageInfo(session.epoch, envelope.counter), + length: 32, + }); + const aad = toAad({ + fromAgentDid: input.fromAgentDid, + toAgentDid: input.toAgentDid, + sessionId: envelope.sessionId, + epoch: envelope.epoch, + counter: envelope.counter, + sentAt: envelope.sentAt, + }); + + let plaintext: Uint8Array; + try { + plaintext = decryptXChaCha20Poly1305({ + key: messageKey, + nonce, + ciphertext, + aad, + }); + } catch { + throw new AppError({ + code: "CONNECTOR_E2EE_DECRYPT_FAILED", + message: "Inbound envelope decryption failed", + status: 400, + expose: true, + }); + } + + const nextChainKey = await hkdfSha256({ + ikm: recvChainKey, + salt: zeroBytes(32), + info: toNextChainInfo(), + length: 32, + }); + session.recvCounter += 1; + session.recvChainKey = encodeBase64url(nextChainKey); + this.sessionsByPeerDid[peer.did] = session; + await this.saveSessions(); + + return decodeCanonicalJson(plaintext); + } + + private requireIdentity(): StoredE2eeIdentity { + if (!this.identity) { + throw new Error("E2EE manager is not initialized"); + } + return this.identity; + } + + private async loadOrCreateIdentity(): Promise { + try { + const raw = await readFile(this.identityPath, "utf8"); + const parsed = JSON.parse(raw); + if (!isRecord(parsed)) { + throw new Error("invalid identity"); + } + const keyId = + typeof parsed.keyId === "string" ? parsed.keyId.trim() : undefined; + const x25519PublicKey = + typeof parsed.x25519PublicKey === "string" + ? parsed.x25519PublicKey.trim() + : undefined; + const x25519SecretKey = + typeof parsed.x25519SecretKey === "string" + ? parsed.x25519SecretKey.trim() + : undefined; + if (!keyId || !x25519PublicKey || !x25519SecretKey) { + throw new Error("invalid identity"); + } + parseBase64Key( + x25519PublicKey, + X25519_KEY_BYTES, + "CONNECTOR_E2EE_IDENTITY_INVALID", + "Local E2EE identity is invalid", + ); + parseBase64Key( + x25519SecretKey, + X25519_KEY_BYTES, + "CONNECTOR_E2EE_IDENTITY_INVALID", + "Local E2EE identity is invalid", + ); + + return { + version: + typeof parsed.version === "number" + ? parsed.version + : E2EE_IDENTITY_VERSION, + keyId, + x25519PublicKey, + x25519SecretKey, + }; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== "ENOENT") { + throw error; + } + } + + const generated = generateX25519Keypair(); + const encoded = encodeX25519KeypairBase64url(generated); + const identity: StoredE2eeIdentity = { + version: E2EE_IDENTITY_VERSION, + keyId: generateUlid(this.nowMs()), + x25519PublicKey: encoded.publicKey, + x25519SecretKey: encoded.secretKey, + }; + await writeJsonAtomic(this.identityPath, identity); + this.logger.info("connector.e2ee.identity_created", { + agentDid: this.agentDid, + keyId: identity.keyId, + }); + return identity; + } + + private async loadSessions(): Promise> { + try { + const raw = await readFile(this.sessionsPath, "utf8"); + const parsed = JSON.parse(raw); + if (!isRecord(parsed)) { + return {}; + } + const sessionsByPeerDidRaw = parsed.sessionsByPeerDid; + if (!isRecord(sessionsByPeerDidRaw)) { + return {}; + } + + const normalized: Record = {}; + for (const [peerDid, value] of Object.entries(sessionsByPeerDidRaw)) { + if (!isRecord(value)) { + continue; + } + const candidate = value as Record; + if ( + typeof candidate.sessionId !== "string" || + typeof candidate.peerDid !== "string" || + typeof candidate.peerKeyId !== "string" || + typeof candidate.localKeyId !== "string" || + typeof candidate.epoch !== "number" || + typeof candidate.epochStartedAtMs !== "number" || + typeof candidate.sendCounter !== "number" || + typeof candidate.recvCounter !== "number" || + typeof candidate.rootKey !== "string" || + typeof candidate.sendChainKey !== "string" || + typeof candidate.recvChainKey !== "string" + ) { + continue; + } + normalized[peerDid] = { + sessionId: candidate.sessionId, + peerDid: candidate.peerDid, + peerKeyId: candidate.peerKeyId, + localKeyId: candidate.localKeyId, + epoch: candidate.epoch, + epochStartedAtMs: candidate.epochStartedAtMs, + sendCounter: candidate.sendCounter, + recvCounter: candidate.recvCounter, + rootKey: candidate.rootKey, + sendChainKey: candidate.sendChainKey, + recvChainKey: candidate.recvChainKey, + }; + } + return normalized; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "ENOENT") { + return {}; + } + throw error; + } + } + + private async saveSessions(): Promise { + const payload: StoredE2eeSessionsFile = { + version: E2EE_SESSIONS_VERSION, + sessionsByPeerDid: this.sessionsByPeerDid, + }; + await writeJsonAtomic(this.sessionsPath, payload); + } + + private async resolvePeerByAlias(alias: string): Promise { + const peers = await this.loadPeers(); + const peer = peers.byAlias[alias]; + if (!peer) { + throw new AppError({ + code: "CONNECTOR_E2EE_PEER_NOT_FOUND", + message: `Peer alias "${alias}" is missing an E2EE configuration`, + status: 400, + expose: true, + }); + } + return peer; + } + + private async resolvePeerByDid(did: string): Promise { + const peers = await this.loadPeers(); + const peer = peers.byDid[did]; + if (!peer) { + throw new AppError({ + code: "CONNECTOR_E2EE_PEER_NOT_FOUND", + message: `Peer DID "${did}" is missing an E2EE configuration`, + status: 400, + expose: true, + }); + } + return peer; + } + + private async loadPeers(): Promise<{ + byAlias: Record; + byDid: Record; + }> { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(this.peersPath, "utf8")); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "ENOENT") { + return { byAlias: {}, byDid: {} }; + } + throw error; + } + if (!isRecord(parsed) || !isRecord(parsed.peers)) { + return { byAlias: {}, byDid: {} }; + } + + const byAlias: Record = {}; + const byDid: Record = {}; + for (const [alias, rawPeer] of Object.entries(parsed.peers)) { + if (!isRecord(rawPeer)) { + continue; + } + const peerEntry = rawPeer as PeerConfigEntry; + const did = typeof peerEntry.did === "string" ? peerEntry.did.trim() : ""; + if (!did) { + continue; + } + const bundle = normalizePeerE2eeBundle(peerEntry.e2ee, did); + if (!bundle) { + continue; + } + byAlias[alias] = bundle; + byDid[bundle.did] = bundle; + } + + return { byAlias, byDid }; + } + + private async getOrCreateSession(input: { + peerDid: string; + peerKeyId: string; + peerPublicKey: Uint8Array; + }): Promise { + const identity = this.requireIdentity(); + const existing = this.sessionsByPeerDid[input.peerDid]; + if ( + existing && + existing.peerKeyId === input.peerKeyId && + existing.localKeyId === identity.keyId + ) { + return existing; + } + + const rootKey = await deriveInitialRootKey({ + localDid: this.agentDid, + peerDid: input.peerDid, + localSecretKey: parseBase64Key( + identity.x25519SecretKey, + X25519_KEY_BYTES, + "CONNECTOR_E2EE_IDENTITY_INVALID", + "Local E2EE identity is invalid", + ), + peerPublicKey: input.peerPublicKey, + }); + const chains = await deriveDirectionalChains({ + rootKey, + localDid: this.agentDid, + peerDid: input.peerDid, + }); + const created: StoredE2eeSession = { + sessionId: generateUlid(this.nowMs()), + peerDid: input.peerDid, + peerKeyId: input.peerKeyId, + localKeyId: identity.keyId, + epoch: 1, + epochStartedAtMs: this.nowMs(), + sendCounter: 0, + recvCounter: 0, + rootKey: encodeBase64url(rootKey), + sendChainKey: encodeBase64url(chains.sendChainKey), + recvChainKey: encodeBase64url(chains.recvChainKey), + }; + + if (existing) { + this.logger.info("connector.e2ee.session_recreated", { + peerDid: input.peerDid, + previousSessionId: existing.sessionId, + previousPeerKeyId: existing.peerKeyId, + previousLocalKeyId: existing.localKeyId, + nextPeerKeyId: input.peerKeyId, + nextLocalKeyId: identity.keyId, + }); + } + + this.sessionsByPeerDid[input.peerDid] = created; + await this.saveSessions(); + return created; + } + + private async rekeyOutbound(input: { + session: StoredE2eeSession; + peerDid: string; + peerPublicKey: Uint8Array; + }): Promise<{ session: StoredE2eeSession; rekeyPublicKey: string }> { + const ephemeral = generateX25519Keypair(); + const epochSecret = deriveX25519SharedSecret( + ephemeral.secretKey, + input.peerPublicKey, + ); + const nextEpoch = input.session.epoch + 1; + const newRootKey = await hkdfSha256({ + ikm: epochSecret, + salt: parseBase64Key( + input.session.rootKey, + 32, + "CONNECTOR_E2EE_SESSION_INVALID", + "E2EE session state is invalid", + ), + info: toEpochInfo(nextEpoch), + length: 32, + }); + const chains = await deriveDirectionalChains({ + rootKey: newRootKey, + localDid: this.agentDid, + peerDid: input.peerDid, + }); + + const nextSession: StoredE2eeSession = { + ...input.session, + epoch: nextEpoch, + epochStartedAtMs: this.nowMs(), + sendCounter: 0, + recvCounter: 0, + rootKey: encodeBase64url(newRootKey), + sendChainKey: encodeBase64url(chains.sendChainKey), + recvChainKey: encodeBase64url(chains.recvChainKey), + }; + this.sessionsByPeerDid[input.peerDid] = nextSession; + await this.saveSessions(); + return { + session: nextSession, + rekeyPublicKey: encodeBase64url(ephemeral.publicKey), + }; + } + + private async rekeyInbound(input: { + session: StoredE2eeSession; + peerDid: string; + rekeyPublicKey: string; + nextEpoch: number; + }): Promise { + const identity = this.requireIdentity(); + const epochSecret = deriveX25519SharedSecret( + parseBase64Key( + identity.x25519SecretKey, + X25519_KEY_BYTES, + "CONNECTOR_E2EE_IDENTITY_INVALID", + "Local E2EE identity is invalid", + ), + parseBase64Key( + input.rekeyPublicKey, + X25519_KEY_BYTES, + "CONNECTOR_E2EE_REKEY_REQUIRED", + "Inbound rekey public key is invalid", + ), + ); + const newRootKey = await hkdfSha256({ + ikm: epochSecret, + salt: parseBase64Key( + input.session.rootKey, + 32, + "CONNECTOR_E2EE_SESSION_INVALID", + "E2EE session state is invalid", + ), + info: toEpochInfo(input.nextEpoch), + length: 32, + }); + const chains = await deriveDirectionalChains({ + rootKey: newRootKey, + localDid: this.agentDid, + peerDid: input.peerDid, + }); + const nextSession: StoredE2eeSession = { + ...input.session, + epoch: input.nextEpoch, + epochStartedAtMs: this.nowMs(), + sendCounter: 0, + recvCounter: 0, + rootKey: encodeBase64url(newRootKey), + sendChainKey: encodeBase64url(chains.sendChainKey), + recvChainKey: encodeBase64url(chains.recvChainKey), + }; + this.sessionsByPeerDid[input.peerDid] = nextSession; + await this.saveSessions(); + return nextSession; + } +} diff --git a/packages/connector/src/index.ts b/packages/connector/src/index.ts index 306b5ac..7e731e4 100644 --- a/packages/connector/src/index.ts +++ b/packages/connector/src/index.ts @@ -30,7 +30,7 @@ export { DEFAULT_RELAY_DELIVER_TIMEOUT_MS, WS_READY_STATE_OPEN, } from "./constants.js"; - +export { ConnectorE2eeManager } from "./e2ee.js"; export type { ConnectorFrame, ConnectorFrameParseErrorCode, diff --git a/packages/connector/src/runtime.ts b/packages/connector/src/runtime.ts index 560586b..b5edfa8 100644 --- a/packages/connector/src/runtime.ts +++ b/packages/connector/src/runtime.ts @@ -9,6 +9,7 @@ import { dirname, join } from "node:path"; import { decodeBase64url, encodeBase64url, + parseEncryptedRelayPayloadV1, RELAY_CONNECT_PATH, RELAY_RECIPIENT_AGENT_DID_HEADER, } from "@clawdentity/protocol"; @@ -39,6 +40,7 @@ import { DEFAULT_OPENCLAW_DELIVER_TIMEOUT_MS, DEFAULT_OPENCLAW_HOOK_PATH, } from "./constants.js"; +import { ConnectorE2eeManager } from "./e2ee.js"; import { type ConnectorInboundInboxSnapshot, createConnectorInboundInbox, @@ -731,6 +733,13 @@ export async function startConnectorRuntime( const inboundReplayStatus: InboundReplayStatus = { replayerActive: false, }; + const e2eeManager = new ConnectorE2eeManager({ + agentDid: input.credentials.agentDid, + agentName: input.agentName, + configDir: input.configDir, + logger, + }); + await e2eeManager.initialize(); let runtimeStopping = false; let replayInFlight = false; let replayIntervalHandle: ReturnType | undefined; @@ -761,12 +770,17 @@ export async function startConnectorRuntime( for (const pending of dueItems) { inboundReplayStatus.lastAttemptAt = new Date().toISOString(); try { + const decryptedPayload = await e2eeManager.decryptInbound({ + fromAgentDid: pending.fromAgentDid, + toAgentDid: pending.toAgentDid, + payload: pending.payload, + }); await deliverToOpenclawHook({ fetchImpl, openclawHookUrl, openclawHookToken, requestId: pending.requestId, - payload: pending.payload, + payload: decryptedPayload, }); await inboundInbox.markDelivered(pending.requestId); inboundReplayStatus.lastReplayAt = new Date().toISOString(); @@ -836,6 +850,18 @@ export async function startConnectorRuntime( fetchImpl, logger, inboundDeliverHandler: async (frame) => { + try { + parseEncryptedRelayPayloadV1(frame.payload); + } catch { + logger.warn("connector.inbound.invalid_e2ee_payload", { + requestId: frame.id, + }); + return { + accepted: false, + reason: "inbound payload is not a valid E2EE envelope", + }; + } + const persisted = await inboundInbox.enqueue(frame); if (!persisted.accepted) { logger.warn("connector.inbound.persist_rejected", { @@ -868,7 +894,12 @@ export async function startConnectorRuntime( const relayToPeer = async (request: OutboundRelayRequest): Promise => { await syncAuthFromDisk(); const peerUrl = new URL(request.peerProxyUrl); - const body = JSON.stringify(request.payload ?? {}); + const encryptedPayload = await e2eeManager.encryptOutbound({ + peerAlias: request.peer, + peerDid: request.peerDid, + payload: request.payload ?? {}, + }); + const body = JSON.stringify(encryptedPayload); const refreshKey = `${REFRESH_SINGLE_FLIGHT_PREFIX}:${input.configDir}:${input.agentName}`; const performRelay = async (auth: AgentAuthBundle): Promise => { diff --git a/packages/connector/vitest.config.ts b/packages/connector/vitest.config.ts index e2ec332..5255a2e 100644 --- a/packages/connector/vitest.config.ts +++ b/packages/connector/vitest.config.ts @@ -1,6 +1,17 @@ +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@clawdentity/protocol": fileURLToPath( + new URL("../protocol/src/index.ts", import.meta.url), + ), + "@clawdentity/sdk": fileURLToPath( + new URL("../sdk/src/index.ts", import.meta.url), + ), + }, + }, test: { globals: true, }, diff --git a/packages/protocol/AGENTS.md b/packages/protocol/AGENTS.md index b10ca06..1a2823d 100644 --- a/packages/protocol/AGENTS.md +++ b/packages/protocol/AGENTS.md @@ -24,9 +24,11 @@ - Keep relay contract constants in protocol exports (`RELAY_CONNECT_PATH`, `RELAY_RECIPIENT_AGENT_DID_HEADER`) so connector and hook routing stay synchronized across apps. - Keep registration-proof canonicalization in protocol exports (`canonicalizeAgentRegistrationProof`) so CLI signing and registry verification use an identical message format. - Keep optional proof fields deterministic in canonical strings (empty-string placeholders) to avoid default-value mismatches between clients and server. +- Keep E2EE envelope parsing strict in `e2ee.ts`: require known `kind`/`alg`, validate nonce/public-key byte lengths from base64url, and reject unknown fields with `INVALID_E2EE_PAYLOAD`. ## Testing - Add focused Vitest tests per helper module and one root export test in `src/index.test.ts`. - Roundtrip tests must cover empty inputs, known vectors, and invalid inputs for parse failures. - Error tests must assert `ProtocolParseError` code values, not just message strings. - CRL helpers specifically need coverage for valid payloads, missing or empty revocation entries, invalid `agentDid`/`jti` values, and `exp <= iat`, all verifying the `INVALID_CRL_CLAIMS` code. +- E2EE helper tests must cover valid envelope parsing plus explicit failures for malformed base64url, wrong nonce/public-key lengths, and invalid timestamps. diff --git a/packages/protocol/src/e2ee.test.ts b/packages/protocol/src/e2ee.test.ts new file mode 100644 index 0000000..12ae759 --- /dev/null +++ b/packages/protocol/src/e2ee.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { encodeBase64url } from "./base64url.js"; +import { parseEncryptedRelayPayloadV1 } from "./e2ee.js"; +import { ProtocolParseError } from "./errors.js"; + +function makeFixedBytes(length: number, offset = 1): Uint8Array { + return Uint8Array.from({ length }, (_value, index) => (index + offset) % 256); +} + +function makeValidPayload() { + return { + kind: "claw_e2ee_v1" as const, + alg: "X25519_XCHACHA20POLY1305_HKDF_SHA256" as const, + sessionId: "sess_01JABCDE1234567890", + epoch: 1, + counter: 0, + nonce: encodeBase64url(makeFixedBytes(24)), + ciphertext: encodeBase64url(makeFixedBytes(48, 3)), + senderE2eePub: encodeBase64url(makeFixedBytes(32, 9)), + rekeyPublicKey: undefined as string | undefined, + sentAt: "2026-02-20T01:00:00.000Z", + }; +} + +describe("E2EE relay payload schema", () => { + it("accepts valid encrypted relay payloads", () => { + const parsed = parseEncryptedRelayPayloadV1(makeValidPayload()); + expect(parsed.kind).toBe("claw_e2ee_v1"); + expect(parsed.alg).toBe("X25519_XCHACHA20POLY1305_HKDF_SHA256"); + }); + + it("rejects invalid nonce lengths", () => { + const payload = makeValidPayload(); + payload.nonce = encodeBase64url(makeFixedBytes(12)); + + expect(() => parseEncryptedRelayPayloadV1(payload)).toThrow( + ProtocolParseError, + ); + }); + + it("rejects invalid sender key lengths", () => { + const payload = makeValidPayload(); + payload.senderE2eePub = encodeBase64url(makeFixedBytes(31)); + + expect(() => parseEncryptedRelayPayloadV1(payload)).toThrow( + ProtocolParseError, + ); + }); + + it("rejects invalid rekey key lengths", () => { + const payload = makeValidPayload(); + payload.rekeyPublicKey = encodeBase64url(makeFixedBytes(31)); + + expect(() => parseEncryptedRelayPayloadV1(payload)).toThrow( + ProtocolParseError, + ); + }); + + it("rejects invalid sentAt timestamps", () => { + const payload = makeValidPayload(); + payload.sentAt = "not-an-iso-time"; + + expect(() => parseEncryptedRelayPayloadV1(payload)).toThrow( + ProtocolParseError, + ); + }); + + it("rejects unknown fields", () => { + const payload = { + ...makeValidPayload(), + unexpected: true, + }; + + expect(() => parseEncryptedRelayPayloadV1(payload)).toThrow( + ProtocolParseError, + ); + }); +}); diff --git a/packages/protocol/src/e2ee.ts b/packages/protocol/src/e2ee.ts new file mode 100644 index 0000000..6f977c3 --- /dev/null +++ b/packages/protocol/src/e2ee.ts @@ -0,0 +1,104 @@ +import { z } from "zod"; +import { decodeBase64url } from "./base64url.js"; +import { ProtocolParseError } from "./errors.js"; + +const ISO_TIMESTAMP_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; +const X25519_PUBLIC_KEY_BYTES = 32; +const XCHACHA20_NONCE_BYTES = 24; +const INVALID_E2EE_PAYLOAD = "INVALID_E2EE_PAYLOAD" as const; + +const base64urlStringSchema = z.string().min(1); +const isoTimestampSchema = z.string().superRefine((value, ctx) => { + if (!ISO_TIMESTAMP_PATTERN.test(value) || Number.isNaN(Date.parse(value))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "sentAt must be a valid ISO-8601 timestamp", + }); + } +}); + +function validateBase64urlLength( + input: string, + expectedBytes: number, + label: string, + ctx: z.RefinementCtx, +): void { + try { + const decoded = decodeBase64url(input); + if (decoded.length !== expectedBytes) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} must decode to ${expectedBytes} bytes`, + }); + } + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} must be valid base64url`, + }); + } +} + +export const encryptedRelayPayloadV1Schema = z + .object({ + kind: z.literal("claw_e2ee_v1"), + alg: z.literal("X25519_XCHACHA20POLY1305_HKDF_SHA256"), + sessionId: z.string().min(1, "sessionId is required"), + epoch: z.number().int().nonnegative(), + counter: z.number().int().nonnegative(), + nonce: base64urlStringSchema, + ciphertext: base64urlStringSchema, + senderE2eePub: base64urlStringSchema, + rekeyPublicKey: base64urlStringSchema.optional(), + sentAt: isoTimestampSchema, + }) + .strict() + .superRefine((payload, ctx) => { + validateBase64urlLength(payload.nonce, XCHACHA20_NONCE_BYTES, "nonce", ctx); + validateBase64urlLength( + payload.senderE2eePub, + X25519_PUBLIC_KEY_BYTES, + "senderE2eePub", + ctx, + ); + + if (payload.rekeyPublicKey !== undefined) { + validateBase64urlLength( + payload.rekeyPublicKey, + X25519_PUBLIC_KEY_BYTES, + "rekeyPublicKey", + ctx, + ); + } + + try { + decodeBase64url(payload.ciphertext); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "ciphertext must be valid base64url", + }); + } + }); + +export type EncryptedRelayPayloadV1 = z.infer< + typeof encryptedRelayPayloadV1Schema +>; + +export function parseEncryptedRelayPayloadV1( + input: unknown, +): EncryptedRelayPayloadV1 { + const parsed = encryptedRelayPayloadV1Schema.safeParse(input); + if (!parsed.success) { + const message = parsed.error.issues + .map((issue) => issue.message) + .join("; "); + throw new ProtocolParseError( + INVALID_E2EE_PAYLOAD, + message.length > 0 ? message : "Invalid E2EE payload", + ); + } + + return parsed.data; +} diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 2f8f8c3..41cc8db 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -3,7 +3,8 @@ export type ProtocolParseErrorCode = | "INVALID_BASE64URL" | "INVALID_ULID" | "INVALID_DID" - | "INVALID_CRL_CLAIMS"; + | "INVALID_CRL_CLAIMS" + | "INVALID_E2EE_PAYLOAD"; export class ProtocolParseError extends Error { readonly code: ProtocolParseErrorCode; diff --git a/packages/protocol/src/index.test.ts b/packages/protocol/src/index.test.ts index 1432303..4964536 100644 --- a/packages/protocol/src/index.test.ts +++ b/packages/protocol/src/index.test.ts @@ -15,6 +15,7 @@ import { crlClaimsSchema, decodeBase64url, encodeBase64url, + encryptedRelayPayloadV1Schema, generateUlid, INTERNAL_IDENTITY_AGENT_OWNERSHIP_PATH, INVITES_PATH, @@ -29,6 +30,7 @@ import { parseAitClaims, parseCrlClaims, parseDid, + parseEncryptedRelayPayloadV1, parseUlid, REGISTRY_METADATA_PATH, RELAY_CONNECT_PATH, @@ -184,4 +186,25 @@ describe("protocol", () => { expect(parsed.revocations[0].agentDid).toBe(agentDid); expect(crlClaimsSchema).toBeDefined(); }); + + it("exports E2EE payload helpers from package root", () => { + const parsed = parseEncryptedRelayPayloadV1({ + kind: "claw_e2ee_v1", + alg: "X25519_XCHACHA20POLY1305_HKDF_SHA256", + sessionId: "sess_01JABCDE1234567890", + epoch: 1, + counter: 0, + nonce: encodeBase64url(Uint8Array.from({ length: 24 }, (_, i) => i + 1)), + ciphertext: encodeBase64url( + Uint8Array.from({ length: 32 }, (_, i) => i + 2), + ), + senderE2eePub: encodeBase64url( + Uint8Array.from({ length: 32 }, (_, i) => i + 3), + ), + sentAt: "2026-02-20T01:00:00.000Z", + }); + + expect(parsed.kind).toBe("claw_e2ee_v1"); + expect(encryptedRelayPayloadV1Schema).toBeDefined(); + }); }); diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 6e16e42..c32b511 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -20,6 +20,11 @@ export type { CrlClaims } from "./crl.js"; export { crlClaimsSchema, parseCrlClaims } from "./crl.js"; export type { ClawDidKind } from "./did.js"; export { makeAgentDid, makeHumanDid, parseDid } from "./did.js"; +export type { EncryptedRelayPayloadV1 } from "./e2ee.js"; +export { + encryptedRelayPayloadV1Schema, + parseEncryptedRelayPayloadV1, +} from "./e2ee.js"; export { ADMIN_BOOTSTRAP_PATH, ADMIN_INTERNAL_SERVICES_PATH, diff --git a/packages/sdk/AGENTS.md b/packages/sdk/AGENTS.md index 7bfe91e..167fbd4 100644 --- a/packages/sdk/AGENTS.md +++ b/packages/sdk/AGENTS.md @@ -11,6 +11,7 @@ - `config`: schema-validated runtime config parsing. - `request-context`: request ID extraction/generation and propagation. - `crypto/ed25519`: byte-first keypair/sign/verify helpers for PoP and token workflows. +- `crypto/x25519` + `crypto/e2ee` + `crypto/hkdf`: X25519 key exchange, XChaCha20-Poly1305 envelope crypto, and HKDF/HMAC primitives for E2EE session chains. - `jwt/ait-jwt`: AIT JWS signing, verification, and header-only inspection via `decodeAIT`; both helpers reuse the same protected-header guard so alg/typ/kid invariants stay aligned even when skipping signature validation. - `jwt/crl-jwt`: CRL JWT helpers with EdDSA signing, header consistency checks, and tamper-detection test coverage. - `crl/cache`: in-memory CRL cache with periodic refresh, staleness reporting, and configurable stale behavior. @@ -27,6 +28,7 @@ - Avoid leaking secrets in logs and error payloads. - Keep all parse/validation errors explicit and deterministic. - Keep cryptography APIs byte-first (`Uint8Array`) and runtime-portable. +- Keep WebCrypto wrappers (`hkdf.ts`) runtime-portable without DOM-only type dependencies so Node-only packages can typecheck cleanly. - Derive Ed25519 public keys via `deriveEd25519PublicKey` (instead of ad-hoc noble calls) so key derivation behavior and validation stay centralized. - Reuse protocol base64url helpers as the single source of truth; do not duplicate encoding logic in SDK. - Keep CRL claim schema authority in `@clawdentity/protocol` (`crl.ts`); SDK JWT helpers should avoid duplicating claim-validation rules. @@ -57,6 +59,7 @@ - Validate error codes/envelopes and request ID behavior. - Keep tests deterministic and offline. - Crypto tests must include explicit negative verification cases (wrong message/signature/key). +- E2EE crypto tests must cover encrypt/decrypt roundtrips, tamper failures, and key-length validation errors. - JWT tests must include sign/verify happy path and failure paths for issuer mismatch and missing/unknown `kid`. - HTTP signing tests must include sign/verify happy path and explicit failures when method, path, body, or timestamp are altered. - Nonce cache tests must include duplicate nonce rejection within TTL and acceptance after TTL expiry. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 0764d1a..adc7b42 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -24,6 +24,8 @@ }, "dependencies": { "@clawdentity/protocol": "workspace:*", + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", "@noble/ed25519": "^3.0.0", "hono": "^4.11.9", "jose": "^6.1.3", diff --git a/packages/sdk/src/crypto/e2ee.test.ts b/packages/sdk/src/crypto/e2ee.test.ts new file mode 100644 index 0000000..615cb8d --- /dev/null +++ b/packages/sdk/src/crypto/e2ee.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + decodeCanonicalJson, + decryptXChaCha20Poly1305, + encodeCanonicalJson, + encryptXChaCha20Poly1305, +} from "./e2ee.js"; + +function bytes(length: number, start = 1): Uint8Array { + return Uint8Array.from({ length }, (_value, index) => (index + start) % 256); +} + +describe("e2ee crypto helpers", () => { + it("encrypts and decrypts payloads with AAD", () => { + const key = bytes(32, 4); + const nonce = bytes(24, 9); + const payload = { message: "hello", n: 1 }; + const aad = new TextEncoder().encode("did:claw:agent:alice|bob"); + const plaintext = encodeCanonicalJson(payload); + const ciphertext = encryptXChaCha20Poly1305({ + key, + nonce, + plaintext, + aad, + }); + const decrypted = decryptXChaCha20Poly1305({ + key, + nonce, + ciphertext, + aad, + }); + expect(decodeCanonicalJson(decrypted)).toEqual(payload); + }); + + it("fails decryption when AAD differs", () => { + const key = bytes(32, 4); + const nonce = bytes(24, 9); + const payload = { message: "hello", n: 1 }; + const ciphertext = encryptXChaCha20Poly1305({ + key, + nonce, + plaintext: encodeCanonicalJson(payload), + aad: new TextEncoder().encode("aad-one"), + }); + + expect(() => + decryptXChaCha20Poly1305({ + key, + nonce, + ciphertext, + aad: new TextEncoder().encode("aad-two"), + }), + ).toThrowError(); + }); +}); diff --git a/packages/sdk/src/crypto/e2ee.ts b/packages/sdk/src/crypto/e2ee.ts new file mode 100644 index 0000000..006e1d7 --- /dev/null +++ b/packages/sdk/src/crypto/e2ee.ts @@ -0,0 +1,44 @@ +import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; + +const XCHACHA20POLY1305_KEY_BYTES = 32; +const XCHACHA20POLY1305_NONCE_BYTES = 24; + +function assertLength(value: Uint8Array, length: number, label: string): void { + if (value.length !== length) { + throw new TypeError(`${label} must be ${length} bytes`); + } +} + +export function encodeCanonicalJson(value: unknown): Uint8Array { + return new TextEncoder().encode(JSON.stringify(value)); +} + +export function decodeCanonicalJson(value: Uint8Array): T { + return JSON.parse(new TextDecoder().decode(value)) as T; +} + +export function encryptXChaCha20Poly1305(input: { + key: Uint8Array; + nonce: Uint8Array; + plaintext: Uint8Array; + aad?: Uint8Array; +}): Uint8Array { + assertLength(input.key, XCHACHA20POLY1305_KEY_BYTES, "key"); + assertLength(input.nonce, XCHACHA20POLY1305_NONCE_BYTES, "nonce"); + return xchacha20poly1305(input.key, input.nonce, input.aad).encrypt( + input.plaintext, + ); +} + +export function decryptXChaCha20Poly1305(input: { + key: Uint8Array; + nonce: Uint8Array; + ciphertext: Uint8Array; + aad?: Uint8Array; +}): Uint8Array { + assertLength(input.key, XCHACHA20POLY1305_KEY_BYTES, "key"); + assertLength(input.nonce, XCHACHA20POLY1305_NONCE_BYTES, "nonce"); + return xchacha20poly1305(input.key, input.nonce, input.aad).decrypt( + input.ciphertext, + ); +} diff --git a/packages/sdk/src/crypto/hkdf.test.ts b/packages/sdk/src/crypto/hkdf.test.ts new file mode 100644 index 0000000..929d3e8 --- /dev/null +++ b/packages/sdk/src/crypto/hkdf.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { hkdfSha256, sha256, zeroBytes } from "./hkdf.js"; + +function bytes(length: number, start = 1): Uint8Array { + return Uint8Array.from({ length }, (_value, index) => (index + start) % 256); +} + +describe("hkdf helpers", () => { + it("derives deterministic output for the same input", async () => { + const ikm = bytes(32, 7); + const salt = bytes(32, 19); + const info = new TextEncoder().encode("claw/e2ee/test"); + const a = await hkdfSha256({ ikm, salt, info, length: 32 }); + const b = await hkdfSha256({ ikm, salt, info, length: 32 }); + expect(Array.from(a)).toEqual(Array.from(b)); + expect(a).toHaveLength(32); + }); + + it("produces different outputs with different info", async () => { + const ikm = bytes(32, 7); + const salt = bytes(32, 19); + const a = await hkdfSha256({ + ikm, + salt, + info: new TextEncoder().encode("info-a"), + length: 32, + }); + const b = await hkdfSha256({ + ikm, + salt, + info: new TextEncoder().encode("info-b"), + length: 32, + }); + expect(Array.from(a)).not.toEqual(Array.from(b)); + }); + + it("computes sha256 digests", async () => { + const input = new TextEncoder().encode("clawdentity"); + const digest = await sha256(input); + expect(digest).toHaveLength(32); + }); + + it("creates zero-filled byte arrays", () => { + expect(Array.from(zeroBytes(4))).toEqual([0, 0, 0, 0]); + }); +}); diff --git a/packages/sdk/src/crypto/hkdf.ts b/packages/sdk/src/crypto/hkdf.ts new file mode 100644 index 0000000..8cc53ee --- /dev/null +++ b/packages/sdk/src/crypto/hkdf.ts @@ -0,0 +1,98 @@ +const SHA_256_BYTES = 32; + +type SubtleCryptoLike = { + digest: (algorithm: string, data: Uint8Array) => Promise; + importKey: ( + format: string, + keyData: Uint8Array, + algorithm: unknown, + extractable: boolean, + keyUsages: string[], + ) => Promise; + deriveBits: ( + algorithm: unknown, + baseKey: unknown, + length: number, + ) => Promise; + sign: ( + algorithm: unknown, + key: unknown, + data: Uint8Array, + ) => Promise; +}; + +function requireCryptoSubtle(): SubtleCryptoLike { + if ( + typeof crypto === "undefined" || + typeof crypto.subtle === "undefined" || + crypto.subtle === null + ) { + throw new Error("WebCrypto SubtleCrypto is unavailable"); + } + + return crypto.subtle as unknown as SubtleCryptoLike; +} + +export async function sha256(input: Uint8Array): Promise { + const subtle = requireCryptoSubtle(); + const digest = await subtle.digest("SHA-256", input); + return new Uint8Array(digest); +} + +export function zeroBytes(length: number): Uint8Array { + if (!Number.isInteger(length) || length < 0) { + throw new TypeError("length must be a non-negative integer"); + } + return new Uint8Array(length); +} + +export async function hkdfSha256(input: { + ikm: Uint8Array; + salt: Uint8Array; + info?: Uint8Array; + length: number; +}): Promise { + if (!Number.isInteger(input.length) || input.length <= 0) { + throw new TypeError("length must be a positive integer"); + } + + const subtle = requireCryptoSubtle(); + const baseKey = await subtle.importKey("raw", input.ikm, "HKDF", false, [ + "deriveBits", + ]); + const bits = await subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: input.salt, + info: input.info ?? new Uint8Array(0), + }, + baseKey, + input.length * 8, + ); + + return new Uint8Array(bits); +} + +export async function hmacSha256(input: { + key: Uint8Array; + data: Uint8Array; +}): Promise { + if (input.key.length !== SHA_256_BYTES) { + throw new TypeError(`key must be ${SHA_256_BYTES} bytes`); + } + + const subtle = requireCryptoSubtle(); + const key = await subtle.importKey( + "raw", + input.key, + { + name: "HMAC", + hash: "SHA-256", + }, + false, + ["sign"], + ); + const signature = await subtle.sign("HMAC", key, input.data); + return new Uint8Array(signature); +} diff --git a/packages/sdk/src/crypto/x25519.test.ts b/packages/sdk/src/crypto/x25519.test.ts new file mode 100644 index 0000000..2aaa815 --- /dev/null +++ b/packages/sdk/src/crypto/x25519.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + decodeX25519KeypairBase64url, + deriveX25519PublicKey, + deriveX25519SharedSecret, + encodeX25519KeypairBase64url, + generateX25519Keypair, +} from "./x25519.js"; + +describe("x25519 crypto helpers", () => { + it("generates keypairs with expected key lengths", () => { + const keypair = generateX25519Keypair(); + expect(keypair.publicKey).toHaveLength(32); + expect(keypair.secretKey).toHaveLength(32); + }); + + it("derives matching shared secrets for both peers", () => { + const alice = generateX25519Keypair(); + const bob = generateX25519Keypair(); + const aliceShared = deriveX25519SharedSecret( + alice.secretKey, + bob.publicKey, + ); + const bobShared = deriveX25519SharedSecret(bob.secretKey, alice.publicKey); + expect(Array.from(aliceShared)).toEqual(Array.from(bobShared)); + }); + + it("derives public key from secret key", () => { + const keypair = generateX25519Keypair(); + const derived = deriveX25519PublicKey(keypair.secretKey); + expect(Array.from(derived)).toEqual(Array.from(keypair.publicKey)); + }); + + it("roundtrips keypairs through base64url wrappers", () => { + const keypair = generateX25519Keypair(); + const encoded = encodeX25519KeypairBase64url(keypair); + const decoded = decodeX25519KeypairBase64url(encoded); + expect(Array.from(decoded.publicKey)).toEqual( + Array.from(keypair.publicKey), + ); + expect(Array.from(decoded.secretKey)).toEqual( + Array.from(keypair.secretKey), + ); + }); +}); diff --git a/packages/sdk/src/crypto/x25519.ts b/packages/sdk/src/crypto/x25519.ts new file mode 100644 index 0000000..b0145e4 --- /dev/null +++ b/packages/sdk/src/crypto/x25519.ts @@ -0,0 +1,60 @@ +import { decodeBase64url, encodeBase64url } from "@clawdentity/protocol"; +import { x25519 } from "@noble/curves/ed25519.js"; + +const X25519_KEY_BYTES = 32; + +export type X25519KeypairBytes = { + publicKey: Uint8Array; + secretKey: Uint8Array; +}; + +export type X25519KeypairBase64url = { + publicKey: string; + secretKey: string; +}; + +function assertX25519KeyLength(key: Uint8Array, label: string): void { + if (key.length !== X25519_KEY_BYTES) { + throw new TypeError(`${label} must be ${X25519_KEY_BYTES} bytes`); + } +} + +export function generateX25519Keypair(): X25519KeypairBytes { + const keypair = x25519.keygen(); + return { + publicKey: keypair.publicKey, + secretKey: keypair.secretKey, + }; +} + +export function deriveX25519PublicKey(secretKey: Uint8Array): Uint8Array { + assertX25519KeyLength(secretKey, "secretKey"); + return x25519.getPublicKey(secretKey); +} + +export function deriveX25519SharedSecret( + secretKey: Uint8Array, + peerPublicKey: Uint8Array, +): Uint8Array { + assertX25519KeyLength(secretKey, "secretKey"); + assertX25519KeyLength(peerPublicKey, "peerPublicKey"); + return x25519.getSharedSecret(secretKey, peerPublicKey); +} + +export function encodeX25519KeypairBase64url( + keypair: X25519KeypairBytes, +): X25519KeypairBase64url { + return { + publicKey: encodeBase64url(keypair.publicKey), + secretKey: encodeBase64url(keypair.secretKey), + }; +} + +export function decodeX25519KeypairBase64url( + keypair: X25519KeypairBase64url, +): X25519KeypairBytes { + return { + publicKey: decodeBase64url(keypair.publicKey), + secretKey: decodeBase64url(keypair.secretKey), + }; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6bc6e01..b2c58ee 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -20,6 +20,12 @@ export { DEFAULT_CRL_MAX_AGE_MS, DEFAULT_CRL_REFRESH_INTERVAL_MS, } from "./crl/cache.js"; +export { + decodeCanonicalJson, + decryptXChaCha20Poly1305, + encodeCanonicalJson, + encryptXChaCha20Poly1305, +} from "./crypto/e2ee.js"; export type { Ed25519KeypairBase64url, Ed25519KeypairBytes, @@ -34,6 +40,18 @@ export { signEd25519, verifyEd25519, } from "./crypto/ed25519.js"; +export { hkdfSha256, hmacSha256, sha256, zeroBytes } from "./crypto/hkdf.js"; +export type { + X25519KeypairBase64url, + X25519KeypairBytes, +} from "./crypto/x25519.js"; +export { + decodeX25519KeypairBase64url, + deriveX25519PublicKey, + deriveX25519SharedSecret, + encodeX25519KeypairBase64url, + generateX25519Keypair, +} from "./crypto/x25519.js"; export { addSeconds, isExpired, nowIso } from "./datetime.js"; export type { EventBus, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2f1bde..d9d41b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,12 @@ importers: '@clawdentity/protocol': specifier: workspace:* version: link:../protocol + '@noble/ciphers': + specifier: ^2.1.1 + version: 2.1.1 + '@noble/curves': + specifier: ^2.0.1 + version: 2.0.1 '@noble/ed25519': specifier: ^3.0.0 version: 3.0.0 @@ -942,9 +948,21 @@ packages: '@napi-rs/wasm-runtime@0.2.4': resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + '@noble/ed25519@3.0.0': resolution: {integrity: sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==} + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nx/nx-darwin-arm64@22.5.0': resolution: {integrity: sha512-MHnzv6tzucvLsh4oS9FTepj+ct/o8/DPXrQow+9Jid7GSgY59xrDX/8CleJOrwL5lqKEyGW7vv8TR+4wGtEWTA==} cpu: [arm64] @@ -2950,8 +2968,16 @@ snapshots: '@emnapi/runtime': 1.8.1 '@tybys/wasm-util': 0.9.0 + '@noble/ciphers@2.1.1': {} + + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@noble/ed25519@3.0.0': {} + '@noble/hashes@2.0.1': {} + '@nx/nx-darwin-arm64@22.5.0': optional: true