Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,14 @@ interface CredentialVerificationResult {
}
```

`verified` is `true` when no check returned a failure. `results` carries every
`verified` is `true` when no **fatal** check returned a failure. Some checks are
advisory: `trust.registry.issuer`, `recognition.profile`,
`cryptographic.status.list-issuer`, the Open Badges checks (the semantic
`schema.obv3.*` checks and the Open Badges JSON-schema check), and any check of
your own that leaves `fatal` unset. An advisory failure never flips `verified`,
so a credential whose only failing check is the registry lookup is still
`verified: true` — its `trust.registry` summary carries `status: 'failure'` and
no `fatalFailureAt`, which is how a UI surfaces it as a warning. `results` carries every
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)).
Expand Down Expand Up @@ -244,7 +251,7 @@ interface ProblemDetail {
```json
{
"verified": true,
"verifiableCredential": { "...parsed credential..." },
"verifiableCredential": { "...the credential you passed in, unchanged..." },
"results": [
{ "id": "cryptographic.core.context-exists", "suite": "core", "outcome": { "status": "success", "message": "Credential has a valid @context property." } },
{ "id": "cryptographic.core.vc-context", "suite": "core", "outcome": { "status": "success", "message": "..." } },
Expand Down Expand Up @@ -656,10 +663,15 @@ Presentation verification does two things:
2. **Verifies each embedded credential** — extracts credentials from the VP and
runs `verifyCredential` on each. Results go in `credentialResults`.

`verified` is `true` only if both the presentation and all embedded credentials
pass.
`verified` is `true` when no **fatal** presentation check failed and every
embedded credential is itself `verified`. The same checks are advisory here as
for a single credential (`trust.registry.issuer`, `recognition.profile`,
`cryptographic.status.list-issuer`, the Open Badges checks, and any check of
your own that leaves `fatal` unset): an embedded credential whose only failure
is advisory is `verified: true`, and so, on its account, is the presentation.

The parsed VP is returned as `verifiablePresentation`, mirroring the wire-level
The VP is returned as `verifiablePresentation` — the object you passed in,
unchanged — mirroring the wire-level
property name so callers (and downstream systems whose templates reach into
result objects by property path) can reach it without carrying the original
input separately.
Expand Down Expand Up @@ -1132,7 +1144,7 @@ small migrations:
- `PresentationVerificationResult.allResults` removed — replace with
`flattenPresentationResults(result)`.
- `PresentationVerificationResult.verifiablePresentation` added — new field
carrying the parsed VP.
carrying the VP you passed in, unchanged.

- **Default cache isolation:** two `createVerifier()` calls without an explicit
`cacheService` no longer share a process-wide cache. To preserve the previous
Expand Down
11 changes: 6 additions & 5 deletions docs/api/timing.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,15 @@ of the suite's own work.

When a credential or presentation envelope fails to parse, the
verifier short-circuits with a single synthetic
`parsing.envelope` `CheckResult`. With `timing: true`, this
`cryptographic.parsing.envelope` `CheckResult`. With `timing: true`, this
synthetic check, its synthetic suite summary, and the top-level
result all carry `timing` — same as a normal run:

```jsonc
{
"verified": false,
"results": [
{ "id": "cryptographic.parsing.parsing.envelope",
{ "id": "cryptographic.parsing.envelope",
"outcome": { "status": "failure", "problems": [/* ... */] },
"fatal": true,
"timing": { "startedAt": "...", "endedAt": "...", "durationMs": 0.001 } }
Expand Down Expand Up @@ -237,9 +237,10 @@ counter semantics and `seed` options (`baseDateMs`,
`performanceTickMs`).

The `TimeService` is the source of truth for any check that
needs to ask "what time is it?". `proof.signature` already reads
it to judge credential expiry (`validUntil` / `expirationDate`)
and `status.bitstring` to judge status-list freshness, so a
needs to ask "what time is it?". `cryptographic.proof.signature`
already reads it to judge credential expiry (`validUntil` /
`expirationDate`) and `cryptographic.status.bitstring` to judge
status-list freshness, so a
verifier built on a `FakeTimeService` decides both at the pinned
moment. Signature clock-skew windows and key rotation belong
there too. Read it from `VerificationContext.timeService` inside
Expand Down
122 changes: 86 additions & 36 deletions docs/api/verification-results.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
## Table of Contents

1. [Overview](#overview)
- [The top-level verdict](#the-top-level-verdict)
- [Check payloads](#check-payloads)
2. [Presentation vs credential scopes](#presentation-vs-credential-scopes)
3. [Phase model](#phase-model)
Expand All @@ -31,12 +32,33 @@ 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.

### The top-level verdict

`verified` is `true` when no **fatal** check failed. For a presentation it
also requires every embedded credential to be `verified`.

Some checks are **advisory** (non-fatal), and an advisory failure never
flips `verified`:

- `trust.registry.issuer`
- `recognition.profile`
- `cryptographic.status.list-issuer`
- the Open Badges checks — the semantic `schema.obv3.*` checks and the
Open Badges JSON-schema check
- any caller-supplied check that leaves `fatal` unset

So a credential whose only failing check is the registry lookup is
`verified: true`, while its `trust.registry` summary has
`status: 'failure'` and no `fatalFailureAt`. A UI that wants to show
"issuer not registered" reads the summary rows, not `verified`; see
[§6 UI rendering recipe](#ui-rendering-recipe).

A successful single-credential verification:

```jsonc
{
"verified": true,
"verifiableCredential": { "...parsed credential..." },
"verifiableCredential": { "...the credential you passed in, unchanged..." },
"results": [
{ "id": "cryptographic.core.context-exists",
"suite": "core",
Expand Down Expand Up @@ -476,33 +498,38 @@ function suiteBadge(s: SuiteSummary): Badge {
}
```

A presentation with two credentials, one verified with warnings and
one not verified, gets rendered as:
A presentation with two credentials — the first failing only advisory
checks, the second failing a fatal one — gets rendered as:

```
Presentation: ✗ (1 of 2 credentials verified)
├─ VP signature: ✓
├─ Credential 1 (obv3p0.openbadge): ✓
│ ├─ cryptographic ✓
│ ├─ trust ⚠ (1 of 1 check failed)
├─ Credential 1 (obv3p0.openbadge): ✓ — verified, with advisory failures
│ ├─ cryptographic ✓ (4 + 1 + 1 = 6 checks passed)
│ ├─ trust ⚠ (1 of 1 check failed — advisory)
│ │ └─ trust.registry.issuer — Issuer Not Registered
│ ├─ recognition ✓
│ └─ semantic ⚠ (1 of 5 checks failed, 4 passed) [openBadgesSuite]
│ └─ semantic ⚠ (1 of 5 checks failed, 4 passed — advisory)
│ └─ semantic.openbadges.schema.obv3.result-ref — Invalid Result Reference
└─ Credential 2 (obv3p0.openbadge): ✗
├─ cryptographic ✗ (proof: 1 of 1 check failed; status: not run)
│ └─ cryptographic.proof.signature — Credential Expired
├─ trust — (registry not run: proof.signature failed)
├─ cryptographic ✗ (1 of 1 check failed)
│ └─ cryptographic.proof.signature — Invalid Signature
├─ trust — (not run after fatal)
├─ recognition ✓
└─ semantic — (not run: proof.signature failed)
└─ semantic — (not run after fatal)
```

Credential 1 is verified: its registry and Open Badges checks are
non-fatal, so their failures are warnings (amber), not errors.
Credential 2 is not verified because `proof.signature` failed fatally;
its proof suite carries `fatalFailureAt: "cryptographic.proof.signature"`,
and every later check was not run. A phase takes the most severe badge
of its suites.
Credential 1 shows the rule from
[§1 The top-level verdict](#the-top-level-verdict): the registry and
Open Badges checks are advisory, so their failures leave
`credentialResults[0].verified === true` even though those two suites
report `status: 'failure'` and `'mixed'`. Neither carries
`fatalFailureAt`, so they render as warnings (amber), not errors. Only
credential 2's fatal signature failure makes the presentation's own
`verified` false. A UI that hid advisory failures because the verdict
was `true` would be hiding exactly what a user wants to know, so render
them — just not as a failed verdict. A phase takes the most severe
badge of its suites.

The `└─` rows under each failing phase row come from `failureDetail()`.
Lazy-expand them — most users only want the verdict + per-phase status.
Expand Down Expand Up @@ -543,6 +570,17 @@ dcc-transaction-service UI implementation.
> every check — passes, skips with a reason, and rows not run after a
> fatal failure — so a row's `outcome.status` is what distinguishes
> them.
>
> `verified` means "no fatal check failed" (and, for a presentation,
> that every embedded credential is itself verified). The advisory
> (non-fatal) checks are `trust.registry.issuer`,
> `recognition.profile`, `cryptographic.status.list-issuer`, the Open
> Badges checks (the semantic `schema.obv3.*` checks and the Open
> Badges JSON-schema check), and any caller-supplied check without
> `fatal`. A credential whose only failing check is advisory is
> `verified: true` with `status: 'failure'` (or `'mixed'`) and no
> `fatalFailureAt` on that suite — render the failure rows as warnings,
> but do not render the verdict as failed.

---

Expand All @@ -551,7 +589,7 @@ dcc-transaction-service UI implementation.
```jsonc
{
"verified": false,
"verifiablePresentation": { /* parsed VP */ },
"verifiablePresentation": { /* the VP you passed in, unchanged */ },
"presentationResults": [
{ "id": "cryptographic.core.vp-structure", /* success */ },
{ "id": "cryptographic.proof.signature", /* success */ }
Expand All @@ -564,8 +602,9 @@ dcc-transaction-service UI implementation.
],
"credentialResults": [
{
// Only advisory checks failed, so the verdict stays true.
"verified": true,
"verifiableCredential": { /* VC from an unregistered issuer */ },
"verifiableCredential": { /* VC with an unregistered issuer */ },
"recognizedProfile": "obv3p0.openbadge",
"results": [
{ "id": "cryptographic.core.context-exists", /* success */ },
Expand Down Expand Up @@ -607,8 +646,9 @@ dcc-transaction-service UI implementation.
]
},
{
// A fatal failure: this is what makes a verdict false.
"verified": false,
"verifiableCredential": { /* expired VC */ },
"verifiableCredential": { /* VC with a broken signature */ },
"recognizedProfile": "obv3p0.openbadge",
"results": [
{ "id": "cryptographic.core.context-exists", /* success */ },
Expand All @@ -618,18 +658,25 @@ dcc-transaction-service UI implementation.
{ "id": "cryptographic.core.proof-exists", /* success */ },
{ "id": "recognition.profile", /* success */ },
{ "id": "cryptographic.proof.signature",
"suite": "proof", "check": "proof.signature",
"suite": "proof",
"outcome": { "status": "failure", "problems": [
{ "type": "https://www.w3.org/TR/vc-data-model#CREDENTIAL_EXPIRED",
"title": "Credential Expired",
"detail": "..." }
{ "type": "https://www.w3.org/TR/vc-data-model#INVALID_SIGNATURE",
"title": "Invalid Signature",
"detail": "The credential's signature did not verify." }
]},
"fatal": true },
{ "id": "cryptographic.status.bitstring",
"suite": "status",
"outcome": { "status": "skipped",
"reason": "Not run: proof.signature failed" } },
{ "id": "cryptographic.status.list-issuer",
"suite": "status",
"outcome": { "status": "skipped",
"reason": "Not run: proof.signature failed" } },
{ "id": "trust.registry.issuer",
"suite": "registry", "check": "registry.issuer",
"suite": "registry",
"outcome": { "status": "skipped",
"reason": "Not run: proof.signature failed" },
"fatal": false }
"reason": "Not run: proof.signature failed" } }
],
"summary": [
{ "id": "cryptographic.core", "phase": "cryptographic",
Expand All @@ -646,6 +693,10 @@ dcc-transaction-service UI implementation.
"message": "1 of 1 check failed",
"counts": { "passed": 0, "failed": 1, "skipped": 0 },
"fatalFailureAt": "cryptographic.proof.signature" },
{ "id": "cryptographic.status", "phase": "cryptographic",
"suite": "status", "status": "skipped",
"message": "status not run: proof.signature failed",
"counts": { "passed": 0, "failed": 0, "skipped": 2 } },
{ "id": "trust.registry", "phase": "trust",
"suite": "registry", "status": "skipped",
"message": "registry not run: proof.signature failed",
Expand Down Expand Up @@ -710,15 +761,14 @@ function failureDetail(
```
Presentation: ✗ (1 of 2 credentials verified)
├─ VP signature: ✓
├─ Credential 1 (obv3p0.openbadge): ✓
├─ Credential 1 (obv3p0.openbadge): ✓ — verified, with advisory failures
│ ├─ cryptographic ✓
│ ├─ trust ✓
│ ├─ recognition ✓
│ └─ semantic ✓
│ ├─ trust ✗ (1 of 1 check failed — advisory)
│ │ └─ trust.registry.issuer — Issuer Not Registered
│ └─ recognition ✓
└─ Credential 2 (obv3p0.openbadge): ✗
├─ cryptographic ✓
├─ trust ✗ (1 of 1 check failed)
│ └─ trust.registry.issuer — Issuer Not Registered
├─ recognition ✓
└─ semantic ✓
├─ cryptographic ✗ (1 of 1 check failed)
│ └─ cryptographic.proof.signature — Invalid Signature
├─ trust — (not run after fatal)
└─ recognition ✓
```
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,8 @@ caller's original object is what the suites verify (see the invariant in `src/ve
The credential is `unknown` on entry — callers don't need to pre-validate.

**Context.** Each `verifyCredential` / `verifyPresentation` call builds an internal
`VerificationContext` from the verifier's long-lived services plus per-call inputs (the parsed
credential or presentation, optional `additionalSuites`, optional `registries` override, optional
`VerificationContext` from the verifier's long-lived services plus per-call inputs (the caller's
original credential or presentation object — Zod gates it, it is never rewritten — optional `additionalSuites`, optional `registries` override, optional
`challenge` / `domain` / `unsignedPresentation` for VPs). The context is the seam the suites consume; it is
not a public composition point. Because `documentLoader` and `cacheService` live on the verifier,
they're shared across every call on the same instance — this is what makes batch verification
Expand Down
30 changes: 23 additions & 7 deletions src/types/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@ import { VerifiablePresentation } from '../schemas/presentation.js';

export interface CredentialVerificationResult {
/**
* True when no fatal check failed. Non-fatal failures (warnings)
* leave this `true`; see {@link SuiteSummary.fatalFailureAt}.
* True if no *fatal* check returned a failure outcome.
*
* Advisory (non-fatal) checks never flip it: `registry.issuer`,
* `recognition.profile`, `status.list-issuer`, the Open Badges
* checks (the semantic `schema.obv3.*` checks and the Open
* Badges JSON-schema check), and any caller-supplied check that
* leaves `fatal` unset. So a credential whose only failing check
* is the registry lookup is `verified: true`, with
* `status: 'failure'` and no `fatalFailureAt` on its
* `trust.registry` summary — read {@link summary} to surface
* advisory failures. `verified` is `false` exactly when one of
* the summaries carries {@link SuiteSummary.fatalFailureAt}.
*/
verified: boolean;

/**
* The Zod-parsed credential that was verified.
* The credential that was verified: the caller's original
* object, returned as it came in. Zod gates the input but does
* not normalize it, so nothing here is rewritten.
*
* Field name matches the W3C / VCALM property name so the
* verification result can be spread directly into a VCALM
Expand Down Expand Up @@ -86,16 +98,20 @@ export interface CredentialVerificationResult {

export interface PresentationVerificationResult {
/**
* True when no presentation-level check failed fatally and every
* entry in {@link credentialResults} is `verified`.
* True if no *fatal* presentation check failed and every
* embedded credential is itself `verified`. Advisory
* (non-fatal) failures never flip it; see
* {@link CredentialVerificationResult.verified} for which
* checks are advisory.
*/
verified: boolean;

/**
* The Zod-parsed presentation that was verified.
* The presentation that was verified: the caller's original
* object, returned as it came in.
*
* Mirror of {@link CredentialVerificationResult.verifiableCredential}.
* Allows VCALM consumers to reach the parsed VP via the documented
* Allows VCALM consumers to reach the VP via the documented
* `results.<step>.verifiablePresentation.…` JSONata path without
* carrying the original input separately.
*/
Expand Down
6 changes: 3 additions & 3 deletions src/types/suite-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ export interface SuiteSummary {
/**
* Aggregate status across the suite's child checks:
* - `'success'` — every check that ran returned `success`.
* - `'failure'` — every check that ran returned `failure`.
* - `'failure'` — at least one check failed and none passed
* (failures alone, or failures plus skips).
* - `'mixed'` — at least one `failure` and at least one
* `success`. A suite whose only non-failures are skips is
* `'failure'`.
* `success`. Skips alone never make a suite `'mixed'`.
* - `'skipped'` — no check ran (suite predicate returned false,
* every check was filtered out by `appliesTo`, or every check
* was halted by an earlier fatal failure).
Expand Down
Loading
Loading