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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ not run after a fatal failure.
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
credential's issuer is not that controller.
- `PROOF_UNSUPPORTED` on `ProblemTypes`, under the same URI namespace: a proof
this verifier did not check, because no registered suite supports its
cryptosuite or it was made for a different proof purpose.
- `proof.unsupported-proofs` check (`cryptographic.proof.unsupported-proofs`),
non-fatal, run after `proof.signature` for credentials and presentations. It
fails with one `PROOF_UNSUPPORTED` problem per proof in a proof set that could
not be checked, as a warning beside the proofs that verified; otherwise it
succeeds ("Every proof was checked."). Every credential and presentation
result now has this one more row.
- Optional `now?: Date` on `CryptoVerifyOptions`, filled by the calling check
from the verification context's `TimeService` and passed through to
`@digitalcredentials/vc` by `DataIntegrityCryptoService`. Custom
Expand Down Expand Up @@ -137,6 +146,22 @@ not run after a fatal failure.

### Fixed

- **A proof set no longer passes when only one of its proofs verifies**
([#67](https://github.com/digitalcredentials/verifier-core/issues/67)). A
credential, presentation or status list credential carrying several proofs
reported `proof.signature` success, and `verified: true`, when any one proof
verified, because the signature libraries accept a set on its first good
proof. Each proof is now verified on its own, and `proof.signature` fails when
any proof a registered suite can check fails, naming it: "Proof 2 of 2
(DataIntegrityProof, eddsa-rdfc-2022): …". A status list with one broken proof
is a bad list signature (`STATUS_LIST_SIGNATURE_ERROR`), and an expired
credential with one broken proof reports the broken proof rather than reading
as authentic and merely expired. A proof in a cryptosuite this verifier does
not support, or made for another proof purpose, is no longer silently ignored:
beside a proof that verified it is a warning on the new
`proof.unsupported-proofs` check; when no proof could be checked,
`proof.signature` fails with `PROOF_UNSUPPORTED` in place of
`INVALID_SIGNATURE`.
- **A key or controller mismatch is no longer reported as `INVALID_SIGNATURE`
with the detail "Verification error(s).", exactly what a tampered credential
reported.** A verification method that is missing, unauthorized by its
Expand Down
67 changes: 42 additions & 25 deletions README.md

Large diffs are not rendered by default.

36 changes: 33 additions & 3 deletions docs/api/verification-results.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ A successful single-credential verification:
"outcome": { "status": "skipped",
"reason": "no recognizer matched" } },
{ "id": "cryptographic.proof.signature", /* success */ },
{ "id": "cryptographic.proof.unsupported-proofs", /* success */ },
{ "id": "cryptographic.proof.validity-period", /* success */ },
{ "id": "cryptographic.status.bitstring",
"suite": "status",
Expand Down Expand Up @@ -110,8 +111,8 @@ A successful single-credential verification:
"counts": { "passed": 0, "failed": 0, "skipped": 1 } },
{ "id": "cryptographic.proof", "phase": "cryptographic",
"suite": "proof", "status": "success",
"message": "2 of 2 checks passed",
"counts": { "passed": 2, "failed": 0, "skipped": 0 } },
"message": "3 of 3 checks passed",
"counts": { "passed": 3, "failed": 0, "skipped": 0 } },
{ "id": "cryptographic.status", "phase": "cryptographic",
"suite": "status", "status": "skipped",
"message": "2 of 2 checks skipped",
Expand Down Expand Up @@ -342,6 +343,34 @@ presentation's `verified` additionally requires every
There is deliberately no per-suite `verified`: the only verdicts are
the result-level `verified` fields.

### Proof sets and `cryptographic.proof.unsupported-proofs`

A credential, presentation or status list credential may carry a
**proof set**: several proofs in its `proof` array, often in different
cryptosuites so that different verifiers can each check one they
support. Each proof is verified on its own, and
`cryptographic.proof.signature` succeeds only when **every proof a
registered suite can check** verified (VC Data Integrity §4.5). One
broken proof fails the check, and halts, even when the others verified;
its problem's `detail` names the proof, e.g.
`Proof 2 of 2 (DataIntegrityProof, eddsa-rdfc-2022): …`. The prefix
appears only when the document carries two or more proofs.

A proof this verifier cannot check — no registered suite supports its
cryptosuite, or it was made for a different proof purpose — does not
fail the signature when another proof verified. The non-fatal
`cryptographic.proof.unsupported-proofs` check, which runs right after
the signature check, reports it instead: a failure with one
`PROOF_UNSUPPORTED` problem per such proof, so the credential stays
`verified: true` and the proof suite shows a warning. The check
succeeds ("Every proof was checked.") when there is nothing to report,
including for a single-proof document; is skipped for an unsigned
presentation accepted through `unsignedPresentation`; and is not run
when the signature check failed. A document whose **only** proofs are
unsupported has nothing verified to warn beside, so it fails
`cryptographic.proof.signature` with `PROOF_UNSUPPORTED`. Unsupported
proofs on a status list credential are not reported.

### Per-suite badge recommendations

Colour a suite by `fatalFailureAt` first and `status` second, so a
Expand Down Expand Up @@ -380,6 +409,7 @@ cryptographic.core
cryptographic.core.proof-exists
cryptographic.proof
cryptographic.proof.signature
cryptographic.proof.unsupported-proofs
cryptographic.proof.validity-period (credentials only)
cryptographic.status
cryptographic.status.bitstring
Expand Down Expand Up @@ -521,7 +551,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 + 2 + 1 = 7 checks passed)
│ ├─ cryptographic ✓ (4 + 3 + 1 = 8 checks passed)
│ ├─ trust ⚠ (1 of 1 check failed — advisory)
│ │ └─ trust.registry.issuer — Issuer Not Registered
│ ├─ recognition ✓
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ src/
├── default-suites.ts Internal: defaultSuites array (core → recognition → proof → status → registry); Open Badges suites are opt-in via the `/openbadges` submodule
├── default-services.ts Internal: lazy factories for default httpGetService, cryptoServices, documentLoader (memoized) + per-call createDefaultCacheService
├── run-suites.ts Suite orchestration engine
├── crypto-dispatch.ts Shared CryptoService selection + invocation (proof suite and status suite)
├── crypto-dispatch.ts Shared CryptoService selection + invocation, one proof at a time for proof sets (proof suite and status suite)
├── extract-credentials-from.ts Normalize VP's embedded credentials to array
├── flatten-presentation-results.ts flattenPresentationResults helper + FlattenedCheckResult provenance-tagged union
├── problem-types.ts ProblemTypes const map + ProblemType union (catalog of built-in ProblemDetail.type URIs)
Expand Down Expand Up @@ -292,7 +292,7 @@ The fix: `createVerifier` forward-declares the verifier reference, then passes
object is built, the slot is filled. Handlers receive the verifier through
`RegistryHandlerContext`, and `vc-recognition` passes `registries: []` to its recursive
`verifier.verifyCredential(...)` as a recursion guard. Status-list credential proofs do **not**
take this path: they go through `dispatchProofVerification` against `cryptoServices` (see the
take this path: they go through `dispatchProofSetVerification` against `cryptoServices` (see the
status suite) rather than a recursive `Verifier.verifyCredential` call.

## Suites and checks
Expand Down Expand Up @@ -351,7 +351,7 @@ 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`, `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. |
| Proof Verification | `proof` | `cryptographic` | `proof.signature`, `proof.unsupported-proofs`, `proof.validity-period` | Yes / No | Cryptographic signature verification dispatched via `CryptoService`. A proof set is verified one proof at a time (`dispatchProofSetVerification` hands each `CryptoService` call a shallow copy carrying a single proof), and `proof.signature` passes only when every proof a registered suite can check verified (VC Data Integrity §4.5): the signature libraries alone accept a set when any one proof verifies. A proof no registered suite supports, or made for another proof purpose, is `PROOF_UNSUPPORTED`; beside a verified proof it is a warning on the non-fatal `proof.unsupported-proofs`, which reads the same memoised verification, and when no proof could be checked it fails `proof.signature`. 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` |

Expand Down
166 changes: 166 additions & 0 deletions src/crypto-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import type {
} from './types/crypto-service.js';
import type { ProblemDetail } from './types/problem-detail.js';
import type { VerificationSubject } from './types/subject.js';
import { ProblemTypes } from './problem-types.js';
import { isValidityDateOnly } from './util/validity-date-problems.js';

/**
* Outcome of dispatching a proof to the registered {@link CryptoService}s.
Expand Down Expand Up @@ -85,3 +87,167 @@ export async function dispatchProofVerification(
return { kind: 'threw', error };
}
}

/** One proof's verdict within a proof set. */
export interface ProofOutcome {
/** 1-based position in the document's `proof` array. */
position: number;
/** Total proofs in the set. */
total: number;
/** `type`, plus `cryptosuite` when present: "DataIntegrityProof, eddsa-rdfc-2022". */
label: string;
result: CryptoDispatchResult;
}

export interface ProofSetDispatchResult {
/** The verdict for the whole document, in the shape callers already handle. */
combined: CryptoDispatchResult;
/** Each proof's verdict; empty unless the document carries two or more proofs. */
proofs: ProofOutcome[];
}

/**
* Whether a proof was not checked at all: rejected only because no
* registered suite supports it, or it was made for another purpose.
*/
export function isUnsupportedProof(result: CryptoDispatchResult): boolean {
return (
result.kind === 'rejected' &&
result.problems.length > 0 &&
result.problems.every(p => p.type === ProblemTypes.PROOF_UNSUPPORTED)
);
}

/**
* A rejected proof's problems, each `detail` saying which proof of the
* set it is about: "Proof 2 of 2 (DataIntegrityProof, eddsa-rdfc-2022): …".
*/
export function problemsOfProof(outcome: ProofOutcome): ProblemDetail[] {
if (outcome.result.kind !== 'rejected') {
return [];
}
const prefix = `Proof ${outcome.position} of ${outcome.total} (${outcome.label}): `;
return outcome.result.problems.map(problem => ({
...problem,
detail: prefix + problem.detail
}));
}

/**
* Verify a document's proofs one at a time, so that every proof a
* registered suite can check must verify.
*
* jsonld-signatures' `ProofSet.verify` reports a proof set verified when
* *any* of its proofs verified, and silently skips proofs no suite or
* purpose matches. vc then checks validity dates after that "any"
* verdict and, when the date check fails, drops the per-proof results.
* VC Data Integrity §4.5 requires every proof to verify instead. Handing
* each {@link CryptoService} call a shallow copy carrying a single proof
* makes each verdict about exactly one proof, which also keeps the
* `CryptoResult` contract (a date-only rejection means the proof
* verified) true for proof sets. Proof chains (`previousProof`) are not
* supported by the underlying libraries either way.
*
* A document with zero or one proof is dispatched exactly as
* {@link dispatchProofVerification} would. For a proof set, `combined`
* is, in order: the first proof that threw; `no-service` if any proof
* found no service; every failing proof's problems, prefixed with the
* proof's position and label; the first date-only rejection, unprefixed
* because the dates belong to the document; the first verified proof;
* otherwise, when no proof was checked at all, the unsupported proofs'
* problems, prefixed. Unsupported proofs beside checked ones do not
* change `combined`; they are left in `proofs` for the caller to report.
*/
export async function dispatchProofSetVerification(
input: CryptoDispatchInput
): Promise<ProofSetDispatchResult> {
const { subject } = input;
// The same document dispatchProofVerification picks.
const key: keyof VerificationSubject = subject.verifiablePresentation
? 'verifiablePresentation'
: 'verifiableCredential';
const document = subject[key] as Record<string, unknown> | undefined;
const proofSet = document?.proof;

if (!document || !Array.isArray(proofSet) || proofSet.length < 2) {
return { combined: await dispatchProofVerification(input), proofs: [] };
}

const total = proofSet.length;
// Copies, never edits: the signed bytes of the original document must
// stay as they arrived.
const proofs = await Promise.all(
proofSet.map(
async (proof: unknown, index): Promise<ProofOutcome> => ({
position: index + 1,
total,
label: proofLabel(proof),
result: await dispatchProofVerification({
...input,
subject: { ...subject, [key]: { ...document, proof } }
})
})
)
);

return { combined: combineProofOutcomes(proofs), proofs };
}

/**
* How a proof is named in a problem's `detail`: its `type`, plus its
* `cryptosuite` when it has one, e.g. "DataIntegrityProof, eddsa-rdfc-2022".
* Data Integrity proofs all share one `type`, so the cryptosuite is what
* tells two of them apart.
*/
function proofLabel(proof: unknown): string {
const { type, cryptosuite } = (proof ?? {}) as {
type?: unknown;
cryptosuite?: unknown;
};
const typeName = typeof type === 'string' ? type : 'unknown proof type';
return typeof cryptosuite === 'string'
? `${typeName}, ${cryptosuite}`
: typeName;
}

function combineProofOutcomes(proofs: ProofOutcome[]): CryptoDispatchResult {
const threw = proofs.find(p => p.result.kind === 'threw');
if (threw) {
return threw.result;
}
if (proofs.some(p => p.result.kind === 'no-service')) {
return { kind: 'no-service' };
}

const unsupported: ProofOutcome[] = [];
const dateOnly: ProofOutcome[] = [];
const failing: ProofOutcome[] = [];
for (const outcome of proofs) {
const { result } = outcome;
if (result.kind !== 'rejected') {
continue;
}
if (isUnsupportedProof(result)) {
unsupported.push(outcome);
} else if (isValidityDateOnly(result.problems)) {
dateOnly.push(outcome);
} else {
failing.push(outcome);
}
}

if (failing.length > 0) {
return { kind: 'rejected', problems: failing.flatMap(problemsOfProof) };
}

if (dateOnly.length > 0) {
return dateOnly[0].result;
}
const verified = proofs.find(p => p.result.kind === 'verified');
if (verified) {
return verified.result;
}
// Nothing was checked at all, so there is no verified proof to warn
// beside: as fatal as a single unsupported proof.
return { kind: 'rejected', problems: unsupported.flatMap(problemsOfProof) };
}
Loading
Loading