diff --git a/.changeset/payment-request-sub-id-binding.md b/.changeset/payment-request-sub-id-binding.md new file mode 100644 index 00000000..233eb07d --- /dev/null +++ b/.changeset/payment-request-sub-id-binding.md @@ -0,0 +1,10 @@ +--- +"@agentcommercekit/ack-pay": patch +--- + +Bind the payment request token subject to its id during verification. + +`createPaymentRequestToken` always sets `sub` to the payment request id, but +`verifyPaymentRequestToken` never checked that binding, so a validly signed +token could carry a different `sub` and still verify. Reject tokens whose JWT +`sub` does not match the parsed payment request id. diff --git a/docs/ack-pay/payment-request-payload.mdx b/docs/ack-pay/payment-request-payload.mdx index 9eebbce0..b76b9258 100644 --- a/docs/ack-pay/payment-request-payload.mdx +++ b/docs/ack-pay/payment-request-payload.mdx @@ -70,7 +70,7 @@ Every Payment Request payload contains essential properties: | Field | Type / Status | Description | | :-------------------- | :----------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `paymentRequestToken` | String / Required | A cryptographic token (e.g., JWT) generated by the Server, signing the `paymentRequest` payload to ensure integrity and authenticity. This token is verified by several participants in the protocol. | +| `paymentRequestToken` | String / Required | A cryptographic token (e.g., JWT) generated by the Server, signing the `paymentRequest` payload to ensure integrity and authenticity. When encoded as a JWT, the `sub` claim must equal `paymentRequest.id`. This token is verified by several participants in the protocol. | | `paymentRequest` | Object / Required | The main payment request object containing all payment details. | | ` id` | String / Required | A unique identifier generated for this specific payment request. Used for tracking and preventing replay attacks. | | ` expiresAt` | String / Optional | An ISO 8601 timestamp indicating when this payment request becomes invalid. Clients should not attempt payment after this time. | diff --git a/packages/ack-pay/src/verify-payment-request-token.test.ts b/packages/ack-pay/src/verify-payment-request-token.test.ts index f42dac56..c9f7b39f 100644 --- a/packages/ack-pay/src/verify-payment-request-token.test.ts +++ b/packages/ack-pay/src/verify-payment-request-token.test.ts @@ -183,6 +183,36 @@ describe("verifyPaymentRequestToken", () => { expect(error.cause).toBeInstanceOf(Error) }) + it("throws when sub does not match id without cause", async () => { + const mismatchedToken = await createJwt( + { + ...paymentRequest, + sub: "different-subject", + }, + { + issuer: issuerDid, + signer, + }, + { + alg: curveToJwtAlgorithm(keypair.curve), + }, + ) + + const resolver = getDidResolver() + resolver.addToCache(issuerDid, issuerDidDocument) + + const error = await verifyPaymentRequestToken(mismatchedToken, { + resolver, + verifyExpiry: false, + }).catch((e) => e) + + expect(error).toBeInstanceOf(InvalidPaymentRequestTokenError) + expect(error.message).toBe( + "Payment Request token subject does not match its id", + ) + expect(error.cause).toBeUndefined() + }) + it("throws for a JWT that does not contain a payment config without cause", async () => { // Create a JWT with valid format but missing payment config const invalidToken = await createJwt( diff --git a/packages/ack-pay/src/verify-payment-request-token.ts b/packages/ack-pay/src/verify-payment-request-token.ts index d1c3d286..4a4af6a8 100644 --- a/packages/ack-pay/src/verify-payment-request-token.ts +++ b/packages/ack-pay/src/verify-payment-request-token.ts @@ -58,6 +58,14 @@ export async function verifyPaymentRequestToken( ) } + const { sub } = parsedPaymentRequestToken.payload + + if (typeof sub !== "string" || sub !== output.id) { + throw new InvalidPaymentRequestTokenError( + "Payment Request token subject does not match its id", + ) + } + return { paymentRequest: output, parsed: parsedPaymentRequestToken,