diff --git a/CHANGELOG.md b/CHANGELOG.md index 576139d..6174ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,11 @@ not run after a fatal failure. summary has no `verified` field: the only verdict is the result-level `verified` ("no fatal check failed"). `status` comes from the suite's counts, so a non-fatal failure shows as `status: 'failure'` or `'mixed'` - inside a result that is still `verified: true`. The one suite whose check - failed fatally carries `fatalFailureAt`, and a result is unverified exactly - when one of its summaries has it; UIs should colour that suite as an error - and other failing suites as warnings. + inside a result that is still `verified: true`. Each suite whose check + failed fatally carries `fatalFailureAt` — more than one can, since some + fatal checks do not halt — and a result is unverified exactly when any of + its summaries has it; UIs should colour those suites as errors and other + failing suites as warnings. - Required `id: string` field on `CheckResult` — dot-separated `..` namespace, set by the suite runner on every row it emits (including not-run rows and synthetic `.applies` @@ -46,12 +47,21 @@ not run after a fatal failure. - `TimeService` interface plus `RealTimeService` and `FakeTimeService` factories. New optional `timeService` on `VerifierConfig` (defaults to `RealTimeService()`). Available on `VerificationContext.timeService` to any - check that needs to ask "what time is it?": `proof.signature` judges + check that needs to ask "what time is it?": `proof.validity-period` judges credential validity dates against it and `status.bitstring` judges status-list freshness against it, so a verifier built on a `FakeTimeService` decides both at the pinned moment. Signature clock-skew and key rotation are still ahead. - `CREDENTIAL_EXPIRED` and `CREDENTIAL_NOT_YET_VALID` on `ProblemTypes`, under the same URI namespace as the other synthesized types. +- `proof.validity-period` check (`cryptographic.proof.validity-period`), run + after `proof.signature` for credentials. It judges `validFrom` / `validUntil` + (v2) or `issuanceDate` / `expirationDate` (v1) against the verification + context's `TimeService`, and reports `CREDENTIAL_EXPIRED` / + `CREDENTIAL_NOT_YET_VALID`. +- Optional `halts?: boolean` on `VerificationCheck` and `CheckResult`. It + defaults to `fatal`; a fatal check with `halts: false` still makes `verified` + false, but the checks after it still run. `proof.validity-period` and + `status.bitstring` set it. - `VERIFICATION_METHOD_ERROR` on `ProblemTypes`, under the same URI namespace. Reported by `proof.signature` when the proof's verification method cannot be found, its controller has not authorized it for the proof purpose, or the @@ -142,10 +152,24 @@ not run after a fatal failure. `INVALID_SIGNATURE` / "Invalid Signature", the same verdict as a forgery.** `@digitalcredentials/vc` reads validity dates only after the proof verifies, so a date failure is positive evidence the credential is authentic. - `proof.signature` now reports `CREDENTIAL_EXPIRED` ("Credential Expired") when - `validUntil` / `expirationDate` has passed, and `CREDENTIAL_NOT_YET_VALID` - ("Credential Not Yet Valid") when `validFrom` / `issuanceDate` is still in the - future. Both remain fatal, and expiry remains part of the proof check. + `proof.signature` now reads a date-only rejection as a verified signature, and + the new `proof.validity-period` check reports `CREDENTIAL_EXPIRED` + ("Credential Expired") when `validUntil` / `expirationDate` has passed, and + `CREDENTIAL_NOT_YET_VALID` ("Credential Not Yet Valid") when `validFrom` / + `issuanceDate` is still in the future. Both are fatal, so `verified` is + `false`, but neither halts: an expired credential still reports whether it + was revoked and whether its issuer is registered. The crypto service + recognises a date failure only from `@digitalcredentials/vc`'s own + unwrapped error and exact wording, so a tampered credential that is also + expired is still reported as a bad signature. +- **A revoked or suspended credential no longer hides its issuer.** + `status.bitstring` stays fatal but no longer halts, so `status.list-issuer`, + `trust.registry.issuer` and any caller-added suites still run after a + revocation, a suspension, or a status list that could not be trusted. + `status.list-issuer` compares only lists whose proof verified, and now + reports `STATUS_LIST_ISSUER_MISMATCH` for a list that marks the credential + revoked, too: previously a revocation read from a list signed by someone + else was reported as a plain revocation. - **Valid credentials no longer fail as `INVALID_SIGNATURE` because the verifier rewrote them.** `verifyCredential` / `verifyPresentation` previously ran the suites against the Zod parse output rather than the document the issuer diff --git a/README.md b/README.md index e5bd08c..0979263 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Verification runs an ordered pipeline of **suites**, each containing one or more | --------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | | **Core** | `cryptographic` | `@context` exists, VC context URI present, VC structure, VP structure, credential ID valid, proof exists | Yes | | **Recognition** | `recognition` | Pluggable credential-profile recognition; produces a normalized credential form (no-op when no recognizers configured) | No | -| **Proof** | `cryptographic` | Cryptographic signature verification | Yes | +| **Proof** | `cryptographic` | Cryptographic signature verification (`proof.signature`), then the credential's validity dates against the verifier's clock (`proof.validity-period`) | Yes | | **Status** | `cryptographic` | Revocation/suspension via BitstringStatusList — sole owner of status verification. `status.bitstring` checks every revocation and suspension `BitstringStatusListEntry`, verifying each list credential's proof with the configured `cryptoServices`. `status.list-issuer` warns when a list is not issued by the credential's issuer. | Yes (`status.list-issuer`: No) | | **Registry** | `trust` | Issuer DID lookup in known trust registries | No | @@ -226,6 +226,15 @@ check — the ones that passed, the ones that were skipped (with a reason), and the ones that were not run after a fatal failure — while `summary` provides the per-suite rollup (see [Per-suite summaries](#per-suite-summaries)). +A fatal failure normally halts every later check for the credential. Two fatal +checks do not halt, because the credential is already known to be authentic when +they run: `proof.validity-period` (expired or not yet valid) and +`status.bitstring` (revoked, suspended, or its status list could not be +trusted). They still make `verified` false, but the checks after them still run, +so an expired credential still reports whether it was revoked, and a revoked one +still reports whether its issuer is registered. A check of your own can do the +same by setting `halts: false` alongside `fatal: true`. + Each `CheckResult` contains a discriminated `CheckOutcome`: ```typescript @@ -260,6 +269,7 @@ interface ProblemDetail { { "id": "cryptographic.core.proof-exists", "suite": "core", "outcome": { "status": "success", "message": "..." } }, { "id": "recognition.profile", "suite": "recognition", "outcome": { "status": "skipped", "reason": "no recognizer matched" } }, { "id": "cryptographic.proof.signature", "suite": "proof", "outcome": { "status": "success", "message": "Signature verified successfully." } }, + { "id": "cryptographic.proof.validity-period", "suite": "proof", "outcome": { "status": "success", "message": "The credential is within its validity period." } }, { "id": "cryptographic.status.bitstring", "suite": "status", "outcome": { "status": "success", "message": "Credential status is valid (not revoked or suspended)." } }, { "id": "cryptographic.status.list-issuer", "suite": "status", "outcome": { "status": "success", "message": "Every status list is issued by the credential's issuer." } }, { "id": "trust.registry.issuer", "suite": "registry", "outcome": { @@ -342,6 +352,14 @@ halted. Each check that would have run appears as `skipped` with reason ] } }, + { + "id": "cryptographic.proof.validity-period", + "suite": "proof", + "outcome": { + "status": "skipped", + "reason": "Not run: proof.signature failed" + } + }, { "id": "cryptographic.status.bitstring", "suite": "status", @@ -370,12 +388,82 @@ halted. Each check that would have run appears as `skipped` with reason } ``` +#### Example: Expired credential (fatal, does not halt) + +An expired credential is authentic — its signature verified — but outside its +validity period. `proof.validity-period` fails it, so `verified` is `false`, but +does not halt: the status, list-issuer and registry checks still run, and here +the status list also marks it revoked. Abbreviated to the rows that matter: + +```json +{ + "verified": false, + "results": [ + { + "id": "cryptographic.proof.signature", + "suite": "proof", + "outcome": { + "status": "success", + "message": "Signature verified successfully." + } + }, + { + "id": "cryptographic.proof.validity-period", + "suite": "proof", + "outcome": { + "status": "failure", + "problems": [ + { + "type": "https://www.w3.org/TR/vc-data-model#CREDENTIAL_EXPIRED", + "title": "Credential Expired", + "detail": "The current date time (2026-10-20T12:00:00.000Z) is after \"validUntil\" (2021-01-01T00:00:00Z)." + } + ] + } + }, + { + "id": "cryptographic.status.bitstring", + "suite": "status", + "outcome": { + "status": "failure", + "problems": [ + { + "type": "https://www.w3.org/TR/vc-data-model#CREDENTIAL_REVOKED", + "title": "Credential Revoked", + "detail": "The status list https://example.edu/status/1 marks the credential revoked (index 42)." + } + ] + } + }, + { + "id": "cryptographic.status.list-issuer", + "suite": "status", + "outcome": { + "status": "success", + "message": "Every status list is issued by the credential's issuer." + } + }, + { + "id": "trust.registry.issuer", + "suite": "registry", + "outcome": { + "status": "success", + "message": "Issuer found in registry: DCC Sandbox Registry" + } + } + ] +} +``` + +Both the `proof` and the `status` summaries carry `fatalFailureAt`. + #### Example: Revoked credential (fatal, sourced from status) When a credential's status list marks it revoked or suspended — or the verifier can't confidently evaluate the list (missing, expired or not yet valid, wrong type, signature invalid) — the status suite fails the credential. The proof -check still passes on its own merits. +check still passes on its own merits, and because `status.bitstring` does not +halt, the list-issuer and registry checks still run. Every `BitstringStatusListEntry` whose `statusPurpose` is `revocation` or `suspension` is checked, and each set bit is its own problem: @@ -444,16 +532,16 @@ not fetched, and the check's message or skip reason names them. "id": "cryptographic.status.list-issuer", "suite": "status", "outcome": { - "status": "skipped", - "reason": "Not run: status.bitstring failed" + "status": "success", + "message": "Every status list is issued by the credential's issuer." } }, { "id": "trust.registry.issuer", "suite": "registry", "outcome": { - "status": "skipped", - "reason": "Not run: status.bitstring failed" + "status": "success", + "message": "Issuer found in registry: DCC Sandbox Registry" } } ] @@ -464,9 +552,11 @@ not fetched, and the check's message or skip reason names them. `status.list-issuer` compares each status list's `issuer` id with the credential's (a string issuer, or an issuer object's `id`, compared exactly). -The list's proof has already been verified by `status.bitstring`, which binds -the list's signing key to its claimed issuer. When they differ, the check fails -with `STATUS_LIST_ISSUER_MISMATCH`: +It compares only lists whose proof verified, which binds the list's signing key +to its claimed issuer; a list whose proof did not verify is left to +`status.bitstring`, and this check is skipped. That includes a list that marks +the credential revoked, so a revocation read from someone else's list says so. +When they differ, the check fails with `STATUS_LIST_ISSUER_MISMATCH`: ```json { @@ -531,12 +621,12 @@ All failures use `ProblemDetail` with a `type` URI. Common error types: | `...#ISSUER_NOT_REGISTERED` | Issuer Not Registered | Issuer DID not found in any registry that could be checked | | `...#REGISTRY_UNCHECKED` | Registry Unchecked | Some registries couldn't be reached | -`CREDENTIAL_EXPIRED` and `CREDENTIAL_NOT_YET_VALID` come from `proof.signature`, -alongside `INVALID_SIGNATURE`, and are fatal in the same way — but they mean the -opposite thing about the issuer. Validity dates are read only after the -signature has verified, so a credential that fails on its dates is authentic and -merely outside its window; tell your users that, not that the credential may be -forged. The date is judged against the verifier's `TimeService` (see +`CREDENTIAL_EXPIRED` and `CREDENTIAL_NOT_YET_VALID` come from +`proof.validity-period`, which runs only after `proof.signature` has verified. +They are fatal, like `INVALID_SIGNATURE`, but they mean the opposite thing about +the issuer: a credential that fails on its dates is authentic and merely outside +its window; tell your users that, not that the credential may be forged. They do +not halt, so the credential's status and issuer are still reported. The date is judged against the verifier's `TimeService` (see [Pluggable clock](#pluggable-clock-timeservice)), the same clock the status check uses for status-list freshness. @@ -881,7 +971,7 @@ table, and recipes. `createVerifier(...)` in tests to make every `TaskTiming` field exact-value-assertable. Both factories are exported from the package barrel. -The same clock decides validity dates: `proof.signature` judges a credential's +The same clock decides validity dates: `proof.validity-period` judges a credential's `validUntil` / `expirationDate` and `validFrom` / `issuanceDate` against it, and `status.bitstring` judges status-list freshness against it, so the two can never disagree within a verification. A verifier built on a `FakeTimeService` pinned diff --git a/docs/api/verification-results.md b/docs/api/verification-results.md index 369c204..694eaba 100644 --- a/docs/api/verification-results.md +++ b/docs/api/verification-results.md @@ -27,8 +27,10 @@ `results[]` carries **every** check: the ones that passed, the ones that were skipped (with the reason), and the ones that were not run because an -earlier fatal failure halted the pipeline. `summary[]`, new in v2.0.0, is -the per-suite rollup over those rows. +earlier fatal failure halted the pipeline (see +[Fatal failures and `verified`](#fatal-failures-and-verified) for the two +fatal checks that do not halt). `summary[]`, new in v2.0.0, is the per-suite +rollup over those rows. ### The top-level verdict @@ -71,6 +73,7 @@ A successful single-credential verification: "outcome": { "status": "skipped", "reason": "no recognizer matched" } }, { "id": "cryptographic.proof.signature", /* success */ }, + { "id": "cryptographic.proof.validity-period", /* success */ }, { "id": "cryptographic.status.bitstring", "suite": "status", "outcome": { "status": "skipped", @@ -107,8 +110,8 @@ A successful single-credential verification: "counts": { "passed": 0, "failed": 0, "skipped": 1 } }, { "id": "cryptographic.proof", "phase": "cryptographic", "suite": "proof", "status": "success", - "message": "1 of 1 check passed", - "counts": { "passed": 1, "failed": 0, "skipped": 0 } }, + "message": "2 of 2 checks passed", + "counts": { "passed": 2, "failed": 0, "skipped": 0 } }, { "id": "cryptographic.status", "phase": "cryptographic", "suite": "status", "status": "skipped", "message": "2 of 2 checks skipped", @@ -268,9 +271,9 @@ interface SuiteSummary { message: string; counts: { passed: number; failed: number; skipped: number }; /** - * The fully-qualified id of the check in this suite that failed - * fatally, if one did. At most one suite per subject carries it, - * and that suite is why the subject is not verified. + * The fully-qualified id of the first check in this suite that + * failed fatally, if one did. Every suite that carries it is a + * reason the subject is not verified; more than one can. */ fatalFailureAt?: string; } @@ -284,12 +287,12 @@ interface SuiteSummary { | All skipped | `" of checks skipped"` | | Failures only | `" of checks failed"` | | Mixed | `" of checks failed ( passed)"` | -| Fatal short-circuit | `" of checks failed ( passed, not run after fatal)"` (either tail clause may be omitted when zero) | +| Fatal short-circuit | `" of checks failed ( passed, not run after fatal)"` (either tail clause may be omitted when zero; a fatal check that does not halt leaves no not-run rows, so it reads like "Failures only" or "Mixed") | | Halted by an earlier suite's fatal failure | `" not run: failed"` | | Explicit `applies` skip | `" not applicable: "` | > "Not run" rows are the `skipped` results `runSuites` emits, with reason -> `"Not run: failed"`, for checks halted by a fatal failure. They +> `"Not run: failed"`, for checks halted by a halting fatal failure. They > count toward `counts.skipped` but not toward the "skipped" wording: the > message reports them as "not run after fatal" instead. @@ -311,11 +314,25 @@ result whose `verified` is still `true`. ### Fatal failures and `verified` -A fatal failure halts every later check for the subject, so a subject -has at most one, and at most one of its summaries carries -`fatalFailureAt`. That gives one invariant to rely on: - -> A credential's `verified` is `false` exactly when one of its +A fatal failure makes `verified` false. Most fatal checks also **halt**: +every later check for the subject is reported as not run. Two built-in +fatal checks do not halt, because by the time they run the credential is +known to be authentic, and what the later checks say still matters: + +- `cryptographic.proof.validity-period` — expired or not yet valid. The + credential's status, list issuer and registry membership are still + reported. +- `cryptographic.status.bitstring` — revoked, suspended, or its status + list could not be trusted. The list issuer and registry membership are + still reported. + +A check marks this with `halts: false` alongside `fatal: true`, which a +caller-supplied check can do too; the row carries `halts: false` as well. +So a subject can have more than one fatal failure, and more than one of +its summaries can carry `fatalFailureAt` — an expired, revoked credential +has both `proof` and `status`. The invariant to rely on: + +> A credential's `verified` is `false` exactly when any of its > `summary[]` entries has `fatalFailureAt`. The same holds for a presentation's own checks and its `summary[]`; the @@ -335,7 +352,7 @@ non-fatal failure never reads as red inside a verified credential: | `fatalFailureAt` is set | ✗ | red | This suite's failure is why the subject is not verified | | `status` is `failure` or `mixed`, no `fatalFailureAt` | ⚠ | amber | Warning: a non-fatal check failed; `verified` is unaffected | | `status === 'success'` | ✓ | green | Every check that ran passed | -| `status === 'skipped'` | — | gray | Nothing ran (not applicable, or halted by an earlier fatal failure) | +| `status === 'skipped'` | — | gray | Nothing ran (not applicable, or halted by an earlier halting fatal failure) | ## `id` namespace reference @@ -363,6 +380,7 @@ cryptographic.core cryptographic.core.proof-exists cryptographic.proof cryptographic.proof.signature + cryptographic.proof.validity-period (credentials only) cryptographic.status cryptographic.status.bitstring cryptographic.status.list-issuer @@ -503,7 +521,7 @@ checks, the second failing a fatal one — gets rendered as: Presentation: ✗ (1 of 2 credentials verified) ├─ VP signature: ✓ ├─ Credential 1 (obv3p0.openbadge): ✓ — verified, with advisory failures -│ ├─ cryptographic ✓ (4 + 1 + 1 = 6 checks passed) +│ ├─ cryptographic ✓ (4 + 2 + 1 = 7 checks passed) │ ├─ trust ⚠ (1 of 1 check failed — advisory) │ │ └─ trust.registry.issuer — Issuer Not Registered │ ├─ recognition ✓ diff --git a/docs/architecture.md b/docs/architecture.md index cb6ceca..457ac56 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -310,7 +310,7 @@ CheckOutcome └── { status: 'skipped', reason } │ ▼ -CheckResult: { id, suite, outcome, fatal?, timing? } +CheckResult: { id, suite, outcome, fatal?, halts?, timing? } ``` `timing` is opt-in: present on every `CheckResult`, @@ -323,12 +323,17 @@ break when the flag is left at its default (`false`). See - **`appliesTo`** limits a check to credential-only or presentation-only subjects. If unset, the check runs for both. -- **`fatal`** on a check means a failure halts every remaining check for the subject, in that - suite and in all later suites: once the subject is known to be invalid, later checks would - only report misleading follow-on failures or do needless network I/O. Each check that would - have run still appears in the report, as `skipped` with reason `Not run: failed`, - so the report stays complete. Suites that would not have run anyway (phase filter, `applies` - false) stay silent. +- **`fatal`** on a check means a failure makes the subject's `verified` false. +- **`halts`** says whether a fatal failure also halts every remaining check for the subject, in + that suite and in all later suites, and defaults to `fatal`: once the subject may not be what + it claims, later checks would only report misleading follow-on failures or do needless network + I/O. Each check that would have run still appears in the report, as `skipped` with reason + `Not run: failed`, so the report stays complete. Suites that would not have run + anyway (phase filter, `applies` false) stay silent. Two built-in fatal checks set + `halts: false`, because they run once the credential is known to be authentic and what follows + still matters: `proof.validity-period` (an expired credential's status and issuer are still + reported) and `status.bitstring` (a revoked credential's list issuer and registry membership + are still reported). A throwing `applies` predicate halts regardless. - **Throws are contained.** A check whose `execute` throws yields a `failure` carrying `CHECK_ERROR`, keeping the check's own `fatal` flag. A suite whose `applies` predicate throws yields a `.applies` `CHECK_ERROR` failure, fatal when the suite has any fatal check. @@ -346,8 +351,8 @@ break when the flag is left at its default (`false`). See |--------------------|---------------|-----------------|--------------------------------------------------------------------------------|-------|------------------------------------------------------------------| | Core Structure | `core` | `cryptographic` | `core.context-exists`, `core.vc-context`, `core.vc-structure`, `core.credential-id`, `core.proof-exists` (credentials); `core.vp-structure` (presentations) | Yes | Validates basic VC / VP structure before crypto. Runs for presentations too, ahead of `proof`. `core.vc-structure` and `core.vp-structure` check the VC Data Model's structural requirements and report every violation, each with a JSON Pointer `instance`. | | Recognition | `recognition` | `recognition` | `recognition.profile` | No | Pluggable recognizer dispatch; produces normalized credential form. No-op when no recognizers configured. | -| Proof Verification | `proof` | `cryptographic` | `proof.signature` | Yes | Cryptographic signature verification dispatched via `CryptoService`, plus the credential's validity dates, which the verifying library reads once the signature holds: an authentic but out-of-date credential fails with `CREDENTIAL_EXPIRED` / `CREDENTIAL_NOT_YET_VALID` rather than `INVALID_SIGNATURE`, judged against `VerificationContext.timeService`. A verification method that is missing, unauthorized by its controller for the proof purpose, or controlled by someone other than the issuer fails with `VERIFICATION_METHOD_ERROR`, also distinct from `INVALID_SIGNATURE`; every signature failure takes its `detail` from the signature library's own message. Does **not** check credential status — see the status suite. | -| Credential Status | `status` | `cryptographic` | `status.bitstring`, `status.list-issuer` | Yes / No | Revocation/suspension via BitstringStatusList. **Sole owner** of status verification. `status.bitstring` (fatal) checks every `BitstringStatusListEntry` for `revocation` or `suspension`, ignoring other entries without fetching their lists: a missing, invalid, expired or not-yet-valid status list, a wrong-typed list, or a set bit (`CREDENTIAL_REVOKED` / `CREDENTIAL_SUSPENDED`, one per entry) fails the credential. The status list credential's own proof is verified through the injected `CryptoService`s, same dispatch as any other credential. `status.list-issuer` (non-fatal) reports `STATUS_LIST_ISSUER_MISMATCH` when a list's `issuer` is not the credential's; each list is fetched once per verification. | +| Proof Verification | `proof` | `cryptographic` | `proof.signature`, `proof.validity-period` | Yes | Cryptographic signature verification dispatched via `CryptoService`. The verifying library also reads the credential's validity dates once the signature holds, and the crypto service reports a date-only rejection as typed `CREDENTIAL_EXPIRED` / `CREDENTIAL_NOT_YET_VALID` problems, which `proof.signature` reads as a verified signature. `proof.validity-period` (fatal, does not halt) then judges those dates itself against `VerificationContext.timeService`, so an authentic but out-of-date credential fails there rather than as `INVALID_SIGNATURE`. A verification method that is missing, unauthorized by its controller for the proof purpose, or controlled by someone other than the issuer fails with `VERIFICATION_METHOD_ERROR`, also distinct from `INVALID_SIGNATURE`; every signature failure takes its `detail` from the signature library's own message. Does **not** check credential status — see the status suite. | +| Credential Status | `status` | `cryptographic` | `status.bitstring`, `status.list-issuer` | Yes / No | Revocation/suspension via BitstringStatusList. **Sole owner** of status verification. `status.bitstring` (fatal, does not halt) checks every `BitstringStatusListEntry` for `revocation` or `suspension`, ignoring other entries without fetching their lists: a missing, invalid, expired or not-yet-valid status list, a wrong-typed list, or a set bit (`CREDENTIAL_REVOKED` / `CREDENTIAL_SUSPENDED`, one per entry) fails the credential. The status list credential's own proof is verified through the injected `CryptoService`s, same dispatch as any other credential. `status.list-issuer` (non-fatal) reports `STATUS_LIST_ISSUER_MISMATCH` when a list's `issuer` is not the credential's, comparing only lists whose proof verified (or was rejected only for its dates) — including a list that marks the credential revoked; each list is fetched and its proof verified once per verification, shared by both checks. | | Issuer Registry | `registry` | `trust` | `registry.issuer` | No | Lookup issuer DID in known registries via `context.lookupIssuers` | Open Badges 3.0 verification (semantic checks and JSON Schema conformance) is no @@ -726,7 +731,7 @@ without network dependencies, and composable — consumers wire in exactly the b (`default-services.ts`) and are not exported. - **`TimeService` is the clock seam.** Wall-clock and monotonic time both flow through `VerificationContext.timeService` (default `RealTimeService`). The orchestrator - (`runSuites`) uses it to populate `TaskTiming` when `timing: true`; `proof.signature` and + (`runSuites`) uses it to populate `TaskTiming` when `timing: true`; `proof.validity-period` and `status.bitstring` read it to judge credential expiry and status-list freshness against the same moment, and any other time-sensitive decision (signature clock-skew window, key rotation) belongs there too. Tests pass `FakeTimeService` for deterministic diff --git a/src/problem-types.ts b/src/problem-types.ts index 37fd398..d01cda4 100644 --- a/src/problem-types.ts +++ b/src/problem-types.ts @@ -57,9 +57,11 @@ export const ProblemTypes = { 'https://www.w3.org/TR/vc-data-model#VERIFICATION_METHOD_ERROR', /** * Synthesized — the credential's validity has ended (`validUntil` / - * `expirationDate` is in the past). Reported by the signature check, - * which is where validity dates are judged: the signature verified, - * so the credential is authentic and merely out of date. + * `expirationDate` is in the past). Reported by + * `proof.validity-period`, which runs only once the signature + * verified, so the credential is authentic and merely out of date. + * A crypto service may also report it for a document whose proof + * verified; `proof.signature` then reads the signature as verified. */ CREDENTIAL_EXPIRED: 'https://www.w3.org/TR/vc-data-model#CREDENTIAL_EXPIRED', /** diff --git a/src/run-suites.ts b/src/run-suites.ts index 2f8d651..b36ff2c 100644 --- a/src/run-suites.ts +++ b/src/run-suites.ts @@ -7,10 +7,12 @@ * and returns a flat `CheckResult[]` report. * * Invariants: - * - The first fatal failure halts the subject: no later check executes, - * in the same suite or any later one. Each check that would have run is - * reported as a `'skipped'` row with reason - * `Not run: failed`, so the report still lists it. + * - The first failure of a halting check — fatal, and `halts` not + * `false` — halts the subject: no later check executes, in the same + * suite or any later one. Each check that would have run is reported + * as a `'skipped'` row with reason `Not run: failed`, so the + * report still lists it. A fatal check with `halts: false` still counts + * against `verified`, but later checks run. * - A throw never escapes. A throwing `execute` or `applies` becomes a * `failure` result carrying {@link ProblemTypes.CHECK_ERROR}, so * `runSuites` always resolves. @@ -123,9 +125,10 @@ function appliesToSubject( * - Checks with `appliesTo` restrictions are skipped if they don't match the subject. * - A throwing check becomes a `failure` carrying `CHECK_ERROR`, and * keeps the check's own `fatal` flag. - * - A fatal failure halts every remaining check for the subject, in - * this suite and all later suites. Each check that would have run is - * reported as `'skipped'` with reason `Not run: failed`. + * - A failure of a halting check (see {@link haltsOnFailure}) halts + * every remaining check for the subject, in this suite and all later + * suites. Each check that would have run is reported as `'skipped'` + * with reason `Not run: failed`. * Phase filtering and `applies` are still evaluated after a halt, so * suites that would not have run stay silent. * - Returns a flat array of all check results. @@ -139,7 +142,7 @@ export async function runSuites( const results: CheckResult[] = []; const timing = context.timing === true; const timeService = context.timeService; - /** Local id of the first fatal failure; once set, no check executes. */ + /** Local id of the first halting failure; once set, no check executes. */ let haltedBy: string | undefined; for (const suite of suites) { @@ -152,6 +155,8 @@ export async function runSuites( try { applies = suite.applies(subject, context); } catch (error) { + // A throwing predicate leaves every check in the suite unknown, so + // any fatal check halts here, whatever its `halts` says. const fatal = suite.checks.some(c => c.fatal === true); results.push( buildAppliesErrorResult(suite, error, fatal, timing, timeService) @@ -199,14 +204,15 @@ export async function runSuites( id: computeId(suite.phase, suite.id, check.id), suite: suite.id, outcome, - fatal: check.fatal + fatal: check.fatal, + ...haltsField(check) }; if (finishedTiming !== undefined) { result.timing = finishedTiming; } results.push(result); - if (check.fatal && outcome.status === 'failure') { + if (haltsOnFailure(check) && outcome.status === 'failure') { haltedBy = check.id; } } @@ -215,6 +221,19 @@ export async function runSuites( return results; } +/** Whether a failure of `check` halts the subject: fatal, and not opted out. */ +function haltsOnFailure(check: VerificationCheck): boolean { + return check.fatal === true && check.halts !== false; +} + +/** + * Internal: `{ halts }` when the check sets it, so rows of checks that + * leave it unset keep their existing shape. + */ +function haltsField(check: VerificationCheck): { halts?: boolean } { + return check.halts !== undefined ? { halts: check.halts } : {}; +} + /** * Internal: turn a throw from `check.execute` into a `failure` * outcome carrying {@link ProblemTypes.CHECK_ERROR}. Only the @@ -276,7 +295,8 @@ function buildNotRunResult( status: 'skipped', reason: `${NOT_RUN_REASON_PREFIX}${haltedBy} failed` }, - fatal: check.fatal + fatal: check.fatal, + ...haltsField(check) }; return withInstantTiming(result, timing, timeService); } diff --git a/src/services/data-integrity-crypto.ts b/src/services/data-integrity-crypto.ts index 8329e63..8c46a0e 100644 --- a/src/services/data-integrity-crypto.ts +++ b/src/services/data-integrity-crypto.ts @@ -17,6 +17,7 @@ import type { VerificationSubject } from '../types/subject.js'; import { ProblemTypes } from '../problem-types.js'; import { documentHasProof } from '../util/document-has-proof.js'; import { isVerificationMethodMessage } from '../util/verification-method-messages.js'; +import { vcDateFault } from '../util/vc-date-messages.js'; /** * No-op `checkStatus` passed to `@digitalcredentials/vc` so its @@ -104,6 +105,27 @@ export function classifySignatureError( error: unknown, credential: Record | undefined ): ProblemDetail[] { + // vc checks validity dates only after the proof verified, and reports a + // date failure as a plain Error rather than a VerificationError. Say so + // with a typed problem: it is how `proof.signature` knows the proof + // verified, and how `status.bitstring` tells an expired list from a bad one. + const dateFault = vcDateFault(error); + if (dateFault !== undefined) { + return [ + dateFault === 'expired' + ? { + type: ProblemTypes.CREDENTIAL_EXPIRED, + title: 'Credential Expired', + detail: (error as Error).message + } + : { + type: ProblemTypes.CREDENTIAL_NOT_YET_VALID, + title: 'Credential Not Yet Valid', + detail: (error as Error).message + } + ]; + } + const errors = extractErrors(error); if (isJsonLdError(errors)) { diff --git a/src/suites/proof/index.ts b/src/suites/proof/index.ts index 4baec48..4c3cea4 100644 --- a/src/suites/proof/index.ts +++ b/src/suites/proof/index.ts @@ -1,5 +1,6 @@ import { VerificationSuite } from '../../types/check.js'; import { signatureCheck } from './signature-check.js'; +import { validityPeriodCheck } from './validity-period-check.js'; /** * Proof verification suite. @@ -12,11 +13,15 @@ import { signatureCheck } from './signature-check.js'; * `@digitalcredentials/jsonld-signatures`, handles AuthenticationProofPurpose vs * AssertionProofPurpose for presentations, and maps library errors to * `ProblemDetail` entries. + * + * `proof.validity-period` follows the signature check, so a failed + * signature halts it, and judges the credential's validity dates. */ export const proofSuite: VerificationSuite = { id: 'proof', name: 'Proof Verification', - description: 'Cryptographic signature verification.', + description: + "Cryptographic signature verification, and the credential's validity period.", phase: 'cryptographic', - checks: [signatureCheck] + checks: [signatureCheck, validityPeriodCheck] }; diff --git a/src/suites/proof/signature-check.ts b/src/suites/proof/signature-check.ts index ea5385c..43fb03d 100644 --- a/src/suites/proof/signature-check.ts +++ b/src/suites/proof/signature-check.ts @@ -5,7 +5,7 @@ import { VerificationContext } from '../../types/context.js'; import { ProblemTypes } from '../../problem-types.js'; import { dispatchProofVerification } from '../../crypto-dispatch.js'; import { documentHasProof } from '../../util/document-has-proof.js'; -import { findVcDateProblem } from '../../util/vc-date-messages.js'; +import { isValidityDateOnly } from '../../util/validity-date-problems.js'; const NO_APPLICABLE_SERVICE: ProblemDetail = { type: ProblemTypes.PROOF_VERIFICATION_ERROR, @@ -31,41 +31,6 @@ const CHALLENGE_REQUIRED: ProblemDetail = { const UNSIGNED_PRESENTATION_ACCEPTED = 'Presentation is unsigned; accepted because unsignedPresentation is set.'; -/** - * Classify a crypto service's rejection. - * - * `@digitalcredentials/vc` checks validity dates only after the - * signature has verified, so a date error in the rejection proves the - * document is authentic and merely out of date. Saying "Invalid - * Signature" there accuses an honest issuer of forgery, so an expired - * or not-yet-valid credential gets its own problem type. Matching is on - * vc's message text, carried in the problem's `detail` — the same - * patterns `status.bitstring` uses for a status list credential. - * - * Still fatal either way: the credential is not currently usable. - */ -function rejectedProblems(problems: ProblemDetail[]): ProblemDetail[] { - const dated = findVcDateProblem(problems); - if (dated === undefined) { - return problems; - } - return dated.fault === 'expired' - ? [ - { - type: ProblemTypes.CREDENTIAL_EXPIRED, - title: 'Credential Expired', - detail: dated.detail - } - ] - : [ - { - type: ProblemTypes.CREDENTIAL_NOT_YET_VALID, - title: 'Credential Not Yet Valid', - detail: dated.detail - } - ]; -} - /** * Signature verification check — dispatches to {@link VerificationContext.cryptoServices}. * @@ -78,14 +43,14 @@ function rejectedProblems(problems: ProblemDetail[]): ProblemDetail[] { * {@link VerificationContext.domain}) is always enforced on a presentation's * proof. A domain without a challenge fails before dispatch. * - * This check also owns the credential's validity dates, because the - * library that verifies the proof is the one that reads them — and it - * reads them only once the signature has verified. The clock comes from - * {@link VerificationContext.timeService}, so a verifier built on a - * `FakeTimeService` judges expiry at the pinned time. An expired or - * not-yet-valid credential fails with `CREDENTIAL_EXPIRED` / - * `CREDENTIAL_NOT_YET_VALID` rather than `INVALID_SIGNATURE`; it is - * authentic, and still fatal. + * The credential's validity dates belong to `proof.validity-period`, + * which runs next. A crypto service may still reject for them: + * `@digitalcredentials/vc` judges the dates itself, and only once the + * proof has verified. A rejection made only of validity-date problems + * therefore proves the signature good, and reads as success here, so an + * out-of-date credential is not reported as forged and does not halt the + * checks after it. The clock passed to the crypto service comes from + * {@link VerificationContext.timeService}. */ export const signatureCheck: VerificationCheck = { id: 'proof.signature', @@ -152,10 +117,13 @@ export const signatureCheck: VerificationCheck = { message: dispatched.message ?? 'Signature verified successfully.' }; case 'rejected': - return { - status: 'failure', - problems: rejectedProblems(dispatched.problems) - }; + if (isValidityDateOnly(dispatched.problems)) { + return { + status: 'success', + message: 'Signature verified successfully.' + }; + } + return { status: 'failure', problems: dispatched.problems }; case 'no-service': return { status: 'failure', problems: [NO_APPLICABLE_SERVICE] }; case 'threw': diff --git a/src/suites/proof/validity-period-check.ts b/src/suites/proof/validity-period-check.ts new file mode 100644 index 0000000..9c482d4 --- /dev/null +++ b/src/suites/proof/validity-period-check.ts @@ -0,0 +1,118 @@ +import { VerificationCheck, CheckOutcome } from '../../types/check.js'; +import { ProblemDetail } from '../../types/problem-detail.js'; +import { VerificationSubject } from '../../types/subject.js'; +import { VerificationContext } from '../../types/context.js'; +import { ProblemTypes } from '../../problem-types.js'; +import { + firstContextEntry, + VC_CONTEXT_V1, + VC_CONTEXT_V2 +} from '../core/structure-helpers.js'; + +/** The credential's start and end dates, by the names its version uses. */ +interface ValidityProperties { + start: 'issuanceDate' | 'validFrom'; + end: 'expirationDate' | 'validUntil'; +} + +function validityProperties( + credential: Record +): ValidityProperties | undefined { + const firstContext = firstContextEntry(credential['@context']); + if (firstContext === VC_CONTEXT_V1) { + return { start: 'issuanceDate', end: 'expirationDate' }; + } + if (firstContext === VC_CONTEXT_V2) { + return { start: 'validFrom', end: 'validUntil' }; + } + return undefined; +} + +/** The date `value` names, or undefined when it names none. */ +function dateOf(value: unknown): Date | undefined { + if (typeof value !== 'string') { + return undefined; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +/** + * Validity period check: the credential's own dates against the + * verifier's clock. + * + * The version comes from the first `@context` entry, as in + * `@digitalcredentials/vc` and `core.vc-structure`: v1 reads + * `issuanceDate` / `expirationDate`, v2 `validFrom` / `validUntil`. The + * comparisons are vc's — expired once the clock is past the end, not yet + * valid while it is before the start — but every fault is reported, not + * only the first. A date that does not parse is left to + * `core.vc-structure`, which has already failed the credential for it. + * + * It runs after `proof.signature`, so by the time it runs the credential + * is authentic. Being out of date makes it unusable, so the check is + * fatal (`verified: false`); but whether it was also revoked, and whether + * its issuer is registered, still matter to a verifier, so it does not + * halt the checks after it. + */ +export const validityPeriodCheck: VerificationCheck = { + id: 'proof.validity-period', + name: 'Validity Period', + description: + "Checks the credential's validity dates against the verifier's clock.", + fatal: true, + halts: false, + appliesTo: ['verifiableCredential'], + execute: async ( + subject: VerificationSubject, + context: VerificationContext + ): Promise => { + const credential = subject.verifiableCredential as + | Record + | undefined; + if (!credential) { + return { + status: 'skipped', + reason: 'No verifiable credential found in subject.' + }; + } + + const properties = validityProperties(credential); + const startValue = properties ? credential[properties.start] : undefined; + const endValue = properties ? credential[properties.end] : undefined; + const start = dateOf(startValue); + const end = dateOf(endValue); + if (!properties || (start === undefined && end === undefined)) { + return { status: 'success', message: 'No validity period stated.' }; + } + + // Read separately from `proof.signature`'s clock, a few milliseconds + // later; that can only matter at the exact boundary. A context built + // directly in a test may carry no clock; the production factory + // always sets one. + const now = new Date(context.timeService?.dateNowMs() ?? Date.now()); + const problems: ProblemDetail[] = []; + if (end !== undefined && now > end) { + problems.push({ + type: ProblemTypes.CREDENTIAL_EXPIRED, + title: 'Credential Expired', + detail: `The current date time (${now.toISOString()}) is after "${properties.end}" (${String(endValue)}).` + }); + } + if (start !== undefined && now < start) { + problems.push({ + type: ProblemTypes.CREDENTIAL_NOT_YET_VALID, + title: 'Credential Not Yet Valid', + detail: `The current date time (${now.toISOString()}) is before "${properties.start}" (${String(startValue)}).` + }); + } + + if (problems.length > 0) { + return { status: 'failure', problems }; + } + return { + status: 'success', + message: 'The credential is within its validity period.' + }; + } +}; diff --git a/src/suites/status/bitstring-status-check.ts b/src/suites/status/bitstring-status-check.ts index 1d70efa..9ca39f6 100644 --- a/src/suites/status/bitstring-status-check.ts +++ b/src/suites/status/bitstring-status-check.ts @@ -1,8 +1,5 @@ import { checkStatus } from '@digitalcredentials/vc-bitstring-status-list'; -import { - dispatchProofVerification, - type CryptoDispatchResult -} from '../../crypto-dispatch.js'; +import type { CryptoDispatchResult } from '../../crypto-dispatch.js'; import { VerificationCheck, CheckOutcome } from '../../types/check.js'; import { ProblemDetail } from '../../types/problem-detail.js'; import { VerificationSubject } from '../../types/subject.js'; @@ -11,7 +8,7 @@ import { type DocumentLoader } from '../../types/context.js'; import { ProblemTypes } from '../../problem-types.js'; -import { findVcDateProblem } from '../../util/vc-date-messages.js'; +import { isValidityDateProblem } from '../../util/validity-date-problems.js'; import { checkedStatusEntries, checkedStatusListUrls, @@ -19,7 +16,8 @@ import { credentialStatusEntries, describeIgnoredStatusEntry, ignoredStatusEntries, - loadStatusList + loadStatusList, + verifyStatusList } from './status-lists.js'; // Error strings raised by `@digitalcredentials/vc-bitstring-status-list`, @@ -43,21 +41,18 @@ type CheckStatusResult = { /** * Classify a crypto service's rejection of a status list credential. * - * `@digitalcredentials/vc` checks a credential's validity dates only - * after its signature has verified, so a date error in the rejection - * proves the list is authentic and merely out of date: report it as - * expired or not yet valid rather than as a bad signature. The match is - * on vc's message text, carried in the problem's `detail` and shared - * with the proof suite (see {@link findVcDateProblem}); if the crypto - * service reports typed expiry problems, those can replace it. + * A crypto service reports `CREDENTIAL_EXPIRED` / + * `CREDENTIAL_NOT_YET_VALID` only once the list's proof verified, so + * such a problem proves the list is authentic and merely out of date: + * report it as expired or not yet valid rather than as a bad signature. */ function rejectedListProblems( problems: ProblemDetail[], url: string ): ProblemDetail[] { - const dated = findVcDateProblem(problems); + const dated = problems.find(isValidityDateProblem); if (dated !== undefined) { - return dated.fault === 'expired' + return dated.type === ProblemTypes.CREDENTIAL_EXPIRED ? [ { type: ProblemTypes.STATUS_LIST_EXPIRED, @@ -240,6 +235,9 @@ function setBitProblem( * list is missing, has an invalid signature, is expired or not yet * valid, has a wrong type, or actually marks the credential as revoked * or suspended), the overall verification result is `verified: false`. + * It does not halt (`halts: false`): the credential is authentic by now, + * since `proof.signature` passed, so whether its issuer is registered and + * who signed its status list still matter to a verifier. * * Every `BitstringStatusListEntry` with `statusPurpose` `revocation` or * `suspension` is checked, wherever it sits in `credentialStatus`. Each @@ -250,7 +248,8 @@ function setBitProblem( * lists are not fetched, and the outcome names them. * * Proof verification of each named BitstringStatusListCredential goes - * through {@link dispatchProofVerification} against + * through {@link verifyStatusList}, once per list per verification + * (shared with `status.list-issuer`), which dispatches against * `context.cryptoServices` — the same dispatch presentation and * credential proofs use; that is also where the list's validity dates * are checked. `@digitalcredentials/vc-bitstring-status-list` keeps @@ -270,6 +269,7 @@ export const bitstringStatusCheck: VerificationCheck = { description: 'Checks revocation and suspension status via BitstringStatusList.', fatal: true, + halts: false, appliesTo: ['verifiableCredential'], execute: async ( subject: VerificationSubject, @@ -307,11 +307,7 @@ export const bitstringStatusCheck: VerificationCheck = { const loaded = new Map(); for (const url of checkedStatusListUrls(credential)) { const document = await loadStatusList(url, context); - const dispatched = await dispatchProofVerification({ - services: context.cryptoServices, - subject: { verifiableCredential: document }, - options: { documentLoader: context.documentLoader, now } - }); + const dispatched = await verifyStatusList(url, document, context, now); if (dispatched.kind !== 'verified') { return { status: 'failure', diff --git a/src/suites/status/list-issuer-check.ts b/src/suites/status/list-issuer-check.ts index 7c7ebd4..bdf3ac6 100644 --- a/src/suites/status/list-issuer-check.ts +++ b/src/suites/status/list-issuer-check.ts @@ -3,12 +3,42 @@ import { ProblemDetail } from '../../types/problem-detail.js'; import { VerificationSubject } from '../../types/subject.js'; import { VerificationContext } from '../../types/context.js'; import { ProblemTypes } from '../../problem-types.js'; +import { isValidityDateOnly } from '../../util/validity-date-problems.js'; import { checkedStatusListUrls, issuerId, - loadStatusList + loadStatusList, + verifyStatusList } from './status-lists.js'; +/** + * The status list at `url`, if it loads and its proof verifies (or is + * rejected only for its validity dates); otherwise undefined. + */ +async function establishedList( + url: string, + context: VerificationContext, + now: Date +): Promise { + let list: unknown; + try { + list = await loadStatusList(url, context); + } catch { + return undefined; + } + const dispatched = await verifyStatusList(url, list, context, now); + if (dispatched.kind === 'verified') { + return list; + } + if ( + dispatched.kind === 'rejected' && + isValidityDateOnly(dispatched.problems) + ) { + return list; + } + return undefined; +} + /** * Reports a status list that is not issued by the credential's issuer. * @@ -18,11 +48,15 @@ import { * by exact string — a string issuer, or an issuer object's `id` — the same * rule `@digitalcredentials/vc-bitstring-status-list` uses. * - * It runs after `status.bitstring` and reads the lists that check - * already loaded. That check verifies each list's proof, which binds the - * list's signing key to its claimed `issuer`; so by the time this check - * runs, the claim is the proven signer. When `status.bitstring` fails it - * is fatal, and this check is reported as not run. + * It compares only lists whose proof it can establish, since a verified + * proof is what binds the list's signing key to its claimed `issuer`. It + * reuses the lists `status.bitstring` loaded and the proof verifications + * it made, and makes any it did not: `status.bitstring` does not halt, + * and may have failed, and stopped, before reaching every list. A list + * rejected only + * for its validity dates counts as established, since the dates are + * checked only once the proof verified. So a list that marks the + * credential revoked is still checked for who signed it. * * This is a **warning** (`fatal: false`): `verified` is unchanged. Some * status services sign every list with one DID of their own rather than @@ -61,17 +95,15 @@ export const statusListIssuerCheck: VerificationCheck = { } const credentialIssuer = issuerId(credential.issuer); + // The same clock `status.bitstring` judges list freshness with. + const now = new Date(context.timeService?.dateNowMs() ?? Date.now()); const problems: ProblemDetail[] = []; + let unestablished = false; for (const url of urls) { - let list: unknown; - try { - list = await loadStatusList(url, context); - } catch { - return { - status: 'skipped', - reason: - 'Status list could not be loaded; status.bitstring reports why.' - }; + const list = await establishedList(url, context, now); + if (list === undefined) { + unestablished = true; + continue; } const listIssuer = issuerId( @@ -94,6 +126,14 @@ export const statusListIssuerCheck: VerificationCheck = { return { status: 'failure', problems }; } + if (unestablished) { + return { + status: 'skipped', + reason: + 'A status list could not be loaded or its proof did not verify; status.bitstring reports why.' + }; + } + return { status: 'success', message: "Every status list is issued by the credential's issuer." diff --git a/src/suites/status/status-lists.ts b/src/suites/status/status-lists.ts index fd83a90..ad39a12 100644 --- a/src/suites/status/status-lists.ts +++ b/src/suites/status/status-lists.ts @@ -1,9 +1,13 @@ /** * Status-list helpers shared by the status suite's checks: reading a - * credential's `credentialStatus` entries, and loading the status list - * credentials they name. + * credential's `credentialStatus` entries, and loading and verifying the + * status list credentials they name. */ +import { + dispatchProofVerification, + type CryptoDispatchResult +} from '../../crypto-dispatch.js'; import type { DocumentLoader, VerificationContext @@ -227,3 +231,42 @@ export function issuerId(issuer: unknown): string | undefined { } return undefined; } + +/** + * Proof verifications of status lists during one verification, keyed + * like {@link loadStatusList}'s memo. `status.bitstring` and + * `status.list-issuer` both need to know whether a list's proof + * verified, and the memo makes it one verification per URL, judged at + * the first caller's `now`. + */ +const verifiedLists = new WeakMap< + VerificationContext, + Map> +>(); + +/** + * Verify the proof of the status list credential `document`, loaded + * from `url`, once per `context`. + */ +export function verifyStatusList( + url: string, + document: unknown, + context: VerificationContext, + now: Date +): Promise { + let byUrl = verifiedLists.get(context); + if (!byUrl) { + byUrl = new Map(); + verifiedLists.set(context, byUrl); + } + let pending = byUrl.get(url); + if (!pending) { + pending = dispatchProofVerification({ + services: context.cryptoServices, + subject: { verifiableCredential: document }, + options: { documentLoader: context.documentLoader, now } + }); + byUrl.set(url, pending); + } + return pending; +} diff --git a/src/types/check.ts b/src/types/check.ts index f4b933c..b7afe97 100644 --- a/src/types/check.ts +++ b/src/types/check.ts @@ -72,12 +72,18 @@ export interface CheckResult { outcome: CheckOutcome; /** * Whether this check was marked fatal in its suite definition. - * Fatal failures affect the overall `verified` status and halt every - * later check for the subject; non-fatal ones are - * informational/warnings only. A `'skipped'` "Not run" row carries - * its check's flag too, but only a `'failure'` counts as fatal. + * Fatal failures make the overall `verified` status `false`; + * non-fatal ones are informational/warnings only. Whether a fatal + * failure also halted later checks is {@link CheckResult.halts}. A + * `'skipped'` "Not run" row carries its check's flag too, but only a + * `'failure'` counts as fatal. */ fatal?: boolean; + /** + * Copied from {@link VerificationCheck.halts} when the check sets it. + * Absent, a fatal check halts. + */ + halts?: boolean; /** * Per-check timing data. Only populated when the producing * call ran with `timing: true` on `VerifierConfig` or @@ -144,11 +150,20 @@ export interface VerificationCheck { /** Restricts this check to specific subject types. Unset = runs for all. */ appliesTo?: readonly VerificationSubjectType[]; /** - * If true, a failure halts every remaining check for the subject, in - * this suite and all later suites. Checks that would have run are - * reported as `'skipped'` with reason `Not run: failed`. + * If true, a failure makes the subject's `verified` false and, unless + * {@link VerificationCheck.halts} is `false`, halts every remaining + * check for the subject, in this suite and all later suites. Checks + * that would have run are reported as `'skipped'` with reason + * `Not run: failed`. */ fatal?: boolean; + /** + * Whether a fatal failure halts every later check for the subject. + * Defaults to `fatal`. Set `halts: false` on a fatal check whose + * failure leaves later checks still meaningful: the failure still + * makes `verified` false, but later checks run. + */ + halts?: boolean; execute: ( subject: VerificationSubject, context: VerificationContext diff --git a/src/types/crypto-service.ts b/src/types/crypto-service.ts index dae0feb..57d6a5f 100644 --- a/src/types/crypto-service.ts +++ b/src/types/crypto-service.ts @@ -14,6 +14,11 @@ import type { VerificationSubject } from './subject.js'; * Outcome of a credential or presentation cryptographic verification. * * Failures carry pre-classified `ProblemDetail` entries (adapter-specific error mapping). + * + * A crypto service reports `CREDENTIAL_EXPIRED` or + * `CREDENTIAL_NOT_YET_VALID` only when the proof itself verified. + * `proof.signature` treats a rejection made only of those problems as a + * verified signature, and leaves the dates to `proof.validity-period`. */ export type CryptoResult = | { verified: true; message?: string } diff --git a/src/types/suite-summary.ts b/src/types/suite-summary.ts index cf57bee..5e55133 100644 --- a/src/types/suite-summary.ts +++ b/src/types/suite-summary.ts @@ -84,19 +84,20 @@ export interface SuiteSummary { counts: { passed: number; failed: number; skipped: number }; /** - * The `id` of the check in this suite that failed fatally, if - * one did. A fatal failure halts every later check for the - * subject, so at most one suite per subject carries this field, - * and the subject's `verified` is `false` exactly when one of - * its summaries does. `counts.passed` and `counts.failed` - * reflect what actually executed before the halt; the halted - * checks count as `skipped`. + * The `id` of the first check in this suite that failed fatally, + * if one did. The subject's `verified` is `false` exactly when any + * of its summaries carries this field. A fatal check halts every + * later check for the subject unless it sets `halts: false`, so + * more than one suite can carry it: one per non-halting fatal + * failure, plus the suite of the halting one. `counts.passed` and + * `counts.failed` reflect what actually executed; halted checks + * count as `skipped`. * * `status` is computed from counts alone and does not say * whether a failure was fatal: a suite whose only failure is * non-fatal has `status: 'failure'` or `'mixed'` inside a * `verified: true` result. Render such a suite as a warning, - * and reserve error styling for the suite with + * and reserve error styling for each suite with * `fatalFailureAt`. * * There is deliberately no per-suite `verified`: the only diff --git a/src/util/validity-date-problems.ts b/src/util/validity-date-problems.ts new file mode 100644 index 0000000..a0a53a4 --- /dev/null +++ b/src/util/validity-date-problems.ts @@ -0,0 +1,29 @@ +/** + * Recognising a crypto service's validity-date problems. + * + * A crypto service reports `CREDENTIAL_EXPIRED` or + * `CREDENTIAL_NOT_YET_VALID` only when the proof itself verified (see + * `CryptoService`). So a rejection made only of those problems proves + * the document is authentic and merely out of date: `proof.signature` + * reads it as a verified signature, and `status.bitstring` as an + * expired or not-yet-valid list rather than a bad one. + */ + +import { ProblemTypes } from '../problem-types.js'; +import type { ProblemDetail } from '../types/problem-detail.js'; + +/** Whether `problem` is a validity-date problem. */ +export function isValidityDateProblem(problem: ProblemDetail): boolean { + return ( + problem.type === ProblemTypes.CREDENTIAL_EXPIRED || + problem.type === ProblemTypes.CREDENTIAL_NOT_YET_VALID + ); +} + +/** + * Whether a rejection's `problems` are all validity-date problems, and + * there is at least one: the proof verified, and only the dates failed. + */ +export function isValidityDateOnly(problems: ProblemDetail[]): boolean { + return problems.length > 0 && problems.every(isValidityDateProblem); +} diff --git a/src/util/vc-date-messages.ts b/src/util/vc-date-messages.ts index 36ac2b2..1611142 100644 --- a/src/util/vc-date-messages.ts +++ b/src/util/vc-date-messages.ts @@ -1,68 +1,57 @@ /** * `@digitalcredentials/vc`'s validity-date error messages. * - * vc checks a credential's validity dates only after its proof has - * verified, and reports a date failure as a plain `Error` whose message - * is the only machine-readable signal: the crypto service can do no - * better than hand it back as an invalid-signature `detail`. Two checks - * need to recognise it — `proof.signature` for the credential itself, - * and `status.bitstring` for a BitstringStatusListCredential — so the - * patterns live here rather than in two copies that could drift apart. - * If a crypto service starts reporting typed date problems, this module - * is what it retires. + * vc's `verifyCredential` verifies the proof first and checks the + * credential's validity dates only once it has verified. A date failure + * comes back as a plain `Error` whose message is the only + * machine-readable signal; a proof failure comes back as a + * `VerificationError` carrying the reasons in `errors[]`. The crypto + * service uses {@link vcDateFault} to tell the two apart and report a + * typed date problem, which is how the rest of the verifier learns that + * the proof itself verified. * * Both document versions are covered: v2 (`validUntil` / `validFrom`) - * and v1 (`expirationDate` / `issuanceDate`). + * and v1 (`expirationDate` / `issuanceDate`). The messages are vc + * 10.0.2's, matched in full. */ -import type { ProblemDetail } from '../types/problem-detail.js'; - /** vc's messages for a credential whose validity has ended. */ -export const VC_EXPIRED_PATTERNS = [ - 'is after "validUntil"', - 'Credential has expired.' +const VC_EXPIRED_MESSAGES = [ + /^Credential has expired\.$/, + /^The current date time \(.+\) is after "validUntil" \(.+\)\.$/ ]; /** vc's messages for a credential whose validity has not begun. */ -export const VC_NOT_YET_VALID_PATTERNS = [ - 'is before "validFrom"', - 'is before the "issuanceDate"' +const VC_NOT_YET_VALID_MESSAGES = [ + /^The current date time \(.+\) is before the "issuanceDate" \(.+\)\.$/, + /^The current date time \(.+\) is before "validFrom" \(.+\)\.$/ ]; /** Which way round a credential is out of date. */ export type VcDateFault = 'expired' | 'not-yet-valid'; -/** A recognized validity-date fault and the vc message that reported it. */ -export interface VcDateProblem { - fault: VcDateFault; - detail: string; -} - -/** The validity-date fault `detail` reports, if it reports one. */ -export function vcDateFault(detail: string): VcDateFault | undefined { - if (VC_EXPIRED_PATTERNS.some(p => detail.includes(p))) { +/** + * The validity-date fault vc's `verifyCredential` rejected with, if that + * is all it rejected for. + * + * vc throws these from `_checkCredential` only after the proof verified, + * as a plain `Error`; a proof failure is a `VerificationError` carrying + * `errors[]`. Both conditions are required, and the message must match + * vc's wording in full, so a date phrase quoted inside a wrapped proof + * error never counts. + */ +export function vcDateFault(error: unknown): VcDateFault | undefined { + if (!(error instanceof Error) || error.name === 'VerificationError') { + return undefined; + } + if (Array.isArray((error as { errors?: unknown }).errors)) { + return undefined; + } + if (VC_EXPIRED_MESSAGES.some(m => m.test(error.message))) { return 'expired'; } - if (VC_NOT_YET_VALID_PATTERNS.some(p => detail.includes(p))) { + if (VC_NOT_YET_VALID_MESSAGES.some(m => m.test(error.message))) { return 'not-yet-valid'; } return undefined; } - -/** - * The first validity-date fault named among `problems`, if any. A - * rejection that names one is proof the document's signature verified: - * vc never reaches the date checks otherwise. - */ -export function findVcDateProblem( - problems: ProblemDetail[] -): VcDateProblem | undefined { - for (const problem of problems) { - const detail = problem.detail ?? ''; - const fault = vcDateFault(detail); - if (fault !== undefined) { - return { fault, detail }; - } - } - return undefined; -} diff --git a/test/credential-validity-dates.spec.ts b/test/credential-validity-dates.spec.ts index d12d6e2..e8f1470 100644 --- a/test/credential-validity-dates.spec.ts +++ b/test/credential-validity-dates.spec.ts @@ -63,19 +63,43 @@ function v1Template(issuerDid: string): Record { }; } +function row(results: CheckResult[], id: string): CheckResult | undefined { + const found = results.find(r => r.id === id); + expect(found, `${id} result present`).toBeDefined(); + return found; +} + function signatureProblem(results: CheckResult[]): ProblemDetail | undefined { - const signature = results.find(r => r.id === 'cryptographic.proof.signature'); - expect(signature, 'proof.signature result present').toBeDefined(); + const signature = row(results, 'cryptographic.proof.signature'); expect(signature?.outcome.status).toBe('failure'); return signature?.outcome.status === 'failure' ? signature.outcome.problems[0] : undefined; } +/** The validity-period problem, once the signature itself verified. */ +function validityProblem(results: CheckResult[]): ProblemDetail | undefined { + expect(row(results, 'cryptographic.proof.signature')?.outcome.status).toBe( + 'success' + ); + const validity = row(results, 'cryptographic.proof.validity-period'); + expect(validity?.outcome.status).toBe('failure'); + return validity?.outcome.status === 'failure' + ? validity.outcome.problems[0] + : undefined; +} + +const NOT_RUN_AFTER_SIGNATURE = { + status: 'skipped', + reason: 'Not run: proof.signature failed' +}; + describe('credential validity dates', () => { let futureV2: Record; let futureV1: Record; let tampered: Record; + let tamperedExpiredV2: Record; + let tamperedExpiredV1: Record; beforeAll(async () => { const key = await Ed25519VerificationKey2020.generate({ @@ -115,6 +139,38 @@ describe('credential validity dates', () => { ...valid, credentialSubject: { id: 'did:example:someone-else' } }; + + // Expired and edited: the proof is bad and the dates are past. vc + // must report the proof, which it checks first. + const expiredV2 = (await issue({ + credential: { + ...v2Template(issuerDid), + id: 'urn:uuid:credential-validity-dates-tampered-expired-v2', + validFrom: '2010-01-01T00:00:00Z', + validUntil: '2011-01-01T00:00:00Z' + }, + suite, + documentLoader: bundledLoader + })) as Record; + tamperedExpiredV2 = { + ...expiredV2, + credentialSubject: { id: 'did:example:someone-else' } + }; + + const expiredV1 = (await issue({ + credential: { + ...v1Template(issuerDid), + id: 'urn:uuid:credential-validity-dates-tampered-expired-v1', + issuanceDate: '2010-01-01T00:00:00Z', + expirationDate: '2011-01-01T00:00:00Z' + }, + suite, + documentLoader: bundledLoader + })) as Record; + tamperedExpiredV1 = { + ...expiredV1, + credentialSubject: { id: 'did:example:someone-else' } + }; }); const verifier = () => @@ -130,7 +186,7 @@ describe('credential validity dates', () => { }); expect(result.verified).toBe(false); - const problem = signatureProblem(result.results); + const problem = validityProblem(result.results); expect(problem?.type).toBe(ProblemTypes.CREDENTIAL_NOT_YET_VALID); expect(problem?.title).toBe('Credential Not Yet Valid'); expect(problem?.detail).toContain('validFrom'); @@ -143,11 +199,41 @@ describe('credential validity dates', () => { }); expect(result.verified).toBe(false); - const problem = signatureProblem(result.results); + const problem = validityProblem(result.results); expect(problem?.type).toBe(ProblemTypes.CREDENTIAL_NOT_YET_VALID); expect(problem?.detail).toContain('issuanceDate'); }); + it('reports an expired v2 credential on proof.validity-period, and still runs the checks after it', async () => { + const result = await verifier().verifyCredential({ credential: v2Expired }); + + expect(result.verified).toBe(false); + const problem = validityProblem(result.results); + expect(problem?.type).toBe(ProblemTypes.CREDENTIAL_EXPIRED); + expect(problem?.detail).toContain('validUntil'); + const notRun = result.results.filter( + r => + r.outcome.status === 'skipped' && + r.outcome.reason.startsWith('Not run:') + ); + expect(notRun).toEqual([]); + expect(result.summary.find(s => s.suite === 'proof')?.fatalFailureAt).toBe( + 'cryptographic.proof.validity-period' + ); + }); + + it('reports an expired v1 credential on proof.validity-period', async () => { + const result = await verifier().verifyCredential({ + credential: v1Expired, + phases: ['cryptographic'] + }); + + expect(result.verified).toBe(false); + const problem = validityProblem(result.results); + expect(problem?.type).toBe(ProblemTypes.CREDENTIAL_EXPIRED); + expect(problem?.detail).toContain('expirationDate'); + }); + it('still reports a tampered credential as INVALID_SIGNATURE', async () => { const result = await verifier().verifyCredential({ credential: tampered, @@ -158,8 +244,36 @@ describe('credential validity dates', () => { expect(signatureProblem(result.results)?.type).toBe( ProblemTypes.INVALID_SIGNATURE ); + expect( + row(result.results, 'cryptographic.proof.validity-period')?.outcome + ).toEqual(NOT_RUN_AFTER_SIGNATURE); }); + // The ordering guard. `proof.signature` reads a date-only rejection as a + // verified signature because vc checks dates only after the proof. If an + // upgrade of `@digitalcredentials/vc` ever checked dates first, a + // tampered, expired credential would read as merely expired; these fail + // instead. + for (const [version, credential] of [ + ['v2', () => tamperedExpiredV2], + ['v1', () => tamperedExpiredV1] + ] as const) { + it(`reports a tampered, expired ${version} credential as a bad signature, not as expired`, async () => { + const result = await verifier().verifyCredential({ + credential: credential(), + phases: ['cryptographic'] + }); + + expect(result.verified).toBe(false); + expect(signatureProblem(result.results)?.type).toBe( + ProblemTypes.INVALID_SIGNATURE + ); + expect( + row(result.results, 'cryptographic.proof.validity-period')?.outcome + ).toEqual(NOT_RUN_AFTER_SIGNATURE); + }); + } + it('honours the verifier clock: an expired fixture verifies when the clock is set inside its validity window', async () => { const withinV2Validity = createVerifier({ httpGetService: offlineHttpGetService, diff --git a/test/fold-results.spec.ts b/test/fold-results.spec.ts index df0533b..c68bfc8 100644 --- a/test/fold-results.spec.ts +++ b/test/fold-results.spec.ts @@ -264,6 +264,39 @@ describe('foldCheckResults', () => { ); }); + it('marks every suite with a non-halting fatal failure, without a "not run" clause', () => { + const suites = [ + mkSuite('proof', [ + mkCheck('proof.a', 'success'), + mkCheck('proof.b', 'failure', { fatal: true }) + ]), + mkSuite('status', [mkCheck('status.a', 'failure', { fatal: true })]), + mkSuite('registry', [mkCheck('registry.a', 'success')]) + ]; + const checks: CheckResult[] = [ + mkResult('proof', 'proof.a', 'success'), + { + ...mkResult('proof', 'proof.b', 'failure', { fatal: true }), + halts: false + }, + { + ...mkResult('status', 'status.a', 'failure', { fatal: true }), + halts: false + }, + mkResult('registry', 'registry.a', 'success') + ]; + + const { summaries } = foldCheckResults(checks, suites); + + expect(summaries.map(s => s.fatalFailureAt)).toEqual([ + 'cryptographic.proof.b', + 'cryptographic.status.a', + undefined + ]); + expect(summaries[0].message).toBe('1 of 2 checks failed (1 passed)'); + expect(summaries[1].message).toBe('1 of 1 check failed'); + }); + describe('not-run rows after a fatal failure', () => { const core = mkSuite( 'core', diff --git a/test/run-suites.spec.ts b/test/run-suites.spec.ts index d61b4a7..defacc8 100644 --- a/test/run-suites.spec.ts +++ b/test/run-suites.spec.ts @@ -22,6 +22,7 @@ describe('runSuites', () => { outcome: 'success' | 'failure' | 'skipped', options: { fatal?: boolean; + halts?: boolean; appliesTo?: Array<'verifiableCredential' | 'verifiablePresentation'>; } = {} ): VerificationCheck => ({ @@ -29,6 +30,7 @@ describe('runSuites', () => { name: `Mock check ${id}`, description: `Test check that returns ${outcome}`, fatal: options.fatal ?? false, + ...(options.halts !== undefined ? { halts: options.halts } : {}), appliesTo: options.appliesTo, execute: async () => { if (outcome === 'success') { @@ -626,6 +628,91 @@ describe('runSuites', () => { }); }); + describe('fatal without halting', () => { + it('runs later checks, in this suite and later ones, after a fatal failure with halts: false', async () => { + const a2 = spyCheck('a.two'); + const b1 = spyCheck('b.one', { fatal: true }); + const suiteA: VerificationSuite = { + id: 'a', + name: 'A', + checks: [ + createMockCheck('a.dated', 'failure', { fatal: true, halts: false }), + a2 + ] + }; + const suiteB: VerificationSuite = { id: 'b', name: 'B', checks: [b1] }; + + const results = await runSuites([suiteA, suiteB], subject, mockContext); + + expect(results.map(r => [r.id, r.outcome.status])).toEqual([ + ['unknown.a.dated', 'failure'], + ['unknown.a.two', 'success'], + ['unknown.b.one', 'success'] + ]); + expect(results[0].fatal).toBe(true); + expect(results[0].halts).toBe(false); + expect(a2.execute).toHaveBeenCalledOnce(); + expect(b1.execute).toHaveBeenCalledOnce(); + }); + + it('names the first halting failure, not an earlier non-halting one, on not-run rows', async () => { + const c1 = spyCheck('c.one'); + const suites: VerificationSuite[] = [ + { + id: 'a', + name: 'A', + checks: [ + createMockCheck('a.dated', 'failure', { fatal: true, halts: false }) + ] + }, + { + id: 'b', + name: 'B', + checks: [createMockCheck('b.forged', 'failure', { fatal: true })] + }, + { id: 'c', name: 'C', checks: [c1] } + ]; + + const results = await runSuites(suites, subject, mockContext); + + expect(results[2].id).toBe('unknown.c.one'); + expect(results[2].outcome).toEqual(notRun('b.forged')); + expect(c1.execute).not.toHaveBeenCalled(); + }); + + it('ignores halts: true on a non-fatal check', async () => { + const a2 = spyCheck('a.two'); + const suite: VerificationSuite = { + id: 'a', + name: 'A', + checks: [ + createMockCheck('a.warn', 'failure', { fatal: false, halts: true }), + a2 + ] + }; + + const results = await runSuites([suite], subject, mockContext); + + expect(results[1].outcome.status).toBe('success'); + expect(a2.execute).toHaveBeenCalledOnce(); + }); + + it('leaves halts off the rows of checks that do not set it', async () => { + const suite: VerificationSuite = { + id: 'a', + name: 'A', + checks: [ + createMockCheck('a.fatal', 'failure', { fatal: true }), + spyCheck('a.two') + ] + }; + + const results = await runSuites([suite], subject, mockContext); + + expect(results.every(r => !('halts' in r))).toBe(true); + }); + }); + describe('appliesTo filtering', () => { it('skips checks that do not apply to the subject type', async () => { const suite: VerificationSuite = { diff --git a/test/status-list-cryptosuite-parity.spec.ts b/test/status-list-cryptosuite-parity.spec.ts index 27a455c..72d1970 100644 --- a/test/status-list-cryptosuite-parity.spec.ts +++ b/test/status-list-cryptosuite-parity.spec.ts @@ -37,6 +37,11 @@ import { BitstringStatusEntry } from './factories/data/index.js'; const STATUS_LIST_URL = 'https://factory.test/status/parity-list'; const FOREIGN_STATUS_LIST_URL = 'https://factory.test/status/foreign-list'; const EXPIRED_STATUS_LIST_URL = 'https://factory.test/status/expired-list'; +const REVOKED_STATUS_LIST_URL = 'https://factory.test/status/revoked-list'; +const FOREIGN_REVOKED_STATUS_LIST_URL = + 'https://factory.test/status/foreign-revoked-list'; +const FOREIGN_EXPIRED_STATUS_LIST_URL = + 'https://factory.test/status/foreign-expired-list'; const offlineHttpGetService: HttpGetService = { async get(url: string): Promise { @@ -75,6 +80,9 @@ describe('status list cryptosuite parity', () => { let signedSubjectWithForeignList: Record; let foreignDid: string; let signedSubjectWithExpiredList: Record; + let signedExpiredSubjectWithRevokedList: Record; + let signedSubjectWithForeignRevokedList: Record; + let signedSubjectWithForeignExpiredList: Record; let documentLoader: DocumentLoader; beforeAll(async () => { @@ -175,10 +183,90 @@ describe('status list cryptosuite parity', () => { documentLoader: bundledLoader })) as Record; + const signSubject = async ( + statusListUrl: string, + overrides: Record = {} + ) => + (await issue({ + credential: { + ...subjectTemplate(issuerDid, statusListUrl), + ...overrides + }, + suite: new DataIntegrityProof({ + signer: multiKey.signer(), + cryptosuite: eddsaRdfc2022CryptoSuite + }), + documentLoader: bundledLoader + })) as Record; + + // A list with the subject's index (0) set, signed by `signingKey` as + // `signerDid`, optionally already expired. + const signRevokedList = async ( + url: string, + signerDid: string, + signingKey: typeof key, + overrides: Record = {} + ) => { + const revokedList = await createList({ length: 32 }); + revokedList.setStatus(0, true); + const unsigned = (await createCredential({ + id: url, + list: revokedList, + statusPurpose: 'revocation' + })) as Record; + unsigned.issuer = signerDid; + unsigned.validFrom = '2020-01-01T00:00:00Z'; + unsigned['@context'] = unsignedList['@context']; + return (await issue({ + credential: { ...unsigned, ...overrides }, + suite: new Ed25519Signature2020({ key: signingKey }), + documentLoader: bundledLoader + })) as Record; + }; + + const signedRevokedStatusList = await signRevokedList( + REVOKED_STATUS_LIST_URL, + issuerDid, + key + ); + const signedForeignRevokedStatusList = await signRevokedList( + FOREIGN_REVOKED_STATUS_LIST_URL, + foreignDid, + foreignKey + ); + const signedForeignExpiredStatusList = (await issue({ + credential: { + ...unsignedForeignList, + id: FOREIGN_EXPIRED_STATUS_LIST_URL, + validUntil: '2021-01-01T00:00:00Z', + credentialSubject: { + ...(unsignedList.credentialSubject as Record), + id: `${FOREIGN_EXPIRED_STATUS_LIST_URL}#list` + } + }, + suite: new Ed25519Signature2020({ key: foreignKey }), + documentLoader: bundledLoader + })) as Record; + + // Revoked on its issuer's own list, and past its validUntil. + signedExpiredSubjectWithRevokedList = await signSubject( + REVOKED_STATUS_LIST_URL, + { validUntil: '2021-01-01T00:00:00Z' } + ); + signedSubjectWithForeignRevokedList = await signSubject( + FOREIGN_REVOKED_STATUS_LIST_URL + ); + signedSubjectWithForeignExpiredList = await signSubject( + FOREIGN_EXPIRED_STATUS_LIST_URL + ); + const lists: Record> = { [STATUS_LIST_URL]: signedStatusList, [FOREIGN_STATUS_LIST_URL]: signedForeignStatusList, - [EXPIRED_STATUS_LIST_URL]: signedExpiredStatusList + [EXPIRED_STATUS_LIST_URL]: signedExpiredStatusList, + [REVOKED_STATUS_LIST_URL]: signedRevokedStatusList, + [FOREIGN_REVOKED_STATUS_LIST_URL]: signedForeignRevokedStatusList, + [FOREIGN_EXPIRED_STATUS_LIST_URL]: signedForeignExpiredStatusList }; documentLoader = async (url: string) => { if (url in lists) { @@ -236,6 +324,12 @@ describe('status list cryptosuite parity', () => { r => r.id === 'cryptographic.proof.signature' ); expect(proofResult?.outcome.status).toBe('success'); + + // The list's proof never verified, so its issuer claim is not compared. + const issuerResult = result.results.find( + r => r.id === 'cryptographic.status.list-issuer' + ); + expect(issuerResult?.outcome.status).toBe('skipped'); }); it('(b) succeeds status.bitstring when the injected set includes the list credential suite', async () => { @@ -301,4 +395,81 @@ describe('status list cryptosuite parity', () => { ); } }); + + const rowOf = ( + results: Awaited< + ReturnType['verifyCredential']> + >['results'], + id: string + ) => { + const row = results.find(r => r.id === id); + expect(row, `${id} result present`).toBeDefined(); + return row!; + }; + const problemTypes = (row: ReturnType) => + row.outcome.status === 'failure' + ? row.outcome.problems.map(p => p.type) + : []; + + it('(e) reports a revoked credential that has also expired as both, and still checks the list issuer and registry', async () => { + const result = await verifierWith([eddsaAndEd25519()]).verifyCredential({ + credential: signedExpiredSubjectWithRevokedList + }); + + expect(result.verified).toBe(false); + expect( + rowOf(result.results, 'cryptographic.proof.signature').outcome.status + ).toBe('success'); + expect( + problemTypes(rowOf(result.results, 'cryptographic.proof.validity-period')) + ).toEqual([ProblemTypes.CREDENTIAL_EXPIRED]); + expect( + problemTypes(rowOf(result.results, 'cryptographic.status.bitstring')) + ).toEqual([ProblemTypes.CREDENTIAL_REVOKED]); + expect( + rowOf(result.results, 'cryptographic.status.list-issuer').outcome.status + ).toBe('success'); + const notRun = result.results.filter( + r => + r.outcome.status === 'skipped' && + r.outcome.reason.startsWith('Not run:') + ); + expect(notRun).toEqual([]); + expect(result.results.some(r => r.suite === 'registry')).toBe(true); + expect( + result.summary + .filter(s => s.fatalFailureAt !== undefined) + .map(s => s.suite) + ).toEqual(['proof', 'status']); + }); + + it('(f) reports a revocation on a list from a different DID as both a revocation and an issuer mismatch', async () => { + const result = await verifierWith([eddsaAndEd25519()]).verifyCredential({ + credential: signedSubjectWithForeignRevokedList, + phases: ['cryptographic'] + }); + + expect(result.verified).toBe(false); + expect( + problemTypes(rowOf(result.results, 'cryptographic.status.bitstring')) + ).toEqual([ProblemTypes.CREDENTIAL_REVOKED]); + expect( + problemTypes(rowOf(result.results, 'cryptographic.status.list-issuer')) + ).toEqual([ProblemTypes.STATUS_LIST_ISSUER_MISMATCH]); + }); + + it('(h) still compares the issuer of an authentic but expired list from a different DID', async () => { + const result = await verifierWith([eddsaAndEd25519()]).verifyCredential({ + credential: signedSubjectWithForeignExpiredList, + phases: ['cryptographic'] + }); + + expect(result.verified).toBe(false); + expect( + problemTypes(rowOf(result.results, 'cryptographic.status.bitstring')) + ).toEqual([ProblemTypes.STATUS_LIST_EXPIRED]); + expect( + problemTypes(rowOf(result.results, 'cryptographic.status.list-issuer')) + ).toEqual([ProblemTypes.STATUS_LIST_ISSUER_MISMATCH]); + }); }); diff --git a/test/suites/proof.spec.ts b/test/suites/proof.spec.ts index 2f15135..91096b9 100644 --- a/test/suites/proof.spec.ts +++ b/test/suites/proof.spec.ts @@ -3,6 +3,10 @@ import { runSuites } from '../../src/run-suites.js'; import { defaultCryptoServices } from '../../src/default-services.js'; import { proofSuite } from '../../src/suites/proof/index.js'; import { signatureCheck } from '../../src/suites/proof/signature-check.js'; +import { validityPeriodCheck } from '../../src/suites/proof/validity-period-check.js'; +import { ProblemTypes } from '../../src/problem-types.js'; +import type { ProblemDetail } from '../../src/types/problem-detail.js'; +import { FakeTimeService } from '../../src/services/time-service/fake-time-service.js'; import { buildTestContext } from '../factories/services/build-test-context.js'; import { VerificationSubject } from '../../src/types/subject.js'; import { CredentialFactory } from '../factories/data/credential-factory.js'; @@ -50,12 +54,15 @@ describe('Proof Verification Suite', () => { ); const results = await runSuites([proofSuite], subject, context); - expect(results).toHaveLength(1); - expect(results[0].id).toBe('cryptographic.proof.signature'); + expect(results.map(r => r.id)).toEqual([ + 'cryptographic.proof.signature', + 'cryptographic.proof.validity-period' + ]); expect(results[0].outcome.status).toBe('success'); if (results[0].outcome.status === 'success') { expect(results[0].outcome.message).toBe('Fake verification passed.'); } + expect(results[1].outcome.status).toBe('success'); }); it('returns failure with injected problems when service rejects', async () => { @@ -305,7 +312,11 @@ describe('Proof Verification Suite', () => { const subject = createCredentialSubject(cred); const results = await runSuites([proofSuite], subject, context); - expect(results).toHaveLength(1); + expect(results).toHaveLength(2); + expect(results[1].outcome).toEqual({ + status: 'skipped', + reason: 'Not run: proof.signature failed' + }); expect(results[0].outcome.status).toBe('failure'); if (results[0].outcome.status === 'failure') { expect(results[0].outcome.problems[0].title).toBe( @@ -326,4 +337,161 @@ describe('Proof Verification Suite', () => { expect(JSON.stringify(outcome)).not.toContain('checkStatus'); }); }); + + describe('validityPeriodCheck', () => { + const NOW = '2026-10-20T12:00:00.000Z'; + // `buildTestContext` takes no clock, so set it here. FakeTimeService + // advances 1 ms per read, so start 1 ms early to read exactly `iso`. + const contextAt = (iso: string = NOW) => ({ + ...buildTestContext(), + timeService: FakeTimeService({ baseDateMs: Date.parse(iso) - 1 }) + }); + const run = ( + version: 'v1' | 'v2', + dates: Record, + at?: string + ) => + validityPeriodCheck.execute( + createCredentialSubject( + CredentialFactory({ version, credential: dates }) + ), + contextAt(at) + ); + const problemTypes = (outcome: Awaited>) => + outcome.status === 'failure' ? outcome.problems.map(p => p.type) : []; + + it('is fatal, does not halt, and runs after proof.signature', () => { + expect(validityPeriodCheck.fatal).toBe(true); + expect(validityPeriodCheck.halts).toBe(false); + expect(proofSuite.checks.map(c => c.id)).toEqual([ + 'proof.signature', + 'proof.validity-period' + ]); + }); + + it('reports a v2 credential past validUntil as expired', async () => { + const outcome = await run('v2', { + validFrom: '2020-01-01T00:00:00Z', + validUntil: '2021-01-01T00:00:00Z' + }); + + expect(problemTypes(outcome)).toEqual([ProblemTypes.CREDENTIAL_EXPIRED]); + if (outcome.status === 'failure') { + expect(outcome.problems[0].detail).toBe( + `The current date time (${NOW}) is after "validUntil" (2021-01-01T00:00:00Z).` + ); + } + }); + + it('reports a v2 credential before validFrom as not yet valid', async () => { + const outcome = await run('v2', { validFrom: '2099-01-01T00:00:00Z' }); + + expect(problemTypes(outcome)).toEqual([ + ProblemTypes.CREDENTIAL_NOT_YET_VALID + ]); + }); + + it('reports a v1 credential past expirationDate as expired', async () => { + const outcome = await run('v1', { + issuanceDate: '2020-01-01T00:00:00Z', + expirationDate: '2021-01-01T00:00:00Z' + }); + + expect(problemTypes(outcome)).toEqual([ProblemTypes.CREDENTIAL_EXPIRED]); + }); + + it('reports a v1 credential before issuanceDate as not yet valid', async () => { + const outcome = await run('v1', { issuanceDate: '2099-01-01T00:00:00Z' }); + + expect(problemTypes(outcome)).toEqual([ + ProblemTypes.CREDENTIAL_NOT_YET_VALID + ]); + }); + + it('reports both faults when both dates are wrong', async () => { + const outcome = await run('v2', { + validFrom: '2099-01-01T00:00:00Z', + validUntil: '2021-01-01T00:00:00Z' + }); + + expect(problemTypes(outcome)).toEqual([ + ProblemTypes.CREDENTIAL_EXPIRED, + ProblemTypes.CREDENTIAL_NOT_YET_VALID + ]); + }); + + it('treats the end date itself as still valid, as vc does', async () => { + const outcome = await run( + 'v2', + { validFrom: '2020-01-01T00:00:00Z', validUntil: NOW }, + NOW + ); + + expect(outcome).toEqual({ + status: 'success', + message: 'The credential is within its validity period.' + }); + }); + + it('ignores v2 date names on a v1 credential, as vc does', async () => { + const outcome = await run('v1', { + issuanceDate: '2020-01-01T00:00:00Z', + validUntil: '2021-01-01T00:00:00Z' + }); + + expect(outcome.status).toBe('success'); + }); + + it('passes a credential that states no validity period', async () => { + const credential = CredentialFactory({ version: 'v2' }); + delete credential.validFrom; + const outcome = await validityPeriodCheck.execute( + createCredentialSubject(credential), + contextAt() + ); + + expect(outcome).toEqual({ + status: 'success', + message: 'No validity period stated.' + }); + }); + }); + + describe('signatureCheck — validity-date rejections', () => { + const expired = { + type: ProblemTypes.CREDENTIAL_EXPIRED, + title: 'Credential Expired', + detail: 'Credential has expired.' + }; + const run = (problems: ProblemDetail[]) => + signatureCheck.execute( + createCredentialSubject(CredentialFactory({ version: 'v2' })), + buildTestContext({ + cryptoServices: [FakeCryptoService({ verified: false, problems })] + }) + ); + + it('reads a rejection made only of date problems as a verified signature', async () => { + const outcome = await run([expired]); + + expect(outcome).toEqual({ + status: 'success', + message: 'Signature verified successfully.' + }); + }); + + it('keeps a rejection that mixes a date problem with a signature problem as a failure', async () => { + const invalid = { + type: ProblemTypes.INVALID_SIGNATURE, + title: 'Invalid Signature', + detail: 'Invalid signature.' + }; + const outcome = await run([expired, invalid]); + + expect(outcome).toEqual({ + status: 'failure', + problems: [expired, invalid] + }); + }); + }); }); diff --git a/test/suites/status.spec.ts b/test/suites/status.spec.ts index 3957cda..c92c256 100644 --- a/test/suites/status.spec.ts +++ b/test/suites/status.spec.ts @@ -56,6 +56,10 @@ describe('bitstringStatusCheck contract', () => { it('is fatal — a status failure flips overall verified to false', () => { expect(bitstringStatusCheck.fatal).toBe(true); }); + + it('does not halt — registry and later suites still run after a status failure', () => { + expect(bitstringStatusCheck.halts).toBe(false); + }); }); describe('statusListIssuerCheck contract', () => { @@ -701,7 +705,7 @@ describe('Status Suite', () => { }); describe('status list validity dates', () => { - async function runRejected(detail: string) { + async function runRejected(problemType: string, detail: string) { const listUrl = 'https://factory.test/status/list-dated'; const list = await StatusListCredentialFactory({ id: listUrl }); const context = buildTestContext({ @@ -709,13 +713,7 @@ describe('Status Suite', () => { cryptoServices: [ FakeCryptoService({ verified: false, - problems: [ - { - type: ProblemTypes.INVALID_SIGNATURE, - title: 'Invalid Signature', - detail - } - ] + problems: [{ type: problemType, title: 'Rejected', detail }] }) ] }); @@ -737,34 +735,42 @@ describe('Status Suite', () => { it.each([ [ + ProblemTypes.CREDENTIAL_EXPIRED, 'The current date time (2026-10-01T00:00:00.000Z) is after "validUntil" (2025-01-01T00:00:00Z).', ProblemTypes.STATUS_LIST_EXPIRED ], - ['Credential has expired.', ProblemTypes.STATUS_LIST_EXPIRED], [ + ProblemTypes.CREDENTIAL_NOT_YET_VALID, 'The current date time (2026-10-01T00:00:00.000Z) is before "validFrom" (2030-01-01T00:00:00Z).', ProblemTypes.STATUS_LIST_NOT_YET_VALID ], [ - 'The current date time (2026-10-01T00:00:00.000Z) is before the "issuanceDate" (2030-01-01T00:00:00Z).', - ProblemTypes.STATUS_LIST_NOT_YET_VALID + ProblemTypes.INVALID_SIGNATURE, + 'Signature verification failed.', + ProblemTypes.STATUS_LIST_SIGNATURE_ERROR ], + // The type decides, not the text: a signature failure that quotes a + // date message is still a signature failure. [ - 'Signature verification failed.', + ProblemTypes.INVALID_SIGNATURE, + 'Credential has expired.', ProblemTypes.STATUS_LIST_SIGNATURE_ERROR ] - ])('classifies a rejected list with "%s"', async (detail, type) => { - const outcome = await runRejected(detail); - - expect(outcome.status).toBe('failure'); - if (outcome.status === 'failure') { - expect(outcome.problems).toHaveLength(1); - expect(outcome.problems[0].type).toBe(type); - if (type !== ProblemTypes.STATUS_LIST_SIGNATURE_ERROR) { - expect(outcome.problems[0].detail).toContain(detail); + ])( + 'classifies a list rejected with %s "%s"', + async (problemType, detail, type) => { + const outcome = await runRejected(problemType, detail); + + expect(outcome.status).toBe('failure'); + if (outcome.status === 'failure') { + expect(outcome.problems).toHaveLength(1); + expect(outcome.problems[0].type).toBe(type); + if (type !== ProblemTypes.STATUS_LIST_SIGNATURE_ERROR) { + expect(outcome.problems[0].detail).toContain(detail); + } } } - }); + ); }); describe('status list loading and list errors', () => { @@ -841,12 +847,13 @@ describe('Status Suite', () => { async function runWithList( list: Record, credentialOverrides: Record = {}, - statusListIndex = '0' + statusListIndex = '0', + cryptoService = FakeCryptoService() ) { const listUrl = list.id as string; const context = buildTestContext({ documentLoader: FakeDocumentLoader({ [listUrl]: list }), - cryptoServices: [FakeCryptoService()] + cryptoServices: [cryptoService] }); const cred = CredentialFactory({ version: 'v2', @@ -981,7 +988,7 @@ describe('Status Suite', () => { } }); - it('is not run when the bitstring check fails', async () => { + it('reports a list from another DID that marks the credential revoked', async () => { const list = await StatusListCredentialFactory({ id: 'https://factory.test/status/revoked-other-issuer', issuer: OTHER_DID, @@ -991,12 +998,67 @@ describe('Status Suite', () => { expect(results[0].outcome.status).toBe('failure'); const result = listIssuerResult(results); - expect(result.outcome.status).toBe('skipped'); - if (result.outcome.status === 'skipped') { - expect(result.outcome.reason).toMatch(/^Not run/); + expect(result.outcome.status).toBe('failure'); + if (result.outcome.status === 'failure') { + expect(result.outcome.problems[0].type).toBe( + ProblemTypes.STATUS_LIST_ISSUER_MISMATCH + ); } }); + it('compares a list rejected only for its dates, since its proof verified', async () => { + const list = await StatusListCredentialFactory({ + id: 'https://factory.test/status/expired-other-issuer', + issuer: OTHER_DID + }); + const results = await runWithList( + list, + {}, + '0', + FakeCryptoService({ + verified: false, + problems: [ + { + type: ProblemTypes.CREDENTIAL_EXPIRED, + title: 'Credential Expired', + detail: 'Credential has expired.' + } + ] + }) + ); + + expect(results[0].outcome).toEqual( + expect.objectContaining({ status: 'failure' }) + ); + const result = listIssuerResult(results); + expect(result.outcome.status).toBe('failure'); + if (result.outcome.status === 'failure') { + expect(result.outcome.problems[0].type).toBe( + ProblemTypes.STATUS_LIST_ISSUER_MISMATCH + ); + } + }); + + it('takes no issuer claim from a list whose proof did not verify', async () => { + const list = await StatusListCredentialFactory({ + id: 'https://factory.test/status/unverified-other-issuer', + issuer: OTHER_DID + }); + const results = await runWithList( + list, + {}, + '0', + FakeCryptoService({ verified: false }) + ); + + expect(results[0].outcome.status).toBe('failure'); + expect(listIssuerResult(results).outcome).toEqual({ + status: 'skipped', + reason: + 'A status list could not be loaded or its proof did not verify; status.bitstring reports why.' + }); + }); + it('skips when the credential has no credentialStatus', async () => { const cred = CredentialFactory({ version: 'v2' }); const results = await runSuites( @@ -1087,7 +1149,8 @@ describe('Status Suite', () => { expect(results).toHaveLength(1); expect(results[0].outcome).toEqual({ status: 'skipped', - reason: 'Status list could not be loaded; status.bitstring reports why.' + reason: + 'A status list could not be loaded or its proof did not verify; status.bitstring reports why.' }); }); }); diff --git a/test/verification-method-errors.spec.ts b/test/verification-method-errors.spec.ts index dfa21be..6105422 100644 --- a/test/verification-method-errors.spec.ts +++ b/test/verification-method-errors.spec.ts @@ -119,14 +119,81 @@ describe('verification method errors (unit)', () => { ); }); - it('takes the detail from an unwrapped error, as vc throws for validity dates', () => { + it('takes the detail from an unwrapped error', () => { const problems = classifySignatureError( - new Error('Credential has expired.'), + new Error('Invalid signature.'), undefined ); expect(problems[0].type).toBe(ProblemTypes.INVALID_SIGNATURE); - expect(problems[0].detail).toBe('Credential has expired.'); + expect(problems[0].detail).toBe('Invalid signature.'); + }); +}); + +describe('validity-date faults (unit)', () => { + // vc 10.0.2's `_checkCredential` messages, verbatim. vc throws them, as a + // plain Error, only after the proof verified. + const dateMessages: Array<[string, string]> = [ + ['Credential has expired.', ProblemTypes.CREDENTIAL_EXPIRED], + [ + 'The current date time (2026-10-20T00:00:00.000Z) is after "validUntil" (2011-01-01T00:00:00Z).', + ProblemTypes.CREDENTIAL_EXPIRED + ], + [ + 'The current date time (2026-10-20T00:00:00.000Z) is before the "issuanceDate" (2099-01-01T00:00:00Z).', + ProblemTypes.CREDENTIAL_NOT_YET_VALID + ], + [ + 'The current date time (2026-10-20T00:00:00.000Z) is before "validFrom" (2099-01-01T00:00:00Z).', + ProblemTypes.CREDENTIAL_NOT_YET_VALID + ] + ]; + + for (const [message, type] of dateMessages) { + it(`types vc's plain "${message}" as a date problem`, () => { + const problems = classifySignatureError(new Error(message), undefined); + + expect(problems).toEqual([ + expect.objectContaining({ type, detail: message }) + ]); + }); + } + + it('does not type a date message wrapped inside a VerificationError', () => { + const problems = classifySignatureError( + wrapped(dateMessages[1][0]), + undefined + ); + + expect(problems[0].type).toBe(ProblemTypes.INVALID_SIGNATURE); + }); + + it('does not type a VerificationError, even one with a date message and no errors array', () => { + const error = new Error('Credential has expired.'); + error.name = 'VerificationError'; + + const problems = classifySignatureError(error, undefined); + + expect(problems[0].type).toBe(ProblemTypes.INVALID_SIGNATURE); + }); + + it('does not type a message that only contains a date phrase', () => { + const problems = classifySignatureError( + new Error('wrapped: Credential has expired.'), + undefined + ); + + expect(problems[0].type).toBe(ProblemTypes.INVALID_SIGNATURE); + }); + + it('does not type a plain Error that carries an errors array', () => { + const error = Object.assign(new Error('Credential has expired.'), { + errors: [new Error('Invalid signature.')] + }); + + const problems = classifySignatureError(error, undefined); + + expect(problems[0].type).toBe(ProblemTypes.INVALID_SIGNATURE); }); }); diff --git a/test/verify-credential.spec.ts b/test/verify-credential.spec.ts index 17393de..d94acc5 100644 --- a/test/verify-credential.spec.ts +++ b/test/verify-credential.spec.ts @@ -190,15 +190,27 @@ describe('verifyCredential', () => { describe('expired credentials', () => { // An authentic credential whose validity has ended is not a forgery, - // and must not be reported as one: the signature verified, which is - // the only reason the date check ran at all. + // and must not be reported as one: its signature verifies, and the + // expiry is reported by `proof.validity-period`, which does not halt + // the checks after it. const expiryProblem = (results: CheckResult[]) => { const signature = results.find( r => r.id === 'cryptographic.proof.signature' ); - expect(signature?.outcome.status).toBe('failure'); - return signature?.outcome.status === 'failure' - ? signature.outcome.problems[0] + expect(signature?.outcome.status).toBe('success'); + const validity = results.find( + r => r.id === 'cryptographic.proof.validity-period' + ); + expect(validity?.outcome.status).toBe('failure'); + expect( + results.filter( + r => + r.outcome.status === 'skipped' && + r.outcome.reason.startsWith('Not run:') + ) + ).toEqual([]); + return validity?.outcome.status === 'failure' + ? validity.outcome.problems[0] : undefined; }; @@ -297,6 +309,51 @@ describe('verifyCredential', () => { expect(customSummary?.fatalFailureAt).toBe('unknown.custom.fatal-check'); }); + it('a custom fatal check with halts: false fails verification without halting later suites', async () => { + const datedCheck: VerificationCheck = { + id: 'dated.check', + name: 'Dated Check', + fatal: true, + halts: false, + appliesTo: ['verifiableCredential'], + execute: async (): Promise => ({ + status: 'failure', + problems: [ + { + type: 'https://www.w3.org/TR/vc-data-model#CUSTOM_ERROR', + title: 'Custom Fatal Error', + detail: 'Fails without halting' + } + ] + }) + }; + const laterCheck: VerificationCheck = { + id: 'later.check', + name: 'Later Check', + appliesTo: ['verifiableCredential'], + execute: async (): Promise => ({ + status: 'success', + message: 'Ran after the fatal failure' + }) + }; + + const credential = CredentialFactory({ version: 'v1', credential: {} }); + const result = await verifyCredential({ + credential, + additionalSuites: [ + { id: 'dated', name: 'Dated', checks: [datedCheck] }, + { id: 'later', name: 'Later', checks: [laterCheck] } + ], + ...fakeVerified + }); + + expect(result.verified).toBe(false); + const later = result.results.find(r => r.suite === 'later'); + expect(later?.outcome.status).toBe('success'); + const datedSummary = result.summary.find(s => s.suite === 'dated'); + expect(datedSummary?.fatalFailureAt).toBe('unknown.dated.check'); + }); + it('a custom non-fatal failure fails its suite but leaves verified true', async () => { const warningCheck: VerificationCheck = { id: 'custom.warning-check',