From be0b63a37c414f7975f73f6bda9bd3e0992d0183 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Tue, 20 Oct 2026 15:52:50 -0500 Subject: [PATCH 1/4] feat(results): let a fatal check fail without halting later checks `fatal` decided two things at once: a failure made `verified` false, and it halted every later check for the subject. A check can now set `halts: false` to keep the first and drop the second, so a fatal failure that leaves later checks meaningful no longer hides their results. `halts` defaults to `fatal`, so existing checks and caller-added suites behave as before, and their rows keep their shape. A throwing `applies` predicate still halts, since it leaves every check in its suite unknown. More than one suite summary can now carry `fatalFailureAt`; `verified` is false exactly when any does. No built-in check sets `halts` yet. Co-Authored-By: Claude Opus 5.5 --- src/run-suites.ts | 42 +++++++++++----- src/types/check.ts | 29 +++++++++--- src/types/suite-summary.ts | 17 +++---- test/fold-results.spec.ts | 33 +++++++++++++ test/run-suites.spec.ts | 87 ++++++++++++++++++++++++++++++++++ test/verify-credential.spec.ts | 51 ++++++++++++++++++-- 6 files changed, 230 insertions(+), 29 deletions(-) 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/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/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/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/verify-credential.spec.ts b/test/verify-credential.spec.ts index 17393de..9f1221e 100644 --- a/test/verify-credential.spec.ts +++ b/test/verify-credential.spec.ts @@ -297,6 +297,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', @@ -670,9 +715,9 @@ describe('verifyCredential', () => { ); expect(failures.length).toBeGreaterThan(0); expect(failures.every(r => r.suite === 'proof')).toBe(true); - expect( - result.results.some(r => r.outcome.status === 'success') - ).toBe(true); + expect(result.results.some(r => r.outcome.status === 'success')).toBe( + true + ); const proofSummary = result.summary.find(s => s.suite === 'proof'); expect(['failure', 'mixed']).toContain(proofSummary?.status); expect(proofSummary?.fatalFailureAt).toBe( From 05c2a4fc8e686ce04683f3354a60de667ab8ce2a Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Tue, 20 Oct 2026 16:05:47 -0500 Subject: [PATCH 2/4] feat(proof): report validity dates from their own check, so expiry no longer halts An expired or not-yet-valid credential failed `proof.signature`, which halts, so its revocation status, its issuer's registration and any caller-added suites came back "Not run". The credential is authentic at that point: @digitalcredentials/vc checks dates only after the proof verifies. - The Data Integrity crypto service reports vc's date failure as a typed CREDENTIAL_EXPIRED / CREDENTIAL_NOT_YET_VALID problem. It does so only for a plain Error, not a VerificationError and with no errors[], whose message matches vc's wording in full, so a date phrase inside a wrapped proof error never counts. - `proof.signature` reads a rejection made only of those problems as a verified signature. - New `proof.validity-period` check, after the signature, judges the dates against the verifier's clock. Fatal, so `verified` stays false, but `halts: false`, so later checks still run. - `status.bitstring` tells an expired status list from a bad one by problem type rather than message text. A tampered and expired credential is still reported as a bad signature; offline tests with real signatures pin this, and fail if a future vc checks dates before the proof. Co-Authored-By: Claude Opus 5.5 --- src/problem-types.ts | 8 +- src/services/data-integrity-crypto.ts | 22 +++ src/suites/proof/index.ts | 9 +- src/suites/proof/signature-check.ts | 64 ++----- src/suites/proof/validity-period-check.ts | 118 +++++++++++++ src/suites/status/bitstring-status-check.ts | 17 +- src/types/crypto-service.ts | 5 + src/util/validity-date-problems.ts | 29 ++++ src/util/vc-date-messages.ts | 81 ++++----- test/credential-validity-dates.spec.ts | 122 +++++++++++++- test/suites/proof.spec.ts | 174 +++++++++++++++++++- test/suites/status.spec.ts | 46 +++--- test/verification-method-errors.spec.ts | 73 +++++++- test/verify-credential.spec.ts | 28 +++- 14 files changed, 647 insertions(+), 149 deletions(-) create mode 100644 src/suites/proof/validity-period-check.ts create mode 100644 src/util/validity-date-problems.ts 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/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..ce8a997 100644 --- a/src/suites/status/bitstring-status-check.ts +++ b/src/suites/status/bitstring-status-check.ts @@ -11,7 +11,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, @@ -43,21 +43,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, 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/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/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..1fb323c 100644 --- a/test/suites/status.spec.ts +++ b/test/suites/status.spec.ts @@ -701,7 +701,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 +709,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 +731,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', () => { 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 9f1221e..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; }; @@ -715,9 +727,9 @@ describe('verifyCredential', () => { ); expect(failures.length).toBeGreaterThan(0); expect(failures.every(r => r.suite === 'proof')).toBe(true); - expect(result.results.some(r => r.outcome.status === 'success')).toBe( - true - ); + expect( + result.results.some(r => r.outcome.status === 'success') + ).toBe(true); const proofSummary = result.summary.find(s => s.suite === 'proof'); expect(['failure', 'mixed']).toContain(proofSummary?.status); expect(proofSummary?.fatalFailureAt).toBe( From 8042a085d2f497251229374c2fb7dab2cc220a56 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Tue, 20 Oct 2026 16:12:43 -0500 Subject: [PATCH 3/4] feat(status): report registry and list issuer after a status failure `status.bitstring` halted the run when it failed, so a revoked or suspended credential, or one whose status list could not be trusted, never reported whether its issuer is registered or who signed the list. A revocation read from a list signed by someone other than the issuer therefore read as a plain revocation. - `status.bitstring` stays fatal but sets `halts: false`. - `status.list-issuer` compares only lists whose proof verified, or was rejected only for its validity dates. It no longer relied on running only after every list verified, which held only while the status check halted. A list whose proof did not verify is skipped, not compared. - Each status list's proof verification is memoised per verification, beside the existing memo of loaded lists, so both checks share one verification per list, judged at one moment. Co-Authored-By: Claude Opus 5.5 --- src/suites/status/bitstring-status-check.ts | 21 ++- src/suites/status/list-issuer-check.ts | 70 ++++++-- src/suites/status/status-lists.ts | 47 +++++- test/status-list-cryptosuite-parity.spec.ts | 173 +++++++++++++++++++- test/suites/status.spec.ts | 75 ++++++++- 5 files changed, 350 insertions(+), 36 deletions(-) diff --git a/src/suites/status/bitstring-status-check.ts b/src/suites/status/bitstring-status-check.ts index ce8a997..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'; @@ -19,7 +16,8 @@ import { credentialStatusEntries, describeIgnoredStatusEntry, ignoredStatusEntries, - loadStatusList + loadStatusList, + verifyStatusList } from './status-lists.js'; // Error strings raised by `@digitalcredentials/vc-bitstring-status-list`, @@ -237,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 @@ -247,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 @@ -267,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, @@ -304,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/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/status.spec.ts b/test/suites/status.spec.ts index 1fb323c..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', () => { @@ -843,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', @@ -983,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, @@ -993,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( @@ -1089,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.' }); }); }); From de6379d57a9b3a55c57714aa7d027d7851e6f709 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Tue, 20 Oct 2026 16:12:43 -0500 Subject: [PATCH 4/4] docs: describe fatal checks that do not halt The README, the verification-results and architecture docs, and the 2.0.0 CHANGELOG entry said that every fatal failure halts later checks, that at most one suite carries `fatalFailureAt`, and that expiry is part of the signature check. Describe `halts`, `proof.validity-period`, the two built-in fatal checks that do not halt, and what an expired or revoked credential now reports, with an expired-and-revoked example. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 42 ++++++++--- README.md | 122 +++++++++++++++++++++++++++---- docs/api/verification-results.md | 50 +++++++++---- docs/architecture.md | 25 ++++--- 4 files changed, 188 insertions(+), 51 deletions(-) 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