From e7bec04d29bd12dfc444f98c81d4f448f15b0f48 Mon Sep 17 00:00:00 2001 From: ygd58 Date: Thu, 13 Aug 2026 15:46:56 +0200 Subject: [PATCH 1/2] fix(vc): isExpired fails closed on an unparseable expirationDate Fixes #148 isExpired returned false (not expired) whenever credential.expirationDate was present but could not be parsed into a valid date. Since isExpired is the check verifyParsedCredential uses to reject expired credentials, a credential with a malformed or malicious expirationDate value was treated as never-expiring instead of being rejected. isExpired now fails closed: an unparseable expirationDate is treated as expired, matching the safer default for a security-relevant check. Updated the existing test that asserted the old fail-open behavior, and added a changeset (patch, @agentcommercekit/vc). AI usage disclosure: this fix was developed with Claude (Anthropic) assistance - identifying the bug, writing the fix, updating the test, and verifying locally (pnpm --filter @agentcommercekit/vc test, oxlint, oxfmt). I reviewed and understand the change: it flips a single boolean return value in one function so a credential with an unparseable expiration date is rejected instead of silently accepted, and updates the one test that covered that branch. --- .changeset/is-expired-fail-closed.md | 14 ++++++++++++++ packages/vc/src/verification/is-expired.test.ts | 4 ++-- packages/vc/src/verification/is-expired.ts | 14 +++++++++++--- 3 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 .changeset/is-expired-fail-closed.md diff --git a/.changeset/is-expired-fail-closed.md b/.changeset/is-expired-fail-closed.md new file mode 100644 index 00000000..15d0b347 --- /dev/null +++ b/.changeset/is-expired-fail-closed.md @@ -0,0 +1,14 @@ +--- +"@agentcommercekit/vc": patch +--- + +Fix `isExpired` failing open on an unparseable `expirationDate` + +`isExpired` returned `false` (not expired) whenever `credential.expirationDate` +was present but could not be parsed into a valid date. Since `isExpired` is +the check `verifyParsedCredential` uses to reject expired credentials, a +credential with a malformed or malicious `expirationDate` value was treated +as never-expiring instead of being rejected. + +`isExpired` now fails closed: an unparseable `expirationDate` is treated as +expired, matching the safer default for a security-relevant check. diff --git a/packages/vc/src/verification/is-expired.test.ts b/packages/vc/src/verification/is-expired.test.ts index ac456e47..ee94c86b 100644 --- a/packages/vc/src/verification/is-expired.test.ts +++ b/packages/vc/src/verification/is-expired.test.ts @@ -47,9 +47,9 @@ describe("isExpired", () => { expect(isExpired(credential)).toBe(false) }) - it("handles invalid date strings gracefully", () => { + it("treats an unparseable expiration date as expired (fail closed)", () => { const credential = buildCredential("invalid-date") - expect(isExpired(credential)).toBe(false) + expect(isExpired(credential)).toBe(true) }) }) diff --git a/packages/vc/src/verification/is-expired.ts b/packages/vc/src/verification/is-expired.ts index 1d66ac8e..829dfccd 100644 --- a/packages/vc/src/verification/is-expired.ts +++ b/packages/vc/src/verification/is-expired.ts @@ -3,8 +3,13 @@ import type { W3CCredential } from "../types" /** * Check if a credential is expired * + * Fails closed: a credential with an `expirationDate` that is present but + * cannot be parsed as a valid date is treated as expired, not as + * non-expiring. + * * @param credential - The {@link W3CCredential} to check - * @returns `true` if the credential is expired, `false` otherwise + * @returns `true` if the credential is expired (or has an unparseable + * expiration date), `false` otherwise */ export function isExpired(credential: W3CCredential): boolean { if (!credential.expirationDate) { @@ -14,8 +19,11 @@ export function isExpired(credential: W3CCredential): boolean { const expirationDate = new Date(credential.expirationDate) if (isNaN(expirationDate.getTime())) { - // Expiration date is invalid, so we consider the credential not expired - return false + // Expiration date is present but unparseable. Fail closed: an + // unparseable expiration date must not be treated as "never expires", + // since that would let a malformed or malicious `expirationDate` value + // grant a credential unbounded validity. + return true } return expirationDate < new Date() From ab564b2d432c462477f08b14841e2abf6c48e816 Mon Sep 17 00:00:00 2001 From: ygd58 Date: Fri, 14 Aug 2026 14:21:06 +0200 Subject: [PATCH 2/2] fix(vc): isExpired also fails closed on a present-but-empty expirationDate Addresses review feedback from @qlxjcj on this PR. The initial fix's guard, if (!credential.expirationDate), treats an false before ever reaching the isNaN fail-closed branch. An empty string is present but unparseable (new Date("") -> NaN), so it should fail closed like any other unparseable value - the falsy check was silently leaving the same fail-open hole open for this one case. Changed the guard to credential.expirationDate === undefined, which only treats a genuinely absent field as absent, letting an empty string reach the isNaN check and correctly fail closed. Added a regression test for this case. pnpm --filter @agentcommercekit/vc exec vitest run src/verification/is-expired.test.ts - 6/6 passing. oxlint and oxfmt clean. --- packages/vc/src/verification/is-expired.test.ts | 9 +++++++++ packages/vc/src/verification/is-expired.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/vc/src/verification/is-expired.test.ts b/packages/vc/src/verification/is-expired.test.ts index ee94c86b..2fde1166 100644 --- a/packages/vc/src/verification/is-expired.test.ts +++ b/packages/vc/src/verification/is-expired.test.ts @@ -52,4 +52,13 @@ describe("isExpired", () => { expect(isExpired(credential)).toBe(true) }) + + it("treats a present but empty-string expiration date as expired (fail closed)", () => { + // An empty string is present (not `undefined`) but unparseable + // (`new Date("")` -> `NaN`). It must not be conflated with an absent + // `expirationDate` via a falsy check, or it silently fails open. + const credential = buildCredential("") + + expect(isExpired(credential)).toBe(true) + }) }) diff --git a/packages/vc/src/verification/is-expired.ts b/packages/vc/src/verification/is-expired.ts index 829dfccd..09a5cc98 100644 --- a/packages/vc/src/verification/is-expired.ts +++ b/packages/vc/src/verification/is-expired.ts @@ -12,7 +12,7 @@ import type { W3CCredential } from "../types" * expiration date), `false` otherwise */ export function isExpired(credential: W3CCredential): boolean { - if (!credential.expirationDate) { + if (credential.expirationDate === undefined) { return false }