From 056106ae962d5c0304af9f87c824fe6da092b1ad Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Fri, 18 Sep 2026 23:53:38 +0000 Subject: [PATCH 1/7] fix(vc): treat unparseable expirationDate as expired isExpired previously returned false for malformed dates, failing open on a security check. Align with the fail-closed stance used for status list expiry. Fixes #148. --- packages/vc/src/verification/is-expired.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/vc/src/verification/is-expired.ts b/packages/vc/src/verification/is-expired.ts index 1d66ac8e..78bd8c60 100644 --- a/packages/vc/src/verification/is-expired.ts +++ b/packages/vc/src/verification/is-expired.ts @@ -3,19 +3,23 @@ import type { W3CCredential } from "../types" /** * Check if a credential is expired * + * Fail closed: a present but unparseable `expirationDate` (including an empty + * string) is treated as expired. An attacker must not clear expiry by mangling + * the date string. + * * @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 unreadable expiry, + * `false` when there is no expiry or it is still in the future */ export function isExpired(credential: W3CCredential): boolean { - if (!credential.expirationDate) { + if (credential.expirationDate === undefined) { return false } const expirationDate = new Date(credential.expirationDate) - if (isNaN(expirationDate.getTime())) { - // Expiration date is invalid, so we consider the credential not expired - return false + if (Number.isNaN(expirationDate.getTime())) { + return true } return expirationDate < new Date() From a2129e85a0ec058ab4a216a58b9a12bb04afca31 Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Fri, 18 Sep 2026 23:53:38 +0000 Subject: [PATCH 2/7] test(vc): cover fail-closed isExpired for bad expiration dates Replace the old graceful-pass expectation and add empty-string and non-ISO cases so the fail-closed behavior cannot regress. --- packages/vc/src/verification/is-expired.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/vc/src/verification/is-expired.test.ts b/packages/vc/src/verification/is-expired.test.ts index ac456e47..6969cce0 100644 --- a/packages/vc/src/verification/is-expired.test.ts +++ b/packages/vc/src/verification/is-expired.test.ts @@ -47,9 +47,21 @@ describe("isExpired", () => { expect(isExpired(credential)).toBe(false) }) - it("handles invalid date strings gracefully", () => { + it("returns true when expiration date cannot be parsed", () => { const credential = buildCredential("invalid-date") - expect(isExpired(credential)).toBe(false) + expect(isExpired(credential)).toBe(true) + }) + + it("returns true for empty-string expiration dates", () => { + const credential = buildCredential("") + + expect(isExpired(credential)).toBe(true) + }) + + it("returns true for non-ISO numeric strings that Date cannot parse as expiry", () => { + const credential = buildCredential("not-a-real-timestamp") + + expect(isExpired(credential)).toBe(true) }) }) From 8ed3211faed9a5e986886b23658fe04eed8e5b06 Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Fri, 18 Sep 2026 23:53:38 +0000 Subject: [PATCH 3/7] docs(vc): refresh status-list expiry comment after isExpired fix The comment still described the old fail-open isExpired behavior. Keep the dedicated status-list check, and document why it stays separate. --- packages/vc/src/verification/is-revoked.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/vc/src/verification/is-revoked.ts b/packages/vc/src/verification/is-revoked.ts index 48255533..c218b330 100644 --- a/packages/vc/src/verification/is-revoked.ts +++ b/packages/vc/src/verification/is-revoked.ts @@ -411,9 +411,10 @@ async function resolveStatusListCredential( ) } - // Check the expiry directly rather than through `isExpired`, which reads an - // unparseable date as "not expired". The expiry is the main bound on status - // list replay, so a malformed one must not quietly remove it. + // Check the expiry directly rather than through `isExpired`, which returns a + // boolean and would map an unreadable date to "expired". Status-list fetch + // needs a distinct undetermined error so callers can tell malformed expiry + // from a list that has genuinely lapsed. if (verified.expirationDate !== undefined) { const expiresAt = Date.parse(verified.expirationDate) From 96352be1bac0415fd5828e0063275f254049b00e Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Fri, 18 Sep 2026 23:53:38 +0000 Subject: [PATCH 4/7] docs(vc): document fail-closed isExpired behavior Note in the API reference that a present but unparseable expirationDate is treated as expired. --- packages/vc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vc/README.md b/packages/vc/README.md index 51aa5d5d..6605643e 100644 --- a/packages/vc/README.md +++ b/packages/vc/README.md @@ -132,7 +132,7 @@ the request does not follow redirects, so serve the credential at the URL the - `verifyParsedCredential(credential, options)` - Verify a credential's proof, expiration, and other claims - `verifyProof(proof, resolver)` - Verify a credential's proof -- `isExpired(credential)` - Check if a credential is expired +- `isExpired(credential)` - Check if a credential is expired. A present but unparseable `expirationDate` is treated as expired (fail closed). - `isRevoked(credential, options)` - Check if a credential has been revoked, against a verified status list credential - `parsedJwtCredential(jwt, resolver)` - Parse a JWT credential string into a W3C Credential From 328de62cd82de26b25823142d8443b5d4dbf4ff5 Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Fri, 18 Sep 2026 23:53:38 +0000 Subject: [PATCH 5/7] chore(changeset): patch @agentcommercekit/vc for isExpired fix Record the fail-closed expirationDate change for the next release. --- .changeset/is-expired-fail-closed.md | 5 +++++ 1 file changed, 5 insertions(+) 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..d0289698 --- /dev/null +++ b/.changeset/is-expired-fail-closed.md @@ -0,0 +1,5 @@ +--- +"@agentcommercekit/vc": patch +--- + +Treat an unparseable Verifiable Credential `expirationDate` as expired in `isExpired`, instead of fail-open. Matches the fail-closed stance already used when checking status-list expiry. From 45f9188bacb7be87ce1116f5815db4e2212c7441 Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Sun, 20 Sep 2026 06:39:32 +0000 Subject: [PATCH 6/7] fix(vc): reject ISO overflow calendar expiration dates Date silently normalizes values like 2099-02-30 into a valid future timestamp. Treat those as expired so isExpired stays fail-closed. --- .../vc/src/verification/is-expired.test.ts | 10 ++++++ packages/vc/src/verification/is-expired.ts | 35 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/vc/src/verification/is-expired.test.ts b/packages/vc/src/verification/is-expired.test.ts index 6969cce0..27a1056a 100644 --- a/packages/vc/src/verification/is-expired.test.ts +++ b/packages/vc/src/verification/is-expired.test.ts @@ -64,4 +64,14 @@ describe("isExpired", () => { expect(isExpired(credential)).toBe(true) }) + + it("returns true for ISO overflow calendar dates that Date would normalize", () => { + // JS Date turns 2099-02-30 into a valid March date; fail closed instead. + const credential = buildCredential("2099-02-30T00:00:00.000Z") + + expect(Number.isNaN(new Date("2099-02-30T00:00:00.000Z").getTime())).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 78bd8c60..c44373e7 100644 --- a/packages/vc/src/verification/is-expired.ts +++ b/packages/vc/src/verification/is-expired.ts @@ -1,11 +1,40 @@ import type { W3CCredential } from "../types" +/** + * ISO-8601 timestamps with an explicit calendar date (`YYYY-MM-DD…`). + * Used only to reject JS-normalized overflow dates such as `2099-02-30…`. + */ +const ISO_CALENDAR_PREFIX = + /^(\d{4})-(\d{2})-(\d{2})(?:[Tt ].*)?$/ + +/** + * Return true when `value` looks like an ISO calendar date whose day overflows + * (e.g. Feb 30) and was silently normalized by `Date`. + */ +function hasOverflowCalendarDate(value: string, parsed: Date): boolean { + const match = ISO_CALENDAR_PREFIX.exec(value) + if (!match) { + return false + } + + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + + return ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() + 1 !== month || + parsed.getUTCDate() !== day + ) +} + /** * Check if a credential is expired * * Fail closed: a present but unparseable `expirationDate` (including an empty * string) is treated as expired. An attacker must not clear expiry by mangling - * the date string. + * the date string. ISO-shaped overflow calendar dates that `Date` would + * normalize (e.g. `2099-02-30T00:00:00.000Z`) are also treated as expired. * * @param credential - The {@link W3CCredential} to check * @returns `true` if the credential is expired or has an unreadable expiry, @@ -22,5 +51,9 @@ export function isExpired(credential: W3CCredential): boolean { return true } + if (hasOverflowCalendarDate(credential.expirationDate, expirationDate)) { + return true + } + return expirationDate < new Date() } From d050956138afeed832bc8b630dc7a43b745e5a1c Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Sun, 20 Sep 2026 07:03:28 +0000 Subject: [PATCH 7/7] fix(vc): validate expiration calendar bounds without UTC compare Offset timestamps like 2099-01-01T00:00:00+14:00 stay valid; only out-of-range calendar fields such as Feb 30 fail closed. --- .../vc/src/verification/is-expired.test.ts | 7 ++++ packages/vc/src/verification/is-expired.ts | 42 ++++++++++++------- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/packages/vc/src/verification/is-expired.test.ts b/packages/vc/src/verification/is-expired.test.ts index 27a1056a..0dd86aac 100644 --- a/packages/vc/src/verification/is-expired.test.ts +++ b/packages/vc/src/verification/is-expired.test.ts @@ -74,4 +74,11 @@ describe("isExpired", () => { ) expect(isExpired(credential)).toBe(true) }) + + it("returns false for a future timestamp whose offset crosses a UTC day boundary", () => { + // 2099-01-01T00:00:00+14:00 is 2098-12-31 in UTC; calendar fields are still valid. + const credential = buildCredential("2099-01-01T00:00:00+14:00") + + expect(isExpired(credential)).toBe(false) + }) }) diff --git a/packages/vc/src/verification/is-expired.ts b/packages/vc/src/verification/is-expired.ts index c44373e7..ae42bf9e 100644 --- a/packages/vc/src/verification/is-expired.ts +++ b/packages/vc/src/verification/is-expired.ts @@ -4,14 +4,24 @@ import type { W3CCredential } from "../types" * ISO-8601 timestamps with an explicit calendar date (`YYYY-MM-DD…`). * Used only to reject JS-normalized overflow dates such as `2099-02-30…`. */ -const ISO_CALENDAR_PREFIX = - /^(\d{4})-(\d{2})-(\d{2})(?:[Tt ].*)?$/ +const ISO_CALENDAR_PREFIX = /^(\d{4})-(\d{2})-(\d{2})(?:[Tt ].*)?$/ + +function daysInMonth(year: number, month: number): number { + if (month === 2) { + const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 + return leap ? 29 : 28 + } + + const lengths = [31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + return lengths[month - 1] ?? 0 +} /** - * Return true when `value` looks like an ISO calendar date whose day overflows - * (e.g. Feb 30) and was silently normalized by `Date`. + * Return true when `value` looks like an ISO calendar date whose year/month/day + * are out of range (e.g. Feb 30). Compares against calendar bounds rather than + * UTC fields of the parsed instant, so valid offsets like `+14:00` stay valid. */ -function hasOverflowCalendarDate(value: string, parsed: Date): boolean { +function hasOverflowCalendarDate(value: string): boolean { const match = ISO_CALENDAR_PREFIX.exec(value) if (!match) { return false @@ -21,11 +31,15 @@ function hasOverflowCalendarDate(value: string, parsed: Date): boolean { const month = Number(match[2]) const day = Number(match[3]) - return ( - parsed.getUTCFullYear() !== year || - parsed.getUTCMonth() + 1 !== month || - parsed.getUTCDate() !== day - ) + if (month < 1 || month > 12) { + return true + } + + if (day < 1 || day > daysInMonth(year, month)) { + return true + } + + return false } /** @@ -45,13 +59,13 @@ export function isExpired(credential: W3CCredential): boolean { return false } - const expirationDate = new Date(credential.expirationDate) - - if (Number.isNaN(expirationDate.getTime())) { + if (hasOverflowCalendarDate(credential.expirationDate)) { return true } - if (hasOverflowCalendarDate(credential.expirationDate, expirationDate)) { + const expirationDate = new Date(credential.expirationDate) + + if (Number.isNaN(expirationDate.getTime())) { return true }