From 0629aafc0d5237ef84bcfb2b86a3b91c053da268 Mon Sep 17 00:00:00 2001 From: operagxoksana Date: Wed, 12 Aug 2026 11:42:00 +0000 Subject: [PATCH] fix: respect entity and signing algorithm in sign endpoint The sign endpoint always used the agent identity regardless of the requested entity, causing /controller/sign to issue tokens with the agent DID. Additionally, the signing algorithm was passed to createJwt in the wrong argument position. The wrapper uses the third argument for the JWT header, so controller tokens were emitted with an ES256K header even though the controller identity uses Ed25519. Fix both issues and add regression coverage verifying both issuer identity and cryptographic signature. --- examples/local-did-host/package.json | 3 + examples/local-did-host/src/index.test.ts | 104 ++++++++++++++++++++++ examples/local-did-host/src/index.ts | 7 +- examples/local-did-host/vitest.config.ts | 2 + pnpm-lock.yaml | 4 + 5 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 examples/local-did-host/src/index.test.ts diff --git a/examples/local-did-host/package.json b/examples/local-did-host/package.json index ba576668..61632831 100644 --- a/examples/local-did-host/package.json +++ b/examples/local-did-host/package.json @@ -31,5 +31,8 @@ "@repo/api-utils": "workspace:*", "hono": "catalog:", "valibot": "catalog:" + }, + "devDependencies": { + "vite-tsconfig-paths": "6.1.1" } } diff --git a/examples/local-did-host/src/index.test.ts b/examples/local-did-host/src/index.test.ts new file mode 100644 index 00000000..0271e975 --- /dev/null +++ b/examples/local-did-host/src/index.test.ts @@ -0,0 +1,104 @@ +import { randomBytes } from "node:crypto" + +import { type DidDocument, getDidResolver } from "@agentcommercekit/did" +import { verifyJwt } from "@agentcommercekit/jwt" +import { beforeEach, describe, expect, it } from "vitest" + +import app from "./index" + +type Entity = "agent" | "controller" + +function randomHexPrivateKey(): `0x${string}` { + return `0x${randomBytes(32).toString("hex")}` +} + +async function getDidDocument(entity: Entity): Promise { + const res = await app.request(`/${entity}/.well-known/did.json`) + expect(res.status).toBe(200) + return res.json() +} + +async function signAs(entity: Entity): Promise { + const res = await app.request(`/${entity}/sign`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + subject: "did:web:subject.example.com", + payload: {}, + }), + }) + expect(res.status).toBe(200) + const body: { jwt: string } = await res.json() + return body.jwt +} + +describe("POST /:entity/sign", () => { + beforeEach(() => { + // Each identity needs its own distinct private key so that a bug which + // conflates the two entities is actually observable in the resulting + // JWT (same key material would make agent- and controller-signed JWTs + // indistinguishable). + process.env.HOSTNAME = "0.0.0.0" + process.env.PORT = "3458" + process.env.AGENT_PRIVATE_KEY = randomHexPrivateKey() + process.env.CONTROLLER_PRIVATE_KEY = randomHexPrivateKey() + }) + + it("issues a JWT from the agent identity when signing as agent", async () => { + const agentDidDocument = await getDidDocument("agent") + const controllerDidDocument = await getDidDocument("controller") + + const resolver = getDidResolver() + resolver.addToCache(agentDidDocument.id, agentDidDocument) + resolver.addToCache(controllerDidDocument.id, controllerDidDocument) + + const jwt = await signAs("agent") + const verified = await verifyJwt(jwt, { resolver }) + + expect(verified.payload.iss).toBe(agentDidDocument.id) + expect(verified.issuer).toBe(agentDidDocument.id) + }) + + it("issues a JWT from the controller identity when signing as controller", async () => { + const agentDidDocument = await getDidDocument("agent") + const controllerDidDocument = await getDidDocument("controller") + + // Sanity check: the two identities must actually be distinct, or this + // test can't tell agent-signed and controller-signed JWTs apart. + expect(controllerDidDocument.id).not.toBe(agentDidDocument.id) + + const resolver = getDidResolver() + resolver.addToCache(agentDidDocument.id, agentDidDocument) + resolver.addToCache(controllerDidDocument.id, controllerDidDocument) + + const jwt = await signAs("controller") + + // This is the core regression check: the JWT returned by + // POST /controller/sign must actually verify against the controller's + // key and carry the controller's DID as issuer, not the agent's. + const verified = await verifyJwt(jwt, { resolver }) + + expect(verified.payload.iss).toBe(controllerDidDocument.id) + expect(verified.issuer).toBe(controllerDidDocument.id) + + // Verifying against the agent's DID must fail: the signature was not + // produced by the agent's key, and the JWT's `iss` claim doesn't match + // the agent's DID either. + await expect( + verifyJwt(jwt, { resolver, issuer: agentDidDocument.id }), + ).rejects.toThrow("Expected issuer") + }) + + it("produces different signatures for agent and controller for the same payload", async () => { + const agentJwt = await signAs("agent") + const controllerJwt = await signAs("controller") + + // Compare the signature segment specifically, not the full JWT string, + // since the header and payload already differ (different `iss`) even + // if the signatures happened to collide. + const agentSignature = agentJwt.split(".")[2] + const controllerSignature = controllerJwt.split(".")[2] + + expect(agentSignature).not.toBe(controllerSignature) + }) +}) diff --git a/examples/local-did-host/src/index.ts b/examples/local-did-host/src/index.ts index 77e0ae6d..c7e00eff 100644 --- a/examples/local-did-host/src/index.ts +++ b/examples/local-did-host/src/index.ts @@ -50,7 +50,8 @@ app.post( }), ), async (c) => { - const { signer, did, alg } = c.get("identities").agent + const { entity } = c.req.valid("param") + const { signer, did, alg } = c.get("identities")[entity] const { subject, payload, audience, expiresIn } = c.req.valid("json") const jwt = await createJwt( @@ -60,12 +61,14 @@ app.post( aud: audience, }, { - alg, issuer: did, expiresIn, signer, canonicalize: true, }, + { + alg, + }, ) return c.json({ jwt }) diff --git a/examples/local-did-host/vitest.config.ts b/examples/local-did-host/vitest.config.ts index 7972f77b..5cef97d7 100644 --- a/examples/local-did-host/vitest.config.ts +++ b/examples/local-did-host/vitest.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "vitest/config" +import tsconfigPaths from "vite-tsconfig-paths" export default defineConfig({ + plugins: [tsconfigPaths()], test: { passWithNoTests: true, watch: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 856c25e9..53673c5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,6 +300,10 @@ importers: valibot: specifier: 'catalog:' version: 1.4.1(typescript@6.0.3) + devDependencies: + vite-tsconfig-paths: + specifier: 6.1.1 + version: 6.1.1(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) examples/verifier: dependencies: