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. 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 diff --git a/packages/vc/src/verification/is-expired.test.ts b/packages/vc/src/verification/is-expired.test.ts index ac456e47..0dd86aac 100644 --- a/packages/vc/src/verification/is-expired.test.ts +++ b/packages/vc/src/verification/is-expired.test.ts @@ -47,9 +47,38 @@ 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(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) + }) + + 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) + }) + + 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 1d66ac8e..ae42bf9e 100644 --- a/packages/vc/src/verification/is-expired.ts +++ b/packages/vc/src/verification/is-expired.ts @@ -1,21 +1,72 @@ 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 ].*)?$/ + +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 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): 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]) + + if (month < 1 || month > 12) { + return true + } + + if (day < 1 || day > daysInMonth(year, month)) { + return true + } + + return false +} + /** * 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. 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, `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 } + if (hasOverflowCalendarDate(credential.expirationDate)) { + return true + } + 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() 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)