diff --git a/.changeset/ack-pay-expires-at-verification.md b/.changeset/ack-pay-expires-at-verification.md new file mode 100644 index 0000000..6557e3a --- /dev/null +++ b/.changeset/ack-pay-expires-at-verification.md @@ -0,0 +1,5 @@ +--- +"@agentcommercekit/ack-pay": patch +--- + +Reject payment requests whose `expiresAt` timestamp has passed when expiry verification is enabled. diff --git a/packages/ack-pay/src/verify-payment-receipt.test.ts b/packages/ack-pay/src/verify-payment-receipt.test.ts index 1fe2b49..44e728a 100644 --- a/packages/ack-pay/src/verify-payment-receipt.test.ts +++ b/packages/ack-pay/src/verify-payment-receipt.test.ts @@ -28,6 +28,9 @@ import type { PaymentRequestInit } from "./payment-request" import { isPaymentReceiptCredential } from "./receipt-claim-verifier" import { verifyPaymentReceipt } from "./verify-payment-receipt" +/** A clearly past ISO 8601 timestamp, fixed for determinism. */ +const PAST_EXPIRES_AT = "2000-01-01T00:00:00.000Z" + describe("verifyPaymentReceipt()", () => { let resolver: Resolvable let unsignedReceipt: W3CCredential @@ -95,6 +98,57 @@ describe("verifyPaymentReceipt()", () => { expect(result.paymentRequest).toBeDefined() }) + it("validates a receipt over an expired payment request", async () => { + const expiredRequestIssuerKeypair = await generateKeypair("secp256k1") + const expiredRequestIssuerDid = createDidKeyUri(expiredRequestIssuerKeypair) + + const expiredRequest = await createSignedPaymentRequest( + { + id: "test-expired-request-id", + paymentOptions: [ + { + id: "test-payment-option-id", + amount: 100, + decimals: 2, + currency: "USD", + network: "eip155:84532", + recipient: "0x592D4858DE40BC81A77E5B373238B70D7C79D3C79", + }, + ], + expiresAt: PAST_EXPIRES_AT, + }, + { + issuer: expiredRequestIssuerDid, + signer: createJwtSigner(expiredRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(expiredRequestIssuerKeypair.curve), + }, + ) + + const unsignedExpiredReceipt = createPaymentReceipt({ + paymentRequestToken: expiredRequest.paymentRequestToken, + paymentOptionId: expiredRequest.paymentRequest.paymentOptions[0].id, + issuer: receiptIssuerDid, + payerDid: createDidPkhUri( + "eip155:84532", + "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", + ), + }) + const signedExpiredReceiptJwt = await signCredential( + unsignedExpiredReceipt, + { + did: receiptIssuerDid, + signer: createJwtSigner(receiptIssuerKeypair), + }, + ) + + const result = await verifyPaymentReceipt(signedExpiredReceiptJwt, { + resolver, + }) + expect(result.receipt).toBeDefined() + expect(result.paymentRequestToken).toBe(expiredRequest.paymentRequestToken) + expect(result.paymentRequest).toBeDefined() + }) + it("validates a parsed credential", async () => { const result = await verifyPaymentReceipt(signedReceipt, { resolver, 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 f42dac5..470b118 100644 --- a/packages/ack-pay/src/verify-payment-request-token.test.ts +++ b/packages/ack-pay/src/verify-payment-request-token.test.ts @@ -19,6 +19,11 @@ import { InvalidPaymentRequestTokenError } from "./errors" import type { PaymentRequestInit } from "./payment-request" import { verifyPaymentRequestToken } from "./verify-payment-request-token" +/** A clearly past ISO 8601 timestamp, fixed for determinism. */ +const PAST_EXPIRES_AT = "2000-01-01T00:00:00.000Z" +/** A clearly future ISO 8601 timestamp, fixed for determinism. */ +const FUTURE_EXPIRES_AT = "2999-12-31T23:59:59.000Z" + /** * Removes undefined values from the payment request */ @@ -206,4 +211,66 @@ describe("verifyPaymentRequestToken", () => { ) expect(error.cause).toBeUndefined() }) + + it("throws for a payment request whose expiresAt has passed", async () => { + const body = await createSignedPaymentRequest( + { ...paymentRequest, expiresAt: PAST_EXPIRES_AT }, + { + issuer: issuerDid, + signer, + algorithm: curveToJwtAlgorithm(keypair.curve), + }, + ) + + const resolver = getDidResolver() + resolver.addToCache(issuerDid, issuerDidDocument) + + const error = await verifyPaymentRequestToken(body.paymentRequestToken, { + resolver, + }).catch((e) => e) + + expect(error).toBeInstanceOf(InvalidPaymentRequestTokenError) + expect(error.message).toBe("Payment request has expired") + }) + + it("accepts a payment request whose expiresAt is in the future", async () => { + const body = await createSignedPaymentRequest( + { ...paymentRequest, expiresAt: FUTURE_EXPIRES_AT }, + { + issuer: issuerDid, + signer, + algorithm: curveToJwtAlgorithm(keypair.curve), + }, + ) + + const resolver = getDidResolver() + resolver.addToCache(issuerDid, issuerDidDocument) + + const result = await verifyPaymentRequestToken(body.paymentRequestToken, { + resolver, + }) + + expect(result.paymentRequest.id).toBe(paymentRequest.id) + }) + + it("accepts an expired payment request when expiry verification is disabled", async () => { + const body = await createSignedPaymentRequest( + { ...paymentRequest, expiresAt: PAST_EXPIRES_AT }, + { + issuer: issuerDid, + signer, + algorithm: curveToJwtAlgorithm(keypair.curve), + }, + ) + + const resolver = getDidResolver() + resolver.addToCache(issuerDid, issuerDidDocument) + + const result = await verifyPaymentRequestToken(body.paymentRequestToken, { + resolver, + verifyExpiry: false, + }) + + expect(result.paymentRequest.id).toBe(paymentRequest.id) + }) }) diff --git a/packages/ack-pay/src/verify-payment-request-token.ts b/packages/ack-pay/src/verify-payment-request-token.ts index d1c3d28..bfa017c 100644 --- a/packages/ack-pay/src/verify-payment-request-token.ts +++ b/packages/ack-pay/src/verify-payment-request-token.ts @@ -12,7 +12,7 @@ interface ValidatePaymentRequestTokenOptions { */ resolver?: Resolvable /** - * Whether to verify the expiry of the payment request token + * Whether to verify JWT `exp` and PaymentRequest `expiresAt` */ verifyExpiry?: boolean /** @@ -58,6 +58,15 @@ export async function verifyPaymentRequestToken( ) } + // `expiresAt` is a business-level expiry distinct from JWT `exp`. + if ( + options.verifyExpiry !== false && + output.expiresAt !== undefined && + Date.parse(output.expiresAt) <= Date.now() + ) { + throw new InvalidPaymentRequestTokenError("Payment request has expired") + } + return { paymentRequest: output, parsed: parsedPaymentRequestToken,