diff --git a/.changeset/is-expired-fail-closed.md b/.changeset/is-expired-fail-closed.md new file mode 100644 index 0000000..15d0b34 --- /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 ac456e4..2fde116 100644 --- a/packages/vc/src/verification/is-expired.test.ts +++ b/packages/vc/src/verification/is-expired.test.ts @@ -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) }) }) diff --git a/packages/vc/src/verification/is-expired.ts b/packages/vc/src/verification/is-expired.ts index 1d66ac8..09a5cc9 100644 --- a/packages/vc/src/verification/is-expired.ts +++ b/packages/vc/src/verification/is-expired.ts @@ -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 */ 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()