diff --git a/CLAUDE.md b/CLAUDE.md index e5cc40a..6b2642d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,13 +88,23 @@ and keep create/verify symmetric** (if create rejects something, verify must too Three aggregate verifiers plug into `verifyDocument()`'s pipeline via `w3cVerifiers`: - `w3cVpSignatureIntegrity` (DOCUMENT_INTEGRITY) — **requires a holder proof** (unsigned - → INVALID), verifies the proof crypto, and enforces **holder binding** in-fragment. - Freshness (challenge/domain) is intentionally out of scope — a stateless pipeline - can't check it. + → INVALID), verifies the proof crypto + **each embedded credential's signature**, and + enforces **holder binding** in-fragment. This fragment is **strictly cryptographic**: + embedded credentials are checked with `verifyCredential` (signature only), **not** + `verifyPresentation`'s `credentialResults` — the latter folds expiry/revocation into + `verified`, and those are DOCUMENT_STATUS concerns, not integrity. Freshness + (challenge/domain) is also out of scope — a stateless pipeline can't check it. - `w3cVpCredentialStatus` (DOCUMENT_STATUS) — VP expiry + each embedded credential's - StatusList revocation. + **temporal validity** (expiry / not-yet-valid, honouring v2 `validFrom`/`validUntil` + and v1.1 `issuanceDate`/`expirationDate`) + StatusList revocation. - `w3cVpIssuerIdentity` (ISSUER_IDENTITY) — each embedded issuer resolves. +**Layer split (why an expired embedded VC lands in STATUS, not INTEGRITY):** temporal +validity is a *status* property, so it's judged by `w3cVpCredentialStatus`. INTEGRITY +answers only "is every signature authentic + is the holder bound". If you move a check +between these two fragments, move its test with it — an expired embedded VC must stay +caught *somewhere*. + `isVpDocument()` (the `test()` gate) routes on shape only (`type` includes `VerifiablePresentation` + has `verifiableCredential`) — it does **not** look at `proof`, so an unsigned VP is still routed in and then judged INVALID by the integrity fragment. @@ -110,8 +120,10 @@ in step. id**. To test/produce a credential with *no* subject id, it must be issued without one. - **Holder binding is string-equality of DIDs and is method-agnostic** (did:key and did:web both work). It's independent of the issuer. -- **StatusList test indices** on `.../statuslist/1`: index **5 → revoked**, index - **10 → not revoked**. Reuse these instead of inventing new ones. +- **StatusList test indices** on `.../statuslist/1` (a **StatusList2021Credential**): index + **5 → revoked**, index **10 → not revoked**. `.../statuslist/2` is the **BitstringStatusList** + equivalent — indices **5–9 → revoked**, others not (so 5 → revoked, 10 → not revoked, same + mnemonic). Reuse these instead of inventing new ones. - **Test fixtures share key material** across did:key and did:web (the same ECDSA key is published under `did:key:zDnae…` and `did:web:trustvc.github.io:did:1#multikey-1`). Handy for tests, but it means "different DID" ≠ "different key" in fixtures. diff --git a/src/__tests__/w3c/vpFragments.test.ts b/src/__tests__/w3c/vpFragments.test.ts index a954096..fa36618 100644 --- a/src/__tests__/w3c/vpFragments.test.ts +++ b/src/__tests__/w3c/vpFragments.test.ts @@ -16,6 +16,7 @@ import { } from '../../verify/fragments/presentation/w3cVpVerifier'; import { w3cIssuerIdentity } from '../../verify/fragments/issuer-identity/w3cIssuerIdentity'; import { verifyDocument } from '../../core/verify'; +import { isValid } from '../../verify/verify'; // Asserts a value is defined and returns it narrowed (avoids `!` assertions). const assertDefined = (value: T | undefined, message: string): T => { @@ -103,12 +104,54 @@ describe('W3C VP verification fragments', () => { sub.credentialSubject.blNumber = 'TAMPERED'; const o = await opts(); const integrity = await w3cVpSignatureIntegrity.verify(tampered, o as never); + // Tampering an embedded credential breaks the VP holder proof (which signs OVER the + // credentials), so this trips the proof check, not the per-credential signature branch. expect(integrity.status).toBe('INVALID'); }); - it('emits INVALID integrity when an embedded credential has EXPIRED', async () => { + it('names the embedded credential index when its OWN signature is invalid (VP proof still valid)', async () => { + // Corrupt the credential's signature BEFORE signing the VP, so the holder proof is valid + // over the corrupted content but the credential's own issuer signature is not. This is the + // defense-in-depth case: a holder can legitimately sign a VP wrapping a forged credential. + // `signPresentation` does not re-verify embedded credentials, so this VP is constructible. + const raw = JSON.parse( + JSON.stringify(await createPresentation(embeddedVc as never, { holder: DID })), + ); + const sub = Array.isArray(raw.verifiableCredential) + ? raw.verifiableCredential[0] + : raw.verifiableCredential; + sub.proof.proofValue = String(sub.proof.proofValue).slice(0, -6) + 'ZZZZZZ'; + const { signed } = await signPresentation(raw, holderKey as never, { challenge: 'y' }); + const o = await opts(); + const integrity = await w3cVpSignatureIntegrity.verify(signed as never, o as never); + expect(integrity.status).toBe('INVALID'); + const message = (integrity as { reason?: { message?: string } }).reason?.message; + expect(message).toMatch(/index 0/); + expect(message).toMatch(/signature/i); + }); + + it('emits INVALID integrity when the declared holder does not match the signer (holder binding)', async () => { + // Holder binding is enforced IN the integrity fragment (independent of the wrapper). A VP + // whose `holder` differs from the signing key's DID must fail even though the proof crypto + // is valid over that (mismatched) holder. + const raw = JSON.parse( + JSON.stringify(await createPresentation(embeddedVc as never, { holder: DID })), + ); + raw.holder = 'did:web:someone-else.example'; // differs from the signing key's DID + const { signed } = await signPresentation(raw, holderKey as never, { challenge: 'hb' }); + const o = await opts(); + const integrity = await w3cVpSignatureIntegrity.verify(signed as never, o as never); + expect(integrity.status).toBe('INVALID'); + expect((integrity as { reason?: { message?: string } }).reason?.message).toMatch( + /does not match the declared holder/i, + ); + }); + + it('an EXPIRED embedded credential fails STATUS, not integrity (temporal validity is a status concern)', async () => { // Embedded credential expired in 2021; VP created in 2020 (so creation passes) with a - // long VP lifetime, then verified "now" (2026) → the expired credential fails. + // long VP lifetime, then verified "now" (2026). The credential is cryptographically + // authentic and holder-bound, so DOCUMENT_INTEGRITY stays VALID; expiry surfaces under + // DOCUMENT_STATUS instead. const raw = { ...W3C_RAW_CREDENTIAL_V2_0, issuer: DID, @@ -134,7 +177,26 @@ describe('W3C VP verification fragments', () => { const { signed } = await signPresentation(vp, holderKey as never, { challenge: 'x' }); const o = await opts(); const integrity = await w3cVpSignatureIntegrity.verify(signed as never, o as never); - expect(integrity.status).toBe('INVALID'); + const status = await w3cVpCredentialStatus.verify(signed as never, o as never); + expect(integrity.status).toBe('VALID'); + expect(status.status).toBe('INVALID'); + expect((status as { reason?: { message?: string } }).reason?.message).toMatch(/expired/i); + }); + + it('w3cVpCredentialStatus emits INVALID when the VP ENVELOPE itself has expired', async () => { + // Distinct from an embedded credential expiring: the presentation's own validUntil is past. + // This branch runs before any status fetch, so a plain object is enough. + const vp = { + type: ['VerifiablePresentation'], + verifiableCredential: [W3C_VERIFIABLE_DOCUMENT], + validUntil: '2020-01-01T00:00:00Z', + }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.status).toBe('INVALID'); + expect((status as { reason?: { message?: string } }).reason?.message).toMatch( + /Presentation has expired/i, + ); }); it('w3cVpCredentialStatus resolves an embedded StatusList2021Entry (not revoked → VALID)', async () => { @@ -160,6 +222,84 @@ describe('W3C VP verification fragments', () => { const status = await w3cVpCredentialStatus.verify(vp as never, o as never); expect(status.status).toBe('INVALID'); expect((status as { reason?: { message?: string } }).reason?.message).toMatch(/revoked/i); + expect((status as { reason?: { message?: string } }).reason?.message).toMatch(/index 0/); + }); + + // statuslist/2 is a real BitstringStatusListCredential (the other supported status type): + // indices 5-9 are revoked, the rest are not. Exercising it proves the fragment handles + // BitstringStatusListEntry, not only StatusList2021Entry. + const bitstringEntry = (index: string) => ({ + id: `https://trustvc.github.io/did/credentials/statuslist/2#${index}`, + type: 'BitstringStatusListEntry', + statusPurpose: 'revocation', + statusListIndex: index, + statusListCredential: 'https://trustvc.github.io/did/credentials/statuslist/2', + }); + + it('w3cVpCredentialStatus resolves a BitstringStatusListEntry (index 10 → not revoked → VALID)', async () => { + const vc = { ...W3C_VERIFIABLE_DOCUMENT, credentialStatus: bitstringEntry('10') }; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [vc] }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.status).toBe('VALID'); + }); + + it('w3cVpCredentialStatus emits INVALID for a REVOKED BitstringStatusListEntry (index 5)', async () => { + const vc = { ...W3C_VERIFIABLE_DOCUMENT, credentialStatus: bitstringEntry('5') }; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [vc] }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.status).toBe('INVALID'); + expect((status as { reason?: { message?: string } }).reason?.message).toMatch(/revoked/i); + }); + + it('w3cVpCredentialStatus emits INVALID when an embedded credential is NOT YET VALID', async () => { + // Temporal validity is a STATUS concern. A credential whose validFrom is in the future + // must fail here (this path is symmetric with the expiry check). The status fragment does + // not verify signatures, so a plain fixture object with an overridden validFrom suffices. + const notYetVc = { ...W3C_VERIFIABLE_DOCUMENT, validFrom: '2999-01-01T00:00:00Z' }; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [notYetVc] }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.status).toBe('INVALID'); + expect((status as { reason?: { message?: string } }).reason?.message).toMatch(/not yet valid/i); + expect((status as { reason?: { message?: string } }).reason?.message).toMatch(/index 0/); + }); + + it('w3cVpCredentialStatus names the CORRECT credential index (2nd VC revoked → "index 1")', async () => { + // Two embedded credentials: index 0 is fine (statusListIndex 10 → not revoked), index 1 is + // revoked (statusListIndex 5). The reason must point at index 1, proving the index is not + // merely present but correct after the flatMap that carries the owning credential index. + const ok = W3C_VERIFIABLE_DOCUMENT; + const revokedVc = { + ...W3C_VERIFIABLE_DOCUMENT, + credentialStatus: { ...W3C_VERIFIABLE_DOCUMENT.credentialStatus, statusListIndex: '5' }, + }; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [ok, revokedVc] }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.status).toBe('INVALID'); + expect((status as { reason?: { message?: string } }).reason?.message).toMatch(/index 1/); + }); + + it('w3cVpCredentialStatus ERRORs (naming the index) when a supported status check fails', async () => { + // A supported StatusList entry whose statusListIndex is out of range makes + // verifyCredentialStatus RETURN an error (it never throws), which the fragment surfaces as + // ERROR — and it must name the owning credential index, same parity as revoked/expired. + const badStatusVc = { + ...W3C_VERIFIABLE_DOCUMENT, + credentialStatus: { + ...W3C_VERIFIABLE_DOCUMENT.credentialStatus, + statusListIndex: '99999999999', // beyond the status list's range → deterministic error + }, + }; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [badStatusVc] }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.status).toBe('ERROR'); + const message = (status as { reason?: { message?: string } }).reason?.message; + expect(message).toMatch(/Could not verify status/i); + expect(message).toMatch(/index 0/); }); it('w3cVpCredentialStatus ERRORs on an unsupported credentialStatus type (not silently dropped)', async () => { @@ -174,6 +314,43 @@ describe('W3C VP verification fragments', () => { expect((status as { reason?: { message?: string } }).reason?.message).toMatch(/Unsupported/i); }); + it('w3cVpCredentialStatus returns VALID for a credential with NO credentialStatus (nothing to check)', async () => { + // Absent credentialStatus is NOT the same as a malformed one: it contributes no status + // entry, so there is nothing to revoke-check and the fragment must stay VALID (not ERROR). + const noStatus = { ...W3C_VERIFIABLE_DOCUMENT } as { credentialStatus?: unknown }; + delete noStatus.credentialStatus; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [noStatus] }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.status).toBe('VALID'); + }); + + it('w3cVpCredentialStatus ERRORs on a credentialStatus with NO type (not silently dropped)', async () => { + // A `credentialStatus: {}` must not fall through both filters and skip revocation — it is + // unevaluable, so it must surface as ERROR naming the credential index. + const vc = { ...W3C_VERIFIABLE_DOCUMENT, credentialStatus: {} }; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [vc] }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.status).toBe('ERROR'); + const message = (status as { reason?: { message?: string } }).reason?.message; + expect(message).toMatch(/missing/i); + expect(message).toMatch(/index 0/); + }); + + it('w3cVpCredentialStatus emits INVALID for an UNPARSEABLE embedded temporal value', async () => { + // `new Date("invalid")` is an Invalid Date whose comparisons all read false, so a garbage + // validUntil would slip through as "not expired" unless explicitly rejected. + const vc = { ...W3C_VERIFIABLE_DOCUMENT, validUntil: 'not-a-date' }; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [vc] }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.status).toBe('INVALID'); + const message = (status as { reason?: { message?: string } }).reason?.message; + expect(message).toMatch(/unparseable validUntil/i); + expect(message).toMatch(/index 0/); + }); + it('w3cVpIssuerIdentity emits INVALID when an embedded credential has no issuer', async () => { const noIssuer = { ...W3C_VERIFIABLE_DOCUMENT } as { issuer?: string }; delete noIssuer.issuer; @@ -182,6 +359,18 @@ describe('W3C VP verification fragments', () => { const issuer = await w3cVpIssuerIdentity.verify(vp as never, o as never); expect(issuer.status).toBe('INVALID'); expect((issuer as { reason?: { message?: string } }).reason?.message).toMatch(/no issuer/i); + expect((issuer as { reason?: { message?: string } }).reason?.message).toMatch(/index 0/); + }); + + it('w3cVpIssuerIdentity names the CORRECT index when only the 2nd VC lacks an issuer', async () => { + const withIssuer = W3C_VERIFIABLE_DOCUMENT; + const noIssuer = { ...W3C_VERIFIABLE_DOCUMENT } as { issuer?: string }; + delete noIssuer.issuer; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [withIssuer, noIssuer] }; + const o = await opts(); + const issuer = await w3cVpIssuerIdentity.verify(vp as never, o as never); + expect(issuer.status).toBe('INVALID'); + expect((issuer as { reason?: { message?: string } }).reason?.message).toMatch(/index 1/); }); it('w3cVpIssuerIdentity resolves an embedded did:web issuer (→ VALID)', async () => { @@ -195,6 +384,19 @@ describe('W3C VP verification fragments', () => { expect(issuer.status).toBe('VALID'); }); + it('w3cVpIssuerIdentity emits INVALID when an embedded issuer DID cannot be resolved', async () => { + // A syntactically valid but non-resolvable did:web (the reserved .invalid TLD never resolves) + // must surface as INVALID rather than silently pass. + const vc = { ...W3C_VERIFIABLE_DOCUMENT, issuer: 'did:web:nonexistent.example.invalid' }; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [vc] }; + const o = await opts(); + const issuer = await w3cVpIssuerIdentity.verify(vp as never, o as never); + expect(issuer.status).toBe('INVALID'); + const message = (issuer as { reason?: { message?: string } }).reason?.message; + expect(message).toMatch(/could not resolve issuer/i); + expect(message).toMatch(/index 0/); + }); + it('runs through the full verifyDocument() pipeline for a signed VP', async () => { const vp = await createPresentation(embeddedVc as never, { holder: DID }); const { signed } = await signPresentation(vp, holderKey as never, { @@ -213,4 +415,167 @@ describe('W3C VP verification fragments', () => { // A valid VP must produce NO INVALID/ERROR fragment across the whole pipeline. expect(fragments.every((f) => f.status === 'VALID' || f.status === 'SKIPPED')).toBe(true); }); + + it('verifyDocument() rejects an EXPIRED embedded VC at DOCUMENT_STATUS, keeping DOCUMENT_INTEGRITY VALID', async () => { + // End-to-end guard for the layer split: since integrity is now crypto-only, DOCUMENT_STATUS + // is the SOLE catcher of embedded-credential expiry. This proves the whole pipeline (not just + // an isolated fragment) still rejects the document AND attributes it to the right dimension. + const raw = { + ...W3C_RAW_CREDENTIAL_V2_0, + issuer: DID, + validFrom: '2020-01-01T00:00:00Z', + validUntil: '2021-01-01T00:00:00Z', + credentialSubject: { ...W3C_RAW_CREDENTIAL_V2_0.credentialSubject, id: DID }, + }; + const s = await signCredential(raw as never, holderKey as never, 'ecdsa-sd-2023'); + const vc = assertDefined( + ( + await deriveCredential(assertDefined(s.signed, 'signed'), [ + '/credentialSubject/id', + '/validUntil', + ]) + ).derived, + 'derived', + ); + const vp = await createPresentation(vc as never, { + holder: DID, + now: new Date('2020-06-01T00:00:00Z'), + expiresInSeconds: 315360000, + }); + const { signed } = await signPresentation(vp, holderKey as never, { challenge: 'e2e' }); + const fragments = await verifyDocument(signed as never); + const byName = (name: string) => fragments.find((f) => f.name === name); + + expect(byName('W3CVpSignatureIntegrity')?.status).toBe('VALID'); + expect(byName('W3CVpCredentialStatus')?.status).toBe('INVALID'); + // Overall verdict: integrity dimension holds, status dimension fails → document invalid. + expect(isValid(fragments, ['DOCUMENT_INTEGRITY'])).toBe(true); + expect(isValid(fragments, ['DOCUMENT_STATUS'])).toBe(false); + expect(isValid(fragments)).toBe(false); + }); + + it('verifyDocument() rejects a REVOKED embedded VC at DOCUMENT_STATUS, keeping DOCUMENT_INTEGRITY VALID', async () => { + // Mint a holder-bound credential that references the real status list at index 5 (revoked). + // It is cryptographically authentic and holder-bound (integrity VALID), but revoked (status + // INVALID). `createPresentation` would reject a revoked credential, so we assemble the VP by + // hand and sign the holder proof directly — the same bypass a malicious presenter would use. + const raw = { + ...W3C_RAW_CREDENTIAL_V2_0, + '@context': [ + ...W3C_RAW_CREDENTIAL_V2_0['@context'], + 'https://w3id.org/vc/status-list/2021/v1', + ], + issuer: DID, + validFrom: '2024-04-01T12:19:52Z', + credentialStatus: { + id: 'https://trustvc.github.io/did/credentials/statuslist/1#5', + type: 'StatusList2021Entry', + statusPurpose: 'revocation', + statusListIndex: '5', // index 5 on statuslist/1 → REVOKED + statusListCredential: 'https://trustvc.github.io/did/credentials/statuslist/1', + }, + credentialSubject: { ...W3C_RAW_CREDENTIAL_V2_0.credentialSubject, id: DID }, + }; + const s = await signCredential(raw as never, holderKey as never, 'ecdsa-sd-2023'); + const vc = assertDefined( + (await deriveCredential(assertDefined(s.signed, 'signed'), ['/credentialSubject/id'])) + .derived, + 'derived', + ); + const rawVp = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiablePresentation'], + verifiableCredential: [vc], + holder: DID, + }; + const { signed } = await signPresentation(rawVp as never, holderKey as never, { + challenge: 'e2e-revoked', + }); + const fragments = await verifyDocument(signed as never); + const byName = (name: string) => fragments.find((f) => f.name === name); + + expect(byName('W3CVpSignatureIntegrity')?.status).toBe('VALID'); + expect(byName('W3CVpCredentialStatus')?.status).toBe('INVALID'); + expect( + (byName('W3CVpCredentialStatus') as { reason?: { message?: string } }).reason?.message, + ).toMatch(/revoked/i); + expect(isValid(fragments, ['DOCUMENT_INTEGRITY'])).toBe(true); + expect(isValid(fragments, ['DOCUMENT_STATUS'])).toBe(false); + expect(isValid(fragments)).toBe(false); + }); + + it('verifyDocument() rejects a REVOKED BitstringStatusList VC at DOCUMENT_STATUS (integrity VALID)', async () => { + // Same end-to-end shape as the StatusList2021 case, but for the OTHER supported status type. + // BitstringStatusListEntry is defined natively by the v2 credentials context, so no extra + // context is needed. statuslist/2 index 5 → revoked. + const raw = { + ...W3C_RAW_CREDENTIAL_V2_0, + issuer: DID, + validFrom: '2024-04-01T12:19:52Z', + credentialStatus: { + id: 'https://trustvc.github.io/did/credentials/statuslist/2#5', + type: 'BitstringStatusListEntry', + statusPurpose: 'revocation', + statusListIndex: '5', // index 5 on statuslist/2 → REVOKED + statusListCredential: 'https://trustvc.github.io/did/credentials/statuslist/2', + }, + credentialSubject: { ...W3C_RAW_CREDENTIAL_V2_0.credentialSubject, id: DID }, + }; + const s = await signCredential(raw as never, holderKey as never, 'ecdsa-sd-2023'); + const vc = assertDefined( + (await deriveCredential(assertDefined(s.signed, 'signed'), ['/credentialSubject/id'])) + .derived, + 'derived', + ); + const rawVp = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiablePresentation'], + verifiableCredential: [vc], + holder: DID, + }; + const { signed } = await signPresentation(rawVp as never, holderKey as never, { + challenge: 'e2e-revoked-bitstring', + }); + const fragments = await verifyDocument(signed as never); + const byName = (name: string) => fragments.find((f) => f.name === name); + + expect(byName('W3CVpSignatureIntegrity')?.status).toBe('VALID'); + expect(byName('W3CVpCredentialStatus')?.status).toBe('INVALID'); + expect( + (byName('W3CVpCredentialStatus') as { reason?: { message?: string } }).reason?.message, + ).toMatch(/revoked/i); + expect(isValid(fragments, ['DOCUMENT_INTEGRITY'])).toBe(true); + expect(isValid(fragments, ['DOCUMENT_STATUS'])).toBe(false); + expect(isValid(fragments)).toBe(false); + }); + + it('verifyDocument() rejects an UNSIGNED VP at DOCUMENT_INTEGRITY', async () => { + // A raw (unsigned) VP is still routed in by shape, then judged INVALID by integrity. + const vp = await createPresentation(embeddedVc as never, { holder: DID }); + const fragments = await verifyDocument(vp as never); + const byName = (name: string) => fragments.find((f) => f.name === name); + + expect(byName('W3CVpSignatureIntegrity')?.status).toBe('INVALID'); + expect(isValid(fragments, ['DOCUMENT_INTEGRITY'])).toBe(false); + expect(isValid(fragments)).toBe(false); + }); + + it('verifyDocument() rejects a VP whose embedded credential SIGNATURE is invalid', async () => { + // Valid holder proof over a forged credential (see the isolated-fragment test) — the whole + // pipeline must reject it at DOCUMENT_INTEGRITY. + const raw = JSON.parse( + JSON.stringify(await createPresentation(embeddedVc as never, { holder: DID })), + ); + const sub = Array.isArray(raw.verifiableCredential) + ? raw.verifiableCredential[0] + : raw.verifiableCredential; + sub.proof.proofValue = String(sub.proof.proofValue).slice(0, -6) + 'ZZZZZZ'; + const { signed } = await signPresentation(raw, holderKey as never, { challenge: 'e2e-badsig' }); + const fragments = await verifyDocument(signed as never); + const byName = (name: string) => fragments.find((f) => f.name === name); + + expect(byName('W3CVpSignatureIntegrity')?.status).toBe('INVALID'); + expect(isValid(fragments, ['DOCUMENT_INTEGRITY'])).toBe(false); + expect(isValid(fragments)).toBe(false); + }); }); diff --git a/src/verify/fragments/presentation/w3cVpVerifier.ts b/src/verify/fragments/presentation/w3cVpVerifier.ts index 882ba93..a7de16e 100644 --- a/src/verify/fragments/presentation/w3cVpVerifier.ts +++ b/src/verify/fragments/presentation/w3cVpVerifier.ts @@ -9,6 +9,7 @@ import { CredentialStatus, SignedVerifiableCredential, VerifiablePresentation, + verifyCredential, verifyCredentialStatus, verifyPresentation, } from '@trustvc/w3c-vc'; @@ -52,6 +53,63 @@ const toArray = (value: T | T[] | undefined | null): T[] => { const getSubjects = (cred: SignedVerifiableCredential): unknown[] => toArray(cred?.credentialSubject as unknown); +// The temporal window of a credential, honouring both VC Data Model versions: +// v2.0 uses validFrom/validUntil, v1.1 uses issuanceDate/expirationDate. +const getCredentialWindow = ( + cred: SignedVerifiableCredential, +): { from?: string; until?: string } => { + const c = cred as { + validFrom?: string; + validUntil?: string; + issuanceDate?: string; + expirationDate?: string; + }; + return { from: c.validFrom ?? c.issuanceDate, until: c.validUntil ?? c.expirationDate }; +}; + +// True when `value` parses to a real date. `new Date('garbage')` is an Invalid Date, whose +// comparisons all read false — so an unparseable validFrom/validUntil would otherwise slip +// through the temporal checks as "valid". Callers must reject a present-but-unparseable value. +const isValidDate = (value: string): boolean => !Number.isNaN(new Date(value).getTime()); + +// Finds the first embedded credential outside its validity window — unparseable, expired, or +// not-yet-valid — returning the reason + fragment data, or undefined when all are within range. +// Extracted from the status verifier to keep that function's cognitive complexity low. +const findEmbeddedTemporalError = ( + credentials: SignedVerifiableCredential[], + now: Date, +): { message: string; data: Record } | undefined => { + for (let i = 0; i < credentials.length; i++) { + const { from, until } = getCredentialWindow(credentials[i]); + // Reject unparseable values before comparing (Invalid Date comparisons all read false). + if (until !== undefined && !isValidDate(until)) { + return { + message: `Embedded credential at index ${i} has an unparseable validUntil ("${until}").`, + data: { credentialIndex: i, validUntil: until }, + }; + } + if (from !== undefined && !isValidDate(from)) { + return { + message: `Embedded credential at index ${i} has an unparseable validFrom ("${from}").`, + data: { credentialIndex: i, validFrom: from }, + }; + } + if (until && now > new Date(until)) { + return { + message: `Embedded credential at index ${i} has expired (validUntil ${until}).`, + data: { expired: true, credentialIndex: i, validUntil: until }, + }; + } + if (from && now < new Date(from)) { + return { + message: `Embedded credential at index ${i} is not yet valid (validFrom ${from}).`, + data: { notYetValid: true, credentialIndex: i, validFrom: from }, + }; + } + } + return undefined; +}; + // Holder binding: the signer's DID (from the proof's verificationMethod) must equal the // holder and every credentialSubject.id. Returns an error message, or undefined when bound. const checkVpHolderBinding = (doc: VerifiablePresentation): string | undefined => { @@ -101,10 +159,14 @@ const checkDidResolve = async (did: string, documentLoader?: DocumentLoader): Pr }; // --------------------------------------------------------------------------- -// DOCUMENT_INTEGRITY — the holder proof (crypto only) + every embedded credential's signature. -// NOTE: challenge/domain are NOT enforced here — they are interactive (anti-replay / audience) -// concerns that a stateless verification pipeline cannot check. Only cryptographic validity -// of the holder proof is verified. +// DOCUMENT_INTEGRITY — the holder proof (crypto only) + every embedded credential's SIGNATURE. +// This fragment is strictly cryptographic: it does NOT judge temporal validity (expiry / +// not-yet-valid) or revocation of embedded credentials — those are DOCUMENT_STATUS concerns +// handled by `w3cVpCredentialStatus`. That is why the embedded credentials are checked with +// `verifyCredential` (signature-only) rather than `verifyPresentation`'s `credentialResults`, +// which fold expiry + revocation into each credential's `verified` flag. +// NOTE: challenge/domain are NOT enforced here either — they are interactive (anti-replay / +// audience) concerns that a stateless verification pipeline cannot check. // --------------------------------------------------------------------------- export const w3cVpSignatureIntegrity: Verifier = { skip: async () => ({ @@ -136,16 +198,28 @@ export const w3cVpSignatureIntegrity: Verifier = { }; } - // Pass the proof's own challenge/domain so an authentication proof verifies its crypto - // (this checks signature validity, NOT freshness — freshness is out of pipeline scope). + // Holder proof crypto. Pass the proof's own challenge/domain so an authentication proof + // verifies its crypto (this checks signature validity, NOT freshness — freshness is out + // of pipeline scope). We only consume `presentationResult` here; the aggregate `verified` + // and `credentialResults` also encode expiry/revocation, which are NOT integrity concerns. const result = await verifyPresentation(doc, { challenge: doc.proof?.challenge as string | undefined, domain: doc.proof?.domain as string | undefined, documentLoader: verifierOptions?.documentLoader, }); - - const credentialsValid = (result.credentialResults ?? []).every((r) => r.verified); const proofValid = result.presentationResult?.verified === true; + + // Embedded credentials — SIGNATURE only. `verifyCredential` verifies the proof crypto + // and does not assert expiry or revocation, so an expired-but-authentic credential still + // passes integrity and is caught downstream by `w3cVpCredentialStatus`. + const signatureResults = await Promise.all( + getCredentials(doc).map((cred) => + verifyCredential(cred, { documentLoader: verifierOptions?.documentLoader }), + ), + ); + const badSignatureIdx = signatureResults.findIndex((r) => !r.verified); + const credentialsValid = badSignatureIdx === -1; + // Holder binding: signer DID == holder == every credentialSubject.id. const bindingError = checkVpHolderBinding(doc); const valid = credentialsValid && proofValid && !bindingError; @@ -157,26 +231,35 @@ export const w3cVpSignatureIntegrity: Verifier = { data: { holderProofVerified: true, holderBound: true, - credentialResults: result.credentialResults, + credentialResults: signatureResults, }, status: 'VALID', }; } + + // Compose the failure reason as a flat if-chain (no nested ternaries). + let message: string; + if (!proofValid) { + message = result.presentationResult?.error ?? 'Presentation proof is invalid.'; + } else if (bindingError) { + message = bindingError; + } else if (badSignatureIdx !== -1) { + const detail = signatureResults[badSignatureIdx].error; + message = `Embedded credential at index ${badSignatureIdx} has an invalid signature${ + detail ? `: ${detail}` : '.' + }`; + } else { + message = 'An embedded credential signature is invalid.'; + } return { type: 'DOCUMENT_INTEGRITY', name: 'W3CVpSignatureIntegrity', data: { holderProofVerified: proofValid, holderBound: !bindingError, - credentialResults: result.credentialResults, - }, - reason: { - message: !proofValid - ? (result.presentationResult?.error ?? 'Presentation proof is invalid.') - : (bindingError ?? - result.credentialResults?.find((r) => !r.verified)?.error ?? - 'An embedded credential is invalid.'), + credentialResults: signatureResults, }, + reason: { message }, status: 'INVALID', }; }, @@ -202,8 +285,17 @@ export const w3cVpCredentialStatus: Verifier = { verify: async (document: unknown, verifierOptions: VerifierOptions) => { const doc = document as VerifiablePresentation; - // VP expiry (validUntil / expirationDate). + // VP expiry (validUntil / expirationDate). A present-but-unparseable value is rejected — + // it must not be silently treated as "not expired". const validUntil = (doc.validUntil ?? doc.expirationDate) as string | undefined; + if (validUntil !== undefined && !isValidDate(validUntil)) { + return { + type: 'DOCUMENT_STATUS', + name: 'W3CVpCredentialStatus', + reason: { message: `Presentation has an unparseable validUntil ("${validUntil}").` }, + status: 'INVALID', + }; + } if (validUntil && new Date() > new Date(validUntil)) { return { type: 'DOCUMENT_STATUS', @@ -214,57 +306,84 @@ export const w3cVpCredentialStatus: Verifier = { }; } - // Embedded credentials' revocation status. const credentials = getCredentials(doc); - const allStatuses = credentials.flatMap((cred) => - toArray(cred.credentialStatus as CredentialStatus | CredentialStatus[] | undefined), - ); - // A status entry whose type we cannot evaluate must NOT be silently dropped (that would - // report VALID while revocation is unenforced). Surface it as ERROR. - const unsupported = allStatuses.filter( - (cs) => cs?.type && !SUPPORTED_STATUS_TYPES.has(cs.type), + // Embedded credentials' temporal validity (unparseable / expired / not-yet-valid). This is + // where the integrity fragment used to implicitly catch expiry via `verifyPresentation`; + // temporal validity is a STATUS concern, so it lives here next to VP expiry and revocation. + const temporalError = findEmbeddedTemporalError(credentials, new Date()); + if (temporalError) { + return { + type: 'DOCUMENT_STATUS', + name: 'W3CVpCredentialStatus', + data: temporalError.data, + reason: { message: temporalError.message }, + status: 'INVALID', + }; + } + + // Embedded credentials' revocation status. Each status entry keeps its owning + // credential index so a revoked/error result can name it (parity with the temporal + // checks above); VP expiry stays index-less because it is the envelope, not a credential. + const statusEntries = credentials.flatMap((cred, i) => + toArray(cred.credentialStatus as CredentialStatus | CredentialStatus[] | undefined).map( + (cs) => ({ cs, credentialIndex: i }), + ), ); + + // A status entry we cannot evaluate must NOT be silently dropped (that would report VALID + // while revocation is unenforced). A MISSING type counts as unevaluable too — otherwise a + // `credentialStatus: {}` would fall through both filters and skip revocation. Surface as + // ERROR, naming each offending credential index. + const unsupported = statusEntries.filter(({ cs }) => !SUPPORTED_STATUS_TYPES.has(cs?.type)); if (unsupported.length > 0) { - const types = [...new Set(unsupported.map((cs) => cs.type))].join(', '); + const detail = unsupported + .map( + ({ cs, credentialIndex }) => `index ${credentialIndex} (${cs?.type ?? 'missing type'})`, + ) + .join(', '); return { type: 'DOCUMENT_STATUS', name: 'W3CVpCredentialStatus', - reason: { message: `Unsupported credentialStatus type(s) cannot be verified: ${types}.` }, + reason: { message: `Unsupported or missing credentialStatus type at ${detail}.` }, status: 'ERROR', }; } + const supported = statusEntries.filter(({ cs }) => SUPPORTED_STATUS_TYPES.has(cs?.type)); const statusChecks = await Promise.all( - allStatuses - .filter((cs) => SUPPORTED_STATUS_TYPES.has(cs?.type)) - .map((cs) => - verifyCredentialStatus( - cs as BitstringStatusListCredentialStatus, - cs.type as CredentialStatusType, - verifierOptions, - ), + supported.map(({ cs }) => + verifyCredentialStatus( + cs as BitstringStatusListCredentialStatus, + cs.type as CredentialStatusType, + verifierOptions, ), + ), ); - const revoked = statusChecks.find((r) => r.status === true); - if (revoked) { + const revokedIdx = statusChecks.findIndex((r) => r.status === true); + if (revokedIdx !== -1) { + const revoked = statusChecks[revokedIdx]; + const credentialIndex = supported[revokedIdx].credentialIndex; return { type: 'DOCUMENT_STATUS', name: 'W3CVpCredentialStatus', - data: { revoked: true }, + data: { revoked: true, credentialIndex }, reason: { - message: `An embedded credential has been revoked (status purpose "${revoked.purpose ?? 'revocation'}").`, + message: `Embedded credential at index ${credentialIndex} has been revoked (status purpose "${revoked.purpose ?? 'revocation'}").`, }, status: 'INVALID', }; } - const statusError = statusChecks.find((r) => r.error); - if (statusError) { + const errorIdx = statusChecks.findIndex((r) => r.error); + if (errorIdx !== -1) { + const credentialIndex = supported[errorIdx].credentialIndex; return { type: 'DOCUMENT_STATUS', name: 'W3CVpCredentialStatus', - reason: { message: `Could not verify an embedded credential status: ${statusError.error}` }, + reason: { + message: `Could not verify status of embedded credential at index ${credentialIndex}: ${statusChecks[errorIdx].error}`, + }, status: 'ERROR', }; } @@ -301,8 +420,8 @@ export const w3cVpIssuerIdentity: Verifier = { // Every embedded credential must declare an issuer — a missing issuer cannot be // resolved, so it must fail rather than be silently dropped. - const missing = issuerIds.filter((id) => !id).length; - if (credentials.length === 0 || missing > 0) { + const missingIndices = issuerIds.map((id, i) => (id ? -1 : i)).filter((i) => i !== -1); + if (credentials.length === 0 || missingIndices.length > 0) { return { type: 'ISSUER_IDENTITY', name: 'W3CVpIssuerIdentity', @@ -310,7 +429,7 @@ export const w3cVpIssuerIdentity: Verifier = { message: credentials.length === 0 ? 'Presentation contains no verifiable credentials.' - : `${missing} embedded credential(s) have no issuer.`, + : `Embedded credential(s) at index ${missingIndices.join(', ')} have no issuer.`, }, status: 'INVALID', }; @@ -329,12 +448,20 @@ export const w3cVpIssuerIdentity: Verifier = { status: 'VALID', }; } - const unresolved = issuers.filter((_, i) => !resolved[i]); + // Report both the credential index and the DID: the index locates the offending + // credential (parity with the other branches), the DID says what failed to resolve. + const unresolved = issuers + .map((did, i) => ({ did, credentialIndex: i })) + .filter(({ credentialIndex }) => !resolved[credentialIndex]); return { type: 'ISSUER_IDENTITY', name: 'W3CVpIssuerIdentity', data: { issuers, unresolved }, - reason: { message: `Could not resolve issuer(s): ${unresolved.join(', ')}.` }, + reason: { + message: `Could not resolve issuer(s): ${unresolved + .map(({ did, credentialIndex }) => `index ${credentialIndex} (${did})`) + .join(', ')}.`, + }, status: 'INVALID', }; },