Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/is-expired-fail-closed.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/vc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 30 additions & 1 deletion packages/vc/src/verification/is-expired.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
61 changes: 56 additions & 5 deletions packages/vc/src/verification/is-expired.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
7 changes: 4 additions & 3 deletions packages/vc/src/verification/is-revoked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down