Skip to content
Open
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
14 changes: 14 additions & 0 deletions .changeset/is-expired-fail-closed.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 11 additions & 2 deletions packages/vc/src/verification/is-expired.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,18 @@ 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)
})

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)
})
})
16 changes: 12 additions & 4 deletions packages/vc/src/verification/is-expired.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,27 @@ 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*/
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
// 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()
Expand Down