diff --git a/CHANGELOG.md b/CHANGELOG.md index 6174ef2..4d59831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 0979263..33148e2 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,13 @@ cryptosuite. Verification runs an ordered pipeline of **suites**, each containing one or more **checks**: -| Suite | Phase | What it checks | Fatal? | -| --------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -| **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 (`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 | +| Suite | Phase | What it checks | Fatal? | +| --------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| **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 (`proof.signature`; in a proof set, every proof a registered suite can check must verify), a warning for proofs in a set that it cannot check (`proof.unsupported-proofs`), then the credential's validity dates against the verifier's clock (`proof.validity-period`) | Yes (`proof.unsupported-proofs`: No) | +| **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 | The **Phase** column drives the optional `phases:` filter on `VerifierConfig` and per-call args, used for @@ -269,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.unsupported-proofs", "suite": "proof", "outcome": { "status": "success", "message": "Every proof was checked." } }, { "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." } }, @@ -352,6 +353,14 @@ halted. Each check that would have run appears as `skipped` with reason ] } }, + { + "id": "cryptographic.proof.unsupported-proofs", + "suite": "proof", + "outcome": { + "status": "skipped", + "reason": "Not run: proof.signature failed" + } + }, { "id": "cryptographic.proof.validity-period", "suite": "proof", @@ -602,24 +611,25 @@ with no `credentialStatus` skips the status check: All failures use `ProblemDetail` with a `type` URI. Common error types: -| Type URI | Title | When | -| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -| `...#PARSING_ERROR` | Credential Parsing Failed / Invalid Credential Structure / Invalid Presentation Structure / Invalid JSON-LD / No VC Context / Invalid Credential ID / No Proof | Envelope gate or `core` structural problems | -| `...#INVALID_SIGNATURE` | Invalid Signature | Signature doesn't match content | -| `...#VERIFICATION_METHOD_ERROR` | Verification Method Error | The proof's verification method is missing or not the issuer's | -| `...#CREDENTIAL_EXPIRED` | Credential Expired | `validUntil` / `expirationDate` has passed; authentic | -| `...#CREDENTIAL_NOT_YET_VALID` | Credential Not Yet Valid | `validFrom` / `issuanceDate` in the future; authentic | -| `...#DID_WEB_UNRESOLVED` | DID Web Unresolved | `did:web` document couldn't be fetched | -| `...#HTTP_ERROR` | HTTP Error | Network error during signature check | -| `...#CREDENTIAL_REVOKED` | Credential Revoked | A revocation status list marks the credential revoked | -| `...#CREDENTIAL_SUSPENDED` | Credential Suspended | A suspension status list marks the credential suspended | -| `...#STATUS_LIST_NOT_FOUND` | Status List Not Found | Status list URL unreachable | -| `...#STATUS_LIST_EXPIRED` | Status List Expired | Status list VC has expired | -| `...#STATUS_LIST_NOT_YET_VALID` | Status List Not Yet Valid | Status list VC is not valid yet | -| `...#STATUS_LIST_SIGNATURE_ERROR` | Status List Signature Error | Status list VC signature invalid | -| `...#STATUS_LIST_ISSUER_MISMATCH` | Status List Issuer Mismatch | Status list not issued by the credential's issuer (non-fatal) | -| `...#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 | +| Type URI | Title | When | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `...#PARSING_ERROR` | Credential Parsing Failed / Invalid Credential Structure / Invalid Presentation Structure / Invalid JSON-LD / No VC Context / Invalid Credential ID / No Proof | Envelope gate or `core` structural problems | +| `...#INVALID_SIGNATURE` | Invalid Signature | Signature doesn't match content | +| `...#VERIFICATION_METHOD_ERROR` | Verification Method Error | The proof's verification method is missing or not the issuer's | +| `...#PROOF_UNSUPPORTED` | Unsupported Proof | No registered suite supports the proof, or it was made for another purpose; a warning beside a proof that verified | +| `...#CREDENTIAL_EXPIRED` | Credential Expired | `validUntil` / `expirationDate` has passed; authentic | +| `...#CREDENTIAL_NOT_YET_VALID` | Credential Not Yet Valid | `validFrom` / `issuanceDate` in the future; authentic | +| `...#DID_WEB_UNRESOLVED` | DID Web Unresolved | `did:web` document couldn't be fetched | +| `...#HTTP_ERROR` | HTTP Error | Network error during signature check | +| `...#CREDENTIAL_REVOKED` | Credential Revoked | A revocation status list marks the credential revoked | +| `...#CREDENTIAL_SUSPENDED` | Credential Suspended | A suspension status list marks the credential suspended | +| `...#STATUS_LIST_NOT_FOUND` | Status List Not Found | Status list URL unreachable | +| `...#STATUS_LIST_EXPIRED` | Status List Expired | Status list VC has expired | +| `...#STATUS_LIST_NOT_YET_VALID` | Status List Not Yet Valid | Status list VC is not valid yet | +| `...#STATUS_LIST_SIGNATURE_ERROR` | Status List Signature Error | Status list VC signature invalid | +| `...#STATUS_LIST_ISSUER_MISMATCH` | Status List Issuer Mismatch | Status list not issued by the credential's issuer (non-fatal) | +| `...#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.validity-period`, which runs only after `proof.signature` has verified. @@ -638,6 +648,13 @@ someone other than the credential's issuer. Nothing was necessarily altered — the issuer's key setup does not back the proof it published. The `detail` is the signature library's own message, so it names the method and the fault. +`PROOF_UNSUPPORTED` marks a proof this verifier did not check: its cryptosuite +is not one a registered suite supports, or it was made for a different proof +purpose. A credential may carry a proof set so that different verifiers can +each check the proof they support, so beside a proof that verified it is only a +warning, on the non-fatal `proof.unsupported-proofs` check. When no proof could +be checked at all, `proof.signature` fails with it instead. + #### Problem types Every built-in problem URI is also exported as a constant. Branch on the const diff --git a/docs/api/verification-results.md b/docs/api/verification-results.md index 694eaba..58e6141 100644 --- a/docs/api/verification-results.md +++ b/docs/api/verification-results.md @@ -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", @@ -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", @@ -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 @@ -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 @@ -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 ✓ diff --git a/docs/architecture.md b/docs/architecture.md index 457ac56..74bfb0a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) @@ -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 @@ -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` | diff --git a/src/crypto-dispatch.ts b/src/crypto-dispatch.ts index 361fc53..28e7746 100644 --- a/src/crypto-dispatch.ts +++ b/src/crypto-dispatch.ts @@ -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. @@ -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 { + const { subject } = input; + // The same document dispatchProofVerification picks. + const key: keyof VerificationSubject = subject.verifiablePresentation + ? 'verifiablePresentation' + : 'verifiableCredential'; + const document = subject[key] as Record | 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 => ({ + 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) }; +} diff --git a/src/problem-types.ts b/src/problem-types.ts index d01cda4..c61f09d 100644 --- a/src/problem-types.ts +++ b/src/problem-types.ts @@ -74,6 +74,14 @@ export const ProblemTypes = { /** Synthesized — proof verification raised an error before reaching a verdict. */ PROOF_VERIFICATION_ERROR: 'https://www.w3.org/TR/vc-data-model#PROOF_VERIFICATION_ERROR', + /** + * Synthesized — a proof was not checked: no registered suite supports + * its type or cryptosuite, or it was made for a different proof + * purpose. Beside a proof that verified, it is a warning + * (`proof.unsupported-proofs`); alone, the document has no proof this + * verifier can check. + */ + PROOF_UNSUPPORTED: 'https://www.w3.org/TR/vc-data-model#PROOF_UNSUPPORTED', /** Synthesized — a `did:web` document could not be fetched. */ DID_WEB_UNRESOLVED: 'https://www.w3.org/TR/vc-data-model#DID_WEB_UNRESOLVED', /** Synthesized — HTTP error during signature/issuer resolution. */ diff --git a/src/services/data-integrity-crypto.ts b/src/services/data-integrity-crypto.ts index 8c46a0e..fdee735 100644 --- a/src/services/data-integrity-crypto.ts +++ b/src/services/data-integrity-crypto.ts @@ -16,6 +16,7 @@ import type { ProblemDetail } from '../types/problem-detail.js'; import type { VerificationSubject } from '../types/subject.js'; import { ProblemTypes } from '../problem-types.js'; import { documentHasProof } from '../util/document-has-proof.js'; +import { isJsigsNoMatch } from '../util/jsigs-no-match.js'; import { isVerificationMethodMessage } from '../util/verification-method-messages.js'; import { vcDateFault } from '../util/vc-date-messages.js'; @@ -201,6 +202,87 @@ export function classifySignatureError( ]; } +/** + * The proof purpose vc checks a credential's proof against: its default + * `CredentialIssuancePurpose` is an assertion purpose. + */ +const CREDENTIAL_PROOF_PURPOSE = 'assertionMethod'; + +/** The document's proof when it carries exactly one, otherwise `undefined`. */ +function singleProof( + document: Record | undefined +): Record | undefined { + const proof = document?.proof; + if (Array.isArray(proof)) { + return proof.length === 1 + ? (proof[0] as Record) + : undefined; + } + return proof !== null && typeof proof === 'object' + ? (proof as Record) + : undefined; +} + +/** + * Whether any configured suite would check `proof`, asked the way + * jsonld-signatures asks: with the document's `@context` on the proof. + */ +async function anySuiteMatches( + proof: Record, + document: Record, + suites: CryptoSuite[], + documentLoader: unknown +): Promise { + const proofWithContext = { '@context': document['@context'], ...proof }; + for (const suite of suites) { + try { + if ( + await suite.matchProof({ + proof: proofWithContext, + document, + documentLoader + }) + ) { + return true; + } + } catch { + // A suite that cannot even look at the proof does not support it. + } + } + return false; +} + +/** + * Say why jsonld-signatures checked no proof, so that a warning about an + * extra proof in a proof set names the real reason. jsonld-signatures + * skips a proof for a purpose mismatch or for having no matching suite, + * and reports both the same way; asking the suites tells them apart. + * A document with several proofs gets a general wording, since each + * proof may have been skipped for a different reason. + */ +async function unsupportedProofProblem( + document: Record | undefined, + suites: CryptoSuite[], + expectedPurpose: string, + documentLoader: unknown +): Promise { + let detail = + 'No proof in this document could be checked: none uses a cryptosuite this verifier supports with the expected proof purpose.'; + const proof = singleProof(document); + if (document && proof) { + if (!(await anySuiteMatches(proof, document, suites, documentLoader))) { + detail = "This verifier does not support this proof's cryptosuite."; + } else if (proof.proofPurpose !== expectedPurpose) { + detail = `This proof's purpose (${String(proof.proofPurpose ?? 'none')}) is not the one being checked (${expectedPurpose}).`; + } + } + return { + type: ProblemTypes.PROOF_UNSUPPORTED, + title: 'Unsupported Proof', + detail + }; +} + function getPresentationPurpose( presentation: Record, challenge: string | null | undefined, @@ -281,6 +363,20 @@ export function DataIntegrityCryptoService( }; } + if (isJsigsNoMatch(result.error)) { + return { + verified: false, + problems: [ + await unsupportedProofProblem( + credential as Record | undefined, + suites, + CREDENTIAL_PROOF_PURPOSE, + options.documentLoader + ) + ] + }; + } + return { verified: false, problems: classifySignatureError( @@ -331,6 +427,20 @@ export function DataIntegrityCryptoService( }; } + if (isJsigsNoMatch(result.error)) { + return { + verified: false, + problems: [ + await unsupportedProofProblem( + presentation as Record | undefined, + suites, + purpose.term, + options.documentLoader + ) + ] + }; + } + return { verified: false, problems: classifySignatureError(result.error, undefined) diff --git a/src/suites/proof/index.ts b/src/suites/proof/index.ts index 4c3cea4..3108b82 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 { unsupportedProofsCheck } from './unsupported-proofs-check.js'; import { validityPeriodCheck } from './validity-period-check.js'; /** @@ -14,8 +15,11 @@ import { validityPeriodCheck } from './validity-period-check.js'; * 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. + * `proof.unsupported-proofs` follows the signature check and warns about + * proofs in a proof set that no registered suite could check. + * + * `proof.validity-period` follows them, so a failed signature halts it, + * and judges the credential's validity dates. */ export const proofSuite: VerificationSuite = { id: 'proof', @@ -23,5 +27,5 @@ export const proofSuite: VerificationSuite = { description: "Cryptographic signature verification, and the credential's validity period.", phase: 'cryptographic', - checks: [signatureCheck, validityPeriodCheck] + checks: [signatureCheck, unsupportedProofsCheck, validityPeriodCheck] }; diff --git a/src/suites/proof/proof-set-verification.ts b/src/suites/proof/proof-set-verification.ts new file mode 100644 index 0000000..7cea7a3 --- /dev/null +++ b/src/suites/proof/proof-set-verification.ts @@ -0,0 +1,64 @@ +import { + dispatchProofSetVerification, + type ProofSetDispatchResult +} from '../../crypto-dispatch.js'; +import type { VerificationContext } from '../../types/context.js'; +import type { VerificationSubject } from '../../types/subject.js'; + +/** + * Proof verifications during one verification, keyed by the context the + * verifier builds for that call and then by the document verified. + * `proof.signature` and `proof.unsupported-proofs` both need each + * proof's verdict, and the memo makes it one verification per document, + * judged at the first caller's `now`. + */ +const verifiedProofs = new WeakMap< + VerificationContext, + WeakMap> +>(); + +/** + * Verify the proofs of the subject's document — its presentation if it + * has one, otherwise its credential — once per `context`, one proof at a + * time when the document carries a proof set. + */ +export function verifyProofSet( + subject: VerificationSubject, + context: VerificationContext +): Promise { + const document = + subject.verifiablePresentation ?? subject.verifiableCredential; + if (document === null || typeof document !== 'object') { + return dispatch(subject, context); + } + + let byDocument = verifiedProofs.get(context); + if (!byDocument) { + byDocument = new WeakMap(); + verifiedProofs.set(context, byDocument); + } + let pending = byDocument.get(document); + if (!pending) { + pending = dispatch(subject, context); + byDocument.set(document, pending); + } + return pending; +} + +function dispatch( + subject: VerificationSubject, + context: VerificationContext +): Promise { + return dispatchProofSetVerification({ + services: context.cryptoServices, + subject, + options: { + documentLoader: context.documentLoader, + challenge: context.challenge, + domain: context.domain, + // A context built directly in a test may carry no clock; the + // production factory always sets one. + now: new Date(context.timeService?.dateNowMs() ?? Date.now()) + } + }); +} diff --git a/src/suites/proof/signature-check.ts b/src/suites/proof/signature-check.ts index 43fb03d..08444e3 100644 --- a/src/suites/proof/signature-check.ts +++ b/src/suites/proof/signature-check.ts @@ -3,9 +3,9 @@ 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 { dispatchProofVerification } from '../../crypto-dispatch.js'; import { documentHasProof } from '../../util/document-has-proof.js'; import { isValidityDateOnly } from '../../util/validity-date-problems.js'; +import { verifyProofSet } from './proof-set-verification.js'; const NO_APPLICABLE_SERVICE: ProblemDetail = { type: ProblemTypes.PROOF_VERIFICATION_ERROR, @@ -43,6 +43,12 @@ const UNSIGNED_PRESENTATION_ACCEPTED = * {@link VerificationContext.domain}) is always enforced on a presentation's * proof. A domain without a challenge fails before dispatch. * + * A document carrying a proof set passes only when every proof a + * registered suite can check verified (VC Data Integrity §4.5); each + * proof is verified on its own (see `verifyProofSet`). Proofs no suite + * can check, beside one that verified, do not fail this check: they are + * reported by `proof.unsupported-proofs`. + * * 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 @@ -97,18 +103,7 @@ export const signatureCheck: VerificationCheck = { return { status: 'failure', problems: [CHALLENGE_REQUIRED] }; } - const dispatched = await dispatchProofVerification({ - services: context.cryptoServices, - subject, - options: { - documentLoader: context.documentLoader, - challenge: context.challenge, - domain: context.domain, - // A context built directly in a test may carry no clock; the - // production factory always sets one. - now: new Date(context.timeService?.dateNowMs() ?? Date.now()) - } - }); + const dispatched = (await verifyProofSet(subject, context)).combined; switch (dispatched.kind) { case 'verified': diff --git a/src/suites/proof/unsupported-proofs-check.ts b/src/suites/proof/unsupported-proofs-check.ts new file mode 100644 index 0000000..fb52e26 --- /dev/null +++ b/src/suites/proof/unsupported-proofs-check.ts @@ -0,0 +1,53 @@ +import { VerificationCheck, CheckOutcome } from '../../types/check.js'; +import { VerificationSubject } from '../../types/subject.js'; +import { VerificationContext } from '../../types/context.js'; +import { isUnsupportedProof, problemsOfProof } from '../../crypto-dispatch.js'; +import { documentHasProof } from '../../util/document-has-proof.js'; +import { verifyProofSet } from './proof-set-verification.js'; + +/** + * Unsupported proofs check: the proofs in a proof set that this verifier + * could not check. + * + * A proof set often carries proofs for different audiences, and not + * every verifier supports every cryptosuite. So a proof that no + * registered suite supports, or that was made for a different proof + * purpose, does not fail `proof.signature` when another proof verified; + * it is reported here instead, one `PROOF_UNSUPPORTED` problem per + * proof. The check is not fatal: it is a warning, and the proofs that + * were checked decide the signature. + * + * It reads the verification `proof.signature` already ran (see + * `verifyProofSet`), so it verifies nothing itself. When the signature + * check fails it halts, and this check is not run. + */ +export const unsupportedProofsCheck: VerificationCheck = { + id: 'proof.unsupported-proofs', + name: 'Unsupported Proofs', + description: + 'Reports proofs in a proof set that this verifier could not check, such as a cryptosuite it does not support. A warning: the other proofs still decide the signature.', + fatal: false, + appliesTo: ['verifiableCredential', 'verifiablePresentation'], + execute: async ( + subject: VerificationSubject, + context: VerificationContext + ): Promise => { + const document = (subject.verifiablePresentation ?? + subject.verifiableCredential) as Record | undefined; + + // An unsigned presentation accepted through `unsignedPresentation`: + // there is nothing to verify, and asking the memo would dispatch. + if (!document || !documentHasProof(document)) { + return { status: 'skipped', reason: 'The document has no proof.' }; + } + + const { proofs } = await verifyProofSet(subject, context); + const problems = proofs + .filter(outcome => isUnsupportedProof(outcome.result)) + .flatMap(problemsOfProof); + + return problems.length > 0 + ? { status: 'failure', problems } + : { status: 'success', message: 'Every proof was checked.' }; + } +}; diff --git a/src/suites/status/status-lists.ts b/src/suites/status/status-lists.ts index ad39a12..12bd623 100644 --- a/src/suites/status/status-lists.ts +++ b/src/suites/status/status-lists.ts @@ -5,7 +5,7 @@ */ import { - dispatchProofVerification, + dispatchProofSetVerification, type CryptoDispatchResult } from '../../crypto-dispatch.js'; import type { @@ -246,7 +246,8 @@ const verifiedLists = new WeakMap< /** * Verify the proof of the status list credential `document`, loaded - * from `url`, once per `context`. + * from `url`, once per `context`. A list's proof set is verified proof + * by proof, like a credential's, so one bad proof makes a bad list. */ export function verifyStatusList( url: string, @@ -261,11 +262,11 @@ export function verifyStatusList( } let pending = byUrl.get(url); if (!pending) { - pending = dispatchProofVerification({ + pending = dispatchProofSetVerification({ services: context.cryptoServices, subject: { verifiableCredential: document }, options: { documentLoader: context.documentLoader, now } - }); + }).then(result => result.combined); byUrl.set(url, pending); } return pending; diff --git a/src/types/crypto-service.ts b/src/types/crypto-service.ts index 57d6a5f..9af7f8a 100644 --- a/src/types/crypto-service.ts +++ b/src/types/crypto-service.ts @@ -19,6 +19,14 @@ import type { VerificationSubject } from './subject.js'; * `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`. + * + * A document carrying a proof set is handed to the service one proof at + * a time (`dispatchProofSetVerification`), so "the proof itself" is that + * one proof. A service that cannot check the proof it was handed — no + * suite supports it, or it was made for another purpose — should reject + * with only `PROOF_UNSUPPORTED` problems: beside a proof that verified, + * `proof.unsupported-proofs` then reports it as a warning rather than a + * failure. */ export type CryptoResult = | { verified: true; message?: string } diff --git a/src/util/jsigs-no-match.ts b/src/util/jsigs-no-match.ts new file mode 100644 index 0000000..3f91476 --- /dev/null +++ b/src/util/jsigs-no-match.ts @@ -0,0 +1,43 @@ +/** + * `@digitalcredentials/jsonld-signatures`' "no proof matched" error. + * + * `ProofSet.verify` skips every proof whose `proofPurpose` is not the one + * being checked, or whose type no registered suite matches. When that + * leaves nothing to verify it fails with an `Error` named + * `NotFoundError`, which the exported `verify` wraps, like every other + * failure, in a `VerificationError` carrying it in `errors[]`; vc passes + * that through unchanged. It is the only signal that a proof was not + * checked at all, as opposed to checked and found bad. The message is + * jsonld-signatures 12.0.1's, matched in full. + */ + +/** jsonld-signatures' message when no proof matched a suite and purpose. */ +const JSIGS_NO_MATCH_MESSAGE = + 'Did not verify any proofs; insufficient proofs matched the ' + + 'acceptable suite(s) and required purpose(s).'; + +function isNoMatchError(error: unknown): boolean { + return ( + error instanceof Error && + error.name === 'NotFoundError' && + error.message === JSIGS_NO_MATCH_MESSAGE + ); +} + +/** + * Whether `error` is jsonld-signatures' "no proof matched" failure: a + * `VerificationError` wrapping that one error and nothing else. + * + * The name and the full message are both required: the document loader + * also writes "NotFoundError" into plain `Error` messages, and those are + * a failure to fetch, not an unchecked proof. + */ +export function isJsigsNoMatch(error: unknown): boolean { + if (!(error instanceof Error) || error.name !== 'VerificationError') { + return false; + } + const { errors } = error as { errors?: unknown }; + return ( + Array.isArray(errors) && errors.length === 1 && isNoMatchError(errors[0]) + ); +} diff --git a/test/challenge-enforcement.spec.ts b/test/challenge-enforcement.spec.ts index 85ad274..f5d1be2 100644 --- a/test/challenge-enforcement.spec.ts +++ b/test/challenge-enforcement.spec.ts @@ -23,6 +23,7 @@ import { Ed25519VerificationKey2020 } from '@digitalcredentials/ed25519-verifica import { Ed25519Signature2020 } from '@digitalcredentials/ed25519-signature-2020'; import { createVerifier } from '../src/verifier.js'; import { defaultDocumentLoaderFor } from '../src/default-services.js'; +import { ProblemTypes } from '../src/problem-types.js'; import type { CheckResult } from '../src/types/check.js'; const { purposes } = jsonLdSignatures; @@ -62,6 +63,23 @@ function expectInvalidSignature(results: CheckResult[]): void { } } +/** + * The presentation's own proof was not checked, because it was made for + * a purpose other than the authentication a challenge requires. + */ +function expectWrongPurpose(results: CheckResult[]): void { + const outcome = signatureResult(results)?.outcome; + expect(outcome?.status).toBe('failure'); + if (outcome?.status === 'failure') { + expect(outcome.problems[0]).toEqual({ + type: ProblemTypes.PROOF_UNSUPPORTED, + title: 'Unsupported Proof', + detail: + "This proof's purpose (assertionMethod) is not the one being checked (authentication)." + }); + } +} + describe('challenge enforcement with the default crypto service', () => { let key: Ed25519VerificationKey2020; let holderDid: string; @@ -129,7 +147,7 @@ describe('challenge enforcement with the default crypto service', () => { const result = await verify(presentation, { challenge: 'x' }); expect(result.verified).toBe(false); - expectInvalidSignature(result.presentationResults); + expectWrongPurpose(result.presentationResults); }); it('fails an authentication presentation signed for a different challenge', async () => { diff --git a/test/crypto-dispatch.spec.ts b/test/crypto-dispatch.spec.ts index 8a898c6..5d9294f 100644 --- a/test/crypto-dispatch.spec.ts +++ b/test/crypto-dispatch.spec.ts @@ -1,12 +1,18 @@ /** * Unit coverage for `dispatchProofVerification` — the shared - * crypto-service dispatch. Cases stay small and inline; spies are + * crypto-service dispatch — and for `dispatchProofSetVerification`, + * which splits a proof set into one dispatch per proof. Cases stay small and inline; spies are * hand-rolled CryptoService doubles so we can assert which method * ran and that later services are never invoked. */ import { describe, it, expect } from 'vitest'; -import { dispatchProofVerification } from '../src/crypto-dispatch.js'; +import { + dispatchProofSetVerification, + dispatchProofVerification, + isUnsupportedProof +} from '../src/crypto-dispatch.js'; +import { ProblemTypes } from '../src/problem-types.js'; import type { CryptoResult, CryptoService, @@ -259,3 +265,327 @@ describe('dispatchProofVerification', () => { expect(dispatched).toEqual({ kind: 'threw', error }); }); }); + +const proofA = { id: 'urn:proof:a', type: 'Ed25519Signature2020' }; +const proofB = { + id: 'urn:proof:b', + type: 'DataIntegrityProof', + cryptosuite: 'eddsa-rdfc-2022' +}; + +const invalid: CryptoResult = { + verified: false, + problems: [ + { + type: ProblemTypes.INVALID_SIGNATURE, + title: 'Invalid Signature', + detail: 'bad bytes' + } + ] +}; + +const unsupported: CryptoResult = { + verified: false, + problems: [ + { + type: ProblemTypes.PROOF_UNSUPPORTED, + title: 'Unsupported Proof', + detail: 'not supported' + } + ] +}; + +const expiredProblems: ProblemDetail[] = [ + { + type: ProblemTypes.CREDENTIAL_EXPIRED, + title: 'Credential Expired', + detail: 'Credential has expired.' + } +]; + +const expired: CryptoResult = { verified: false, problems: expiredProblems }; + +/** + * A service that answers by the `id` of the one proof it is handed, and + * records which document and proof each call saw. A document still + * carrying an array of proofs is recorded as `set`. + */ +function perProofService( + verdicts: Record, + calls: string[] +): CryptoService { + const answer = (kind: string, document: unknown): CryptoResult => { + const proof = (document as { proof?: unknown }).proof; + const id = Array.isArray(proof) + ? 'set' + : ((proof as { id?: string } | undefined)?.id ?? 'none'); + calls.push(`${kind}:${id}`); + const verdict = verdicts[id]; + if (verdict instanceof Error) { + throw verdict; + } + return verdict ?? invalid; + }; + return { + canVerify: () => true, + verifyCredential: async credential => answer('credential', credential), + verifyPresentation: async presentation => + answer('presentation', presentation) + }; +} + +function proofSetSubject(...proofs: unknown[]): VerificationSubject { + return { + verifiableCredential: { id: 'urn:test:credential', proof: proofs } + }; +} + +describe('dispatchProofSetVerification', () => { + it('dispatches a document with no proof unchanged', async () => { + const calls: string[] = []; + const result = await dispatchProofSetVerification({ + services: [ + perProofService({ none: { verified: true, message: 'whole' } }, calls) + ], + subject: credentialSubject, + options: dummyOptions + }); + + expect(result).toEqual({ + combined: { kind: 'verified', message: 'whole' }, + proofs: [] + }); + expect(calls).toEqual(['credential:none']); + }); + + it('dispatches a one-proof array unchanged, without splitting it', async () => { + const calls: string[] = []; + const result = await dispatchProofSetVerification({ + services: [ + perProofService({ set: { verified: true, message: 'whole' } }, calls) + ], + subject: proofSetSubject(proofA), + options: dummyOptions + }); + + expect(result).toEqual({ + combined: { kind: 'verified', message: 'whole' }, + proofs: [] + }); + expect(calls).toEqual(['credential:set']); + }); + + it('verifies each proof of a set on its own and passes when all verify', async () => { + const calls: string[] = []; + const result = await dispatchProofSetVerification({ + services: [ + perProofService( + { + 'urn:proof:a': { verified: true, message: 'a ok' }, + 'urn:proof:b': { verified: true, message: 'b ok' } + }, + calls + ) + ], + subject: proofSetSubject(proofA, proofB), + options: dummyOptions + }); + + expect(result.combined).toEqual({ kind: 'verified', message: 'a ok' }); + expect(calls.sort()).toEqual([ + 'credential:urn:proof:a', + 'credential:urn:proof:b' + ]); + expect( + result.proofs.map(p => [p.position, p.total, p.label, p.result.kind]) + ).toEqual([ + [1, 2, 'Ed25519Signature2020', 'verified'], + [2, 2, 'DataIntegrityProof, eddsa-rdfc-2022', 'verified'] + ]); + }); + + it('rejects the set when one proof fails among verified ones, naming that proof', async () => { + const result = await dispatchProofSetVerification({ + services: [ + perProofService( + { 'urn:proof:a': { verified: true }, 'urn:proof:b': invalid }, + [] + ) + ], + subject: proofSetSubject(proofA, proofB), + options: dummyOptions + }); + + expect(result.combined).toEqual({ + kind: 'rejected', + problems: [ + { + type: ProblemTypes.INVALID_SIGNATURE, + title: 'Invalid Signature', + detail: + 'Proof 2 of 2 (DataIntegrityProof, eddsa-rdfc-2022): bad bytes' + } + ] + }); + }); + + it('reports every failing proof when several fail', async () => { + const result = await dispatchProofSetVerification({ + services: [ + perProofService({ 'urn:proof:a': invalid, 'urn:proof:b': invalid }, []) + ], + subject: proofSetSubject(proofA, proofB), + options: dummyOptions + }); + + expect( + result.combined.kind === 'rejected' && + result.combined.problems.map(p => p.detail) + ).toEqual([ + 'Proof 1 of 2 (Ed25519Signature2020): bad bytes', + 'Proof 2 of 2 (DataIntegrityProof, eddsa-rdfc-2022): bad bytes' + ]); + }); + + it('rejects the set with the unsupported problems when no proof could be checked', async () => { + const result = await dispatchProofSetVerification({ + services: [ + perProofService( + { 'urn:proof:a': unsupported, 'urn:proof:b': unsupported }, + [] + ) + ], + subject: proofSetSubject(proofA, proofB), + options: dummyOptions + }); + + expect(result.combined.kind).toBe('rejected'); + expect( + result.combined.kind === 'rejected' && + result.combined.problems.map(p => [p.type, p.detail]) + ).toEqual([ + [ + ProblemTypes.PROOF_UNSUPPORTED, + 'Proof 1 of 2 (Ed25519Signature2020): not supported' + ], + [ + ProblemTypes.PROOF_UNSUPPORTED, + 'Proof 2 of 2 (DataIntegrityProof, eddsa-rdfc-2022): not supported' + ] + ]); + }); + + it('passes the set when an unsupported proof sits beside a verified one, and keeps it in proofs', async () => { + const result = await dispatchProofSetVerification({ + services: [ + perProofService( + { + 'urn:proof:a': { verified: true, message: 'a ok' }, + 'urn:proof:b': unsupported + }, + [] + ) + ], + subject: proofSetSubject(proofA, proofB), + options: dummyOptions + }); + + expect(result.combined).toEqual({ kind: 'verified', message: 'a ok' }); + expect(result.proofs.map(p => isUnsupportedProof(p.result))).toEqual([ + false, + true + ]); + }); + + it('reports a date-only rejection beside a verified proof unprefixed', async () => { + const result = await dispatchProofSetVerification({ + services: [ + perProofService( + { 'urn:proof:a': { verified: true }, 'urn:proof:b': expired }, + [] + ) + ], + subject: proofSetSubject(proofA, proofB), + options: dummyOptions + }); + + expect(result.combined).toEqual({ + kind: 'rejected', + problems: expiredProblems + }); + }); + + it('lets a failing proof win over a date-only one', async () => { + const result = await dispatchProofSetVerification({ + services: [ + perProofService({ 'urn:proof:a': expired, 'urn:proof:b': invalid }, []) + ], + subject: proofSetSubject(proofA, proofB), + options: dummyOptions + }); + + expect( + result.combined.kind === 'rejected' && + result.combined.problems.map(p => p.type) + ).toEqual([ProblemTypes.INVALID_SIGNATURE]); + }); + + it('returns the first proof that threw', async () => { + const error = new Error('boom on b'); + const result = await dispatchProofSetVerification({ + services: [ + perProofService( + { 'urn:proof:a': { verified: true }, 'urn:proof:b': error }, + [] + ) + ], + subject: proofSetSubject(proofA, proofB), + options: dummyOptions + }); + + expect(result.combined).toEqual({ kind: 'threw', error }); + }); + + it('returns no-service when there is no service, as for a single proof', async () => { + const result = await dispatchProofSetVerification({ + services: [], + subject: proofSetSubject(proofA, proofB), + options: dummyOptions + }); + + expect(result.combined).toEqual({ kind: 'no-service' }); + }); + + it('does not change the document it was given', async () => { + const subject = proofSetSubject(proofA, proofB); + const credential = subject.verifiableCredential as Record; + const proofSet = credential.proof; + const before = structuredClone(credential); + + await dispatchProofSetVerification({ + services: [perProofService({}, [])], + subject, + options: dummyOptions + }); + + expect(credential).toEqual(before); + expect(credential.proof).toBe(proofSet); + }); + + it('splits the presentation, not the credential, when the subject carries both', async () => { + const calls: string[] = []; + await dispatchProofSetVerification({ + services: [perProofService({}, calls)], + subject: { + verifiableCredential: { proof: [{ id: 'c1' }, { id: 'c2' }] }, + verifiablePresentation: { proof: [proofA, proofB] } + }, + options: dummyOptions + }); + + expect(calls.sort()).toEqual([ + 'presentation:urn:proof:a', + 'presentation:urn:proof:b' + ]); + }); +}); diff --git a/test/factories/data/did-key-signers.ts b/test/factories/data/did-key-signers.ts new file mode 100644 index 0000000..0e36b37 --- /dev/null +++ b/test/factories/data/did-key-signers.ts @@ -0,0 +1,54 @@ +/** + * Signing suites for offline real-signature tests: one `did:key` issuer + * that signs with both `Ed25519Signature2020` and `DataIntegrityProof` + + * `eddsa-rdfc-2022`, as `status-list-cryptosuite-parity.spec.ts` does. + * `did:key` resolves from the identifier itself, so verifying needs no + * network. + */ + +import { Ed25519VerificationKey2020 } from '@digitalcredentials/ed25519-verification-key-2020'; +import { Ed25519Signature2020 } from '@digitalcredentials/ed25519-signature-2020'; +import { DataIntegrityProof } from '@digitalcredentials/data-integrity'; +import { cryptosuite as eddsaRdfc2022CryptoSuite } from '@digitalcredentials/eddsa-rdfc-2022-cryptosuite'; +import * as Ed25519Multikey from '@digitalcredentials/ed25519-multikey'; + +// The package ships no type declarations, and the ones TypeScript infers +// from its JavaScript give `generate` no parameters; it takes a seed. +const generateMultikey = Ed25519Multikey.generate as unknown as (options: { + seed: Uint8Array; + controller: string; + id: string; +}) => ReturnType; + +export interface DidKeySigners { + issuerDid: string; + /** A new `Ed25519Signature2020` suite for each signature. */ + ed25519Signature2020: () => InstanceType; + /** A new `DataIntegrityProof` + `eddsa-rdfc-2022` suite for each signature. */ + eddsaRdfc2022: () => InstanceType; +} + +/** Both suites for the `did:key` derived from a seed filled with `seedByte`. */ +export async function DidKeySigners(seedByte: number): Promise { + const seed = new Uint8Array(32).fill(seedByte); + const key = await Ed25519VerificationKey2020.generate({ seed }); + const issuerDid = `did:key:${key.fingerprint()}`; + key.controller = issuerDid; + key.id = `${issuerDid}#${key.fingerprint()}`; + + const multiKey = await generateMultikey({ + seed, + controller: issuerDid, + id: key.id + }); + + return { + issuerDid, + ed25519Signature2020: () => new Ed25519Signature2020({ key }), + eddsaRdfc2022: () => + new DataIntegrityProof({ + signer: multiKey.signer(), + cryptosuite: eddsaRdfc2022CryptoSuite + }) + }; +} diff --git a/test/proof-sets.spec.ts b/test/proof-sets.spec.ts new file mode 100644 index 0000000..731ec7c --- /dev/null +++ b/test/proof-sets.spec.ts @@ -0,0 +1,575 @@ +/** + * Acceptance evidence that every proof in a proof set must verify (VC + * Data Integrity §4.5): a credential, presentation or status list + * credential signed with both `Ed25519Signature2020` and + * `DataIntegrityProof` + `eddsa-rdfc-2022` fails when either proof is + * broken, not only when both are. A proof this verifier cannot check + * beside one that verified is a warning on `proof.unsupported-proofs`. + * + * Runs offline with real signatures, the way + * `status-list-cryptosuite-parity.spec.ts` does: `did:key` resolves from + * the identifier itself, every `@context` is bundled in + * `@digitalcredentials/security-document-loader`, status lists are + * served by the test's document loader, and the `httpGetService` throws + * if anything reaches for the network. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { + createPresentation, + issue, + signPresentation +} from '@digitalcredentials/vc'; +import jsonLdSignatures from '@digitalcredentials/jsonld-signatures'; +import { + createCredential, + createList +} from '@digitalcredentials/vc-bitstring-status-list'; +import { createVerifier } from '../src/verifier.js'; +import { + defaultCryptoServices, + defaultDocumentLoaderFor +} from '../src/default-services.js'; +import { ProblemTypes } from '../src/problem-types.js'; +import type { CheckResult } from '../src/types/check.js'; +import type { CryptoService } from '../src/types/crypto-service.js'; +import type { DocumentLoader } from '../src/types/context.js'; +import type { HttpGetService } from '../src/services/http-get-service/http-get-service.js'; +import type { HttpGetResult } from '../src/types/http.js'; +import { BitstringStatusEntry } from './factories/data/index.js'; +import { + DidKeySigners, + type DidKeySigners as Signers +} from './factories/data/did-key-signers.js'; + +const { purposes } = jsonLdSignatures; + +const STATUS_LIST_URL = 'https://factory.test/status/proof-set-list'; +const BROKEN_STATUS_LIST_URL = + 'https://factory.test/status/proof-set-list-broken'; + +const offlineHttpGetService: HttpGetService = { + async get(url: string): Promise { + throw new Error(`test reached the network for ${url}`); + } +}; + +const bundledLoader = defaultDocumentLoaderFor(offlineHttpGetService); + +const ED25519_LABEL = 'Ed25519Signature2020'; +const EDDSA_LABEL = 'DataIntegrityProof, eddsa-rdfc-2022'; + +const NOT_RUN_AFTER_SIGNATURE = { + status: 'skipped', + reason: 'Not run: proof.signature failed' +}; + +const V2_CONTEXT = [ + 'https://www.w3.org/ns/credentials/v2', + 'https://w3id.org/security/suites/ed25519-2020/v1' +]; + +const V1_CONTEXT = [ + 'https://www.w3.org/2018/credentials/v1', + 'https://w3id.org/security/suites/ed25519-2020/v1', + 'https://w3id.org/security/data-integrity/v2' +]; + +type Doc = Record; + +/** + * Sign `document` with `Ed25519Signature2020`, then again with + * `DataIntegrityProof` + `eddsa-rdfc-2022`. jsonld-signatures appends a + * second proof to an existing one, so the result carries a set of two. + */ +async function signTwice(document: Doc, signers: Signers): Promise { + const once = (await issue({ + credential: { ...document }, + suite: signers.ed25519Signature2020(), + documentLoader: bundledLoader + })) as Doc; + const twice = (await issue({ + credential: { ...once }, + suite: signers.eddsaRdfc2022(), + documentLoader: bundledLoader + })) as Doc; + expect(twice.proof, 'signed with a set of two proofs').toHaveLength(2); + return twice; +} + +/** + * `document` with the last character of proof `index`'s `proofValue` + * replaced by another base58btc character, as issue #67 describes. + */ +function breakProof(document: Doc, index: number): Doc { + const proofs = document.proof as Doc[]; + const value = proofs[index].proofValue as string; + const broken = value.slice(0, -1) + (value.endsWith('A') ? 'B' : 'A'); + expect(broken).not.toBe(value); + return { + ...document, + proof: proofs.map((proof, i) => + i === index ? { ...proof, proofValue: broken } : proof + ) + }; +} + +function row(results: CheckResult[], id: string): CheckResult | undefined { + const found = results.find(r => r.id === id); + expect(found, `${id} result present`).toBeDefined(); + return found; +} + +/** The `[type, detail]` of each problem on a failed row. */ +function failureProblems( + results: CheckResult[], + id: string +): [string, string][] { + const outcome = row(results, id)?.outcome; + expect(outcome?.status).toBe('failure'); + return outcome?.status === 'failure' + ? outcome.problems.map(p => [p.type, p.detail]) + : []; +} + +describe('proof sets: every proof must verify', () => { + let signers: Signers; + let v2: Doc; + let v2Expired: Doc; + let v1: Doc; + let singleProof: Doc; + let presentation: Doc; + let subjectWithList: Doc; + let subjectWithBrokenList: Doc; + let documentLoader: DocumentLoader; + + beforeAll(async () => { + signers = await DidKeySigners(41); + const issuer = signers.issuerDid; + const credentialSubject = { id: 'did:example:subject' }; + + v2 = await signTwice( + { + '@context': V2_CONTEXT, + id: 'urn:uuid:proof-sets-v2', + type: ['VerifiableCredential'], + issuer, + validFrom: '2020-01-01T00:00:00Z', + credentialSubject + }, + signers + ); + + v2Expired = await signTwice( + { + '@context': V2_CONTEXT, + id: 'urn:uuid:proof-sets-v2-expired', + type: ['VerifiableCredential'], + issuer, + validFrom: '2010-01-01T00:00:00Z', + validUntil: '2011-01-01T00:00:00Z', + credentialSubject + }, + signers + ); + + v1 = await signTwice( + { + '@context': V1_CONTEXT, + id: 'urn:uuid:proof-sets-v1', + type: ['VerifiableCredential'], + issuer, + issuanceDate: '2020-01-01T00:00:00Z', + credentialSubject + }, + signers + ); + + singleProof = (await issue({ + credential: { + '@context': V2_CONTEXT, + id: 'urn:uuid:proof-sets-single', + type: ['VerifiableCredential'], + issuer, + validFrom: '2020-01-01T00:00:00Z', + credentialSubject + }, + suite: signers.ed25519Signature2020(), + documentLoader: bundledLoader + })) as Doc; + + // A presentation whose own proof is a set of two, both made for + // assertionMethod so that no challenge is needed to verify them. + const unsignedPresentation = createPresentation({ + holder: issuer + }) as Doc; + unsignedPresentation['@context'] = [ + ...(unsignedPresentation['@context'] as string[]), + 'https://w3id.org/security/suites/ed25519-2020/v1', + 'https://w3id.org/security/data-integrity/v2' + ]; + const presentedOnce = (await signPresentation({ + presentation: { ...unsignedPresentation }, + suite: signers.ed25519Signature2020(), + purpose: new purposes.AssertionProofPurpose(), + documentLoader: bundledLoader + })) as Doc; + presentation = (await signPresentation({ + presentation: { ...presentedOnce }, + suite: signers.eddsaRdfc2022(), + purpose: new purposes.AssertionProofPurpose(), + documentLoader: bundledLoader + })) as Doc; + expect(presentation.proof).toHaveLength(2); + + // Two status lists, each signed with both proofs; the second then has + // its DataIntegrityProof broken. + const signList = async (url: string) => { + const unsigned = (await createCredential({ + id: url, + list: await createList({ length: 32 }), + statusPurpose: 'revocation' + })) as Doc; + const context = unsigned['@context']; + return signTwice( + { + ...unsigned, + '@context': [ + ...(Array.isArray(context) ? context : [context]), + 'https://w3id.org/security/suites/ed25519-2020/v1' + ], + issuer, + validFrom: '2020-01-01T00:00:00Z' + }, + signers + ); + }; + const lists: Record = { + [STATUS_LIST_URL]: await signList(STATUS_LIST_URL), + [BROKEN_STATUS_LIST_URL]: breakProof( + await signList(BROKEN_STATUS_LIST_URL), + 1 + ) + }; + documentLoader = async (url: string) => + url in lists + ? { contextUrl: null, document: lists[url], documentUrl: url } + : bundledLoader(url); + + const subjectFor = async (statusListCredential: string) => + (await issue({ + credential: { + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://w3id.org/security/data-integrity/v2' + ], + id: `urn:uuid:proof-sets-subject-${statusListCredential.length}`, + type: ['VerifiableCredential'], + issuer, + validFrom: '2020-01-01T00:00:00Z', + credentialSubject, + credentialStatus: BitstringStatusEntry({ + statusListCredential, + statusListIndex: '0', + statusPurpose: 'revocation' + }) + }, + suite: signers.eddsaRdfc2022(), + documentLoader: bundledLoader + })) as Doc; + subjectWithList = await subjectFor(STATUS_LIST_URL); + subjectWithBrokenList = await subjectFor(BROKEN_STATUS_LIST_URL); + }); + + const verifier = (cryptoServices?: CryptoService[]) => + createVerifier({ + httpGetService: offlineHttpGetService, + documentLoader, + cryptoServices + }); + + const verifyCredential = (credential: Doc) => + verifier().verifyCredential({ credential, phases: ['cryptographic'] }); + + describe("issue #67's table, v2 credential", () => { + it('verifies when both proofs are valid', async () => { + const result = await verifyCredential(v2); + + expect(result.verified).toBe(true); + expect( + row(result.results, 'cryptographic.proof.signature')?.outcome + ).toEqual({ + status: 'success', + message: 'Signature verified successfully.' + }); + }); + + it('fails when the DataIntegrityProof is broken, naming it', async () => { + const result = await verifyCredential(breakProof(v2, 1)); + + expect(result.verified).toBe(false); + const problems = failureProblems( + result.results, + 'cryptographic.proof.signature' + ); + expect(problems).toHaveLength(1); + expect(problems[0][0]).toBe(ProblemTypes.INVALID_SIGNATURE); + expect(problems[0][1]).toMatch( + new RegExp(`^Proof 2 of 2 \\(${EDDSA_LABEL}\\): `) + ); + }); + + it('fails when the Ed25519Signature2020 proof is broken, naming it', async () => { + const result = await verifyCredential(breakProof(v2, 0)); + + expect(result.verified).toBe(false); + const problems = failureProblems( + result.results, + 'cryptographic.proof.signature' + ); + expect(problems).toHaveLength(1); + expect(problems[0][0]).toBe(ProblemTypes.INVALID_SIGNATURE); + expect(problems[0][1]).toMatch( + new RegExp(`^Proof 1 of 2 \\(${ED25519_LABEL}\\): `) + ); + }); + + it('fails with a problem for each proof when both are broken', async () => { + const result = await verifyCredential(breakProof(breakProof(v2, 0), 1)); + + expect(result.verified).toBe(false); + const problems = failureProblems( + result.results, + 'cryptographic.proof.signature' + ); + expect(problems.map(([type]) => type)).toEqual([ + ProblemTypes.INVALID_SIGNATURE, + ProblemTypes.INVALID_SIGNATURE + ]); + expect(problems[0][1]).toMatch(/^Proof 1 of 2 /); + expect(problems[1][1]).toMatch(/^Proof 2 of 2 /); + }); + }); + + describe('an expired credential with a proof set', () => { + it('reports only the expiry when both proofs are valid', async () => { + const result = await verifyCredential(v2Expired); + + expect(result.verified).toBe(false); + expect( + row(result.results, 'cryptographic.proof.signature')?.outcome.status + ).toBe('success'); + expect( + failureProblems( + result.results, + 'cryptographic.proof.validity-period' + ).map(([type]) => type) + ).toEqual([ProblemTypes.CREDENTIAL_EXPIRED]); + }); + + it('reports the broken proof, not "verified, merely expired"', async () => { + const result = await verifyCredential(breakProof(v2Expired, 1)); + + expect(result.verified).toBe(false); + const problems = failureProblems( + result.results, + 'cryptographic.proof.signature' + ); + expect(problems.map(([type]) => type)).toEqual([ + ProblemTypes.INVALID_SIGNATURE + ]); + expect(problems[0][1]).toMatch(/^Proof 2 of 2 /); + expect( + row(result.results, 'cryptographic.proof.validity-period')?.outcome + ).toEqual(NOT_RUN_AFTER_SIGNATURE); + }); + }); + + describe('a v1 credential with a proof set', () => { + it('verifies when both proofs are valid', async () => { + expect((await verifyCredential(v1)).verified).toBe(true); + }); + + for (const [index, label] of [ + [0, ED25519_LABEL], + [1, EDDSA_LABEL] + ] as const) { + it(`fails when proof ${index + 1} (${label}) is broken`, async () => { + const result = await verifyCredential(breakProof(v1, index)); + + expect(result.verified).toBe(false); + const problems = failureProblems( + result.results, + 'cryptographic.proof.signature' + ); + expect(problems).toHaveLength(1); + expect(problems[0][1]).toContain( + `Proof ${index + 1} of 2 (${label}): ` + ); + }); + } + }); + + describe('a status list credential with a proof set', () => { + it('accepts the list when both proofs are valid', async () => { + const result = await verifyCredential(subjectWithList); + + expect(result.verified).toBe(true); + expect( + row(result.results, 'cryptographic.status.bitstring')?.outcome.status + ).toBe('success'); + }); + + it('treats a list with one broken proof as a bad list signature', async () => { + const result = await verifyCredential(subjectWithBrokenList); + + expect(result.verified).toBe(false); + expect( + failureProblems(result.results, 'cryptographic.status.bitstring').map( + ([type]) => type + ) + ).toEqual([ProblemTypes.STATUS_LIST_SIGNATURE_ERROR]); + }); + }); + + describe("a presentation's own proof set", () => { + it('verifies when both proofs are valid', async () => { + const result = await verifier().verifyPresentation({ presentation }); + + expect( + row(result.presentationResults, 'cryptographic.proof.signature') + ?.outcome.status + ).toBe('success'); + }); + + it('fails when one proof is broken', async () => { + const result = await verifier().verifyPresentation({ + presentation: breakProof(presentation, 1) + }); + + expect(result.verified).toBe(false); + const problems = failureProblems( + result.presentationResults, + 'cryptographic.proof.signature' + ); + expect(problems).toHaveLength(1); + expect(problems[0][1]).toMatch( + new RegExp(`^Proof 2 of 2 \\(${EDDSA_LABEL}\\): `) + ); + }); + }); + + it('verifies a single-proof credential as before', async () => { + const result = await verifyCredential(singleProof); + + expect(result.verified).toBe(true); + expect( + row(result.results, 'cryptographic.proof.signature')?.outcome + ).toEqual({ + status: 'success', + message: 'Signature verified successfully.' + }); + }); + + describe('proofs this verifier cannot check', () => { + const eddsaProof = () => (v2.proof as Doc[])[1]; + const ed25519Proof = () => (v2.proof as Doc[])[0]; + const UNSUPPORTED_ROW = 'cryptographic.proof.unsupported-proofs'; + + it('warns about an unsupported cryptosuite beside a verified proof', async () => { + const result = await verifyCredential({ + ...v2, + proof: [eddsaProof(), { ...eddsaProof(), cryptosuite: 'bbs-2023' }] + }); + + expect(result.verified).toBe(true); + expect( + row(result.results, 'cryptographic.proof.signature')?.outcome.status + ).toBe('success'); + expect(row(result.results, UNSUPPORTED_ROW)?.fatal).toBe(false); + expect(failureProblems(result.results, UNSUPPORTED_ROW)).toEqual([ + [ + ProblemTypes.PROOF_UNSUPPORTED, + "Proof 2 of 2 (DataIntegrityProof, bbs-2023): This verifier does not support this proof's cryptosuite." + ] + ]); + expect( + result.summary.find(s => s.suite === 'proof')?.fatalFailureAt + ).toBeUndefined(); + }); + + it('warns about a proof made for another purpose beside a verified proof', async () => { + const result = await verifyCredential({ + ...v2, + proof: [ + eddsaProof(), + { ...ed25519Proof(), proofPurpose: 'authentication' } + ] + }); + + expect(result.verified).toBe(true); + expect(failureProblems(result.results, UNSUPPORTED_ROW)).toEqual([ + [ + ProblemTypes.PROOF_UNSUPPORTED, + "Proof 2 of 2 (Ed25519Signature2020): This proof's purpose (authentication) is not the one being checked (assertionMethod)." + ] + ]); + }); + + it('fails the signature when the only proof is unsupported', async () => { + const result = await verifyCredential({ + ...v2, + proof: { ...eddsaProof(), cryptosuite: 'bbs-2023' } + }); + + expect(result.verified).toBe(false); + expect( + failureProblems(result.results, 'cryptographic.proof.signature').map( + ([type]) => type + ) + ).toEqual([ProblemTypes.PROOF_UNSUPPORTED]); + expect(row(result.results, UNSUPPORTED_ROW)?.outcome).toEqual( + NOT_RUN_AFTER_SIGNATURE + ); + }); + + it('reports every proof checked for a set of two supported proofs', async () => { + const result = await verifyCredential(v2); + + expect(row(result.results, UNSUPPORTED_ROW)?.outcome).toEqual({ + status: 'success', + message: 'Every proof was checked.' + }); + }); + + it('reports every proof checked for a single proof', async () => { + const result = await verifyCredential(singleProof); + + expect(row(result.results, UNSUPPORTED_ROW)?.outcome).toEqual({ + status: 'success', + message: 'Every proof was checked.' + }); + }); + }); + + it('asks the crypto service once per proof, for both proof checks together', async () => { + const [service] = defaultCryptoServices(); + const seen: unknown[] = []; + const counting: CryptoService = { + ...service, + verifyCredential: async (credential, options) => { + seen.push((credential as Doc).proof); + return service.verifyCredential(credential, options); + } + }; + + const result = await verifier([counting]).verifyCredential({ + credential: v2, + phases: ['cryptographic'] + }); + + expect(result.verified).toBe(true); + expect(seen).toHaveLength(2); + expect(seen.every(proof => !Array.isArray(proof))).toBe(true); + }); +}); diff --git a/test/services/data-integrity-unsupported-proof.spec.ts b/test/services/data-integrity-unsupported-proof.spec.ts new file mode 100644 index 0000000..7b2231f --- /dev/null +++ b/test/services/data-integrity-unsupported-proof.spec.ts @@ -0,0 +1,160 @@ +/** + * The Data Integrity crypto service reports a proof it could not check — + * no registered suite supports it, or it was made for another purpose — + * as `PROOF_UNSUPPORTED`, saying which of the two it was, rather than as + * a bad signature. + * + * Runs offline with real signatures, the way + * `credential-validity-dates.spec.ts` does: `did:key` resolves from the + * identifier itself, every `@context` is bundled in + * `@digitalcredentials/security-document-loader`, and the + * `httpGetService` throws if anything reaches for the network. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { issue } from '@digitalcredentials/vc'; +import { + defaultCryptoSuites, + defaultDocumentLoaderFor +} from '../../src/default-services.js'; +import { DataIntegrityCryptoService } from '../../src/services/data-integrity-crypto.js'; +import { ProblemTypes } from '../../src/problem-types.js'; +import type { CryptoResult } from '../../src/types/crypto-service.js'; +import type { HttpGetService } from '../../src/services/http-get-service/http-get-service.js'; +import type { HttpGetResult } from '../../src/types/http.js'; +import { DidKeySigners } from '../factories/data/did-key-signers.js'; + +const offlineHttpGetService: HttpGetService = { + async get(url: string): Promise { + throw new Error(`test reached the network for ${url}`); + } +}; + +const bundledLoader = defaultDocumentLoaderFor(offlineHttpGetService); + +const service = DataIntegrityCryptoService({ suites: defaultCryptoSuites() }); +const options = { documentLoader: bundledLoader }; + +function onlyProblem(result: CryptoResult) { + expect(result.verified).toBe(false); + if (result.verified) { + return undefined; + } + expect(result.problems).toHaveLength(1); + return result.problems[0]; +} + +describe('DataIntegrityCryptoService: proofs it cannot check', () => { + let signed: Record; + let signedProof: Record; + + beforeAll(async () => { + const signers = await DidKeySigners(31); + + signed = (await issue({ + credential: { + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://w3id.org/security/data-integrity/v2' + ], + id: 'urn:uuid:data-integrity-unsupported-proof', + type: ['VerifiableCredential'], + issuer: signers.issuerDid, + validFrom: '2020-01-01T00:00:00Z', + credentialSubject: { id: 'did:example:subject' } + }, + suite: signers.eddsaRdfc2022(), + documentLoader: bundledLoader + })) as Record; + signedProof = signed.proof as Record; + }); + + it('verifies the credential as signed', async () => { + expect(await service.verifyCredential(signed, options)).toEqual({ + verified: true, + message: 'Signature verified successfully.' + }); + }); + + it('names the cryptosuite when no registered suite supports it', async () => { + const problem = onlyProblem( + await service.verifyCredential( + { ...signed, proof: { ...signedProof, cryptosuite: 'bbs-2023' } }, + options + ) + ); + expect(problem).toEqual({ + type: ProblemTypes.PROOF_UNSUPPORTED, + title: 'Unsupported Proof', + detail: "This verifier does not support this proof's cryptosuite." + }); + }); + + it('names the purpose when a supported proof was made for another one', async () => { + const problem = onlyProblem( + await service.verifyCredential( + { + ...signed, + proof: { ...signedProof, proofPurpose: 'authentication' } + }, + options + ) + ); + expect(problem?.type).toBe(ProblemTypes.PROOF_UNSUPPORTED); + expect(problem?.detail).toBe( + "This proof's purpose (authentication) is not the one being checked (assertionMethod)." + ); + }); + + it('uses a general wording when several proofs went unchecked', async () => { + const problem = onlyProblem( + await service.verifyCredential( + { + ...signed, + proof: [ + { ...signedProof, cryptosuite: 'bbs-2023' }, + { ...signedProof, proofPurpose: 'authentication' } + ] + }, + options + ) + ); + expect(problem?.type).toBe(ProblemTypes.PROOF_UNSUPPORTED); + expect(problem?.detail).toBe( + 'No proof in this document could be checked: none uses a cryptosuite this verifier supports with the expected proof purpose.' + ); + }); + + it("reports a presentation's unsupported proof the same way", async () => { + const problem = onlyProblem( + await service.verifyPresentation( + { + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://w3id.org/security/data-integrity/v2' + ], + type: ['VerifiablePresentation'], + proof: { + ...signedProof, + cryptosuite: 'bbs-2023', + proofPurpose: 'authentication' + } + }, + options + ) + ); + expect(problem?.detail).toBe( + "This verifier does not support this proof's cryptosuite." + ); + }); + + it('still reports a tampered proof as INVALID_SIGNATURE', async () => { + const problem = onlyProblem( + await service.verifyCredential( + { ...signed, credentialSubject: { id: 'did:example:someone-else' } }, + options + ) + ); + expect(problem?.type).toBe(ProblemTypes.INVALID_SIGNATURE); + }); +}); diff --git a/test/status-list-cryptosuite-parity.spec.ts b/test/status-list-cryptosuite-parity.spec.ts index 72d1970..7480df4 100644 --- a/test/status-list-cryptosuite-parity.spec.ts +++ b/test/status-list-cryptosuite-parity.spec.ts @@ -112,8 +112,10 @@ describe('status list cryptosuite parity', () => { 'https://w3id.org/security/suites/ed25519-2020/v1' ]; + // vc's `issue` adds the proof to the object it is given. Sign copies, + // so the lists spread from these templates carry only their own proof. signedStatusList = (await issue({ - credential: unsignedList, + credential: { ...unsignedList }, suite: new Ed25519Signature2020({ key }), documentLoader: bundledLoader })) as Record; @@ -144,7 +146,7 @@ describe('status list cryptosuite parity', () => { } }; signedForeignStatusList = (await issue({ - credential: unsignedForeignList, + credential: { ...unsignedForeignList }, suite: new Ed25519Signature2020({ key: foreignKey }), documentLoader: bundledLoader })) as Record; diff --git a/test/suites/proof.spec.ts b/test/suites/proof.spec.ts index 91096b9..9b9e0e4 100644 --- a/test/suites/proof.spec.ts +++ b/test/suites/proof.spec.ts @@ -3,9 +3,11 @@ 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 { unsupportedProofsCheck } from '../../src/suites/proof/unsupported-proofs-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 type { CryptoService } from '../../src/types/crypto-service.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'; @@ -56,6 +58,7 @@ describe('Proof Verification Suite', () => { expect(results.map(r => r.id)).toEqual([ 'cryptographic.proof.signature', + 'cryptographic.proof.unsupported-proofs', 'cryptographic.proof.validity-period' ]); expect(results[0].outcome.status).toBe('success'); @@ -139,9 +142,10 @@ describe('Proof Verification Suite', () => { const subject = createPresentationSubject(presentation); const results = await runSuites([proofSuite], subject, context); - expect(results).toHaveLength(1); + expect(results).toHaveLength(2); expect(results[0].id).toBe('cryptographic.proof.signature'); expect(results[0].outcome.status).toBe('success'); + expect(results[1].id).toBe('cryptographic.proof.unsupported-proofs'); }); it('skips an unsigned presentation when context allows it', async () => { @@ -312,11 +316,13 @@ describe('Proof Verification Suite', () => { const subject = createCredentialSubject(cred); const results = await runSuites([proofSuite], subject, context); - expect(results).toHaveLength(2); - expect(results[1].outcome).toEqual({ - status: 'skipped', - reason: 'Not run: proof.signature failed' - }); + expect(results).toHaveLength(3); + for (const notRun of results.slice(1)) { + expect(notRun.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( @@ -365,6 +371,7 @@ describe('Proof Verification Suite', () => { expect(validityPeriodCheck.halts).toBe(false); expect(proofSuite.checks.map(c => c.id)).toEqual([ 'proof.signature', + 'proof.unsupported-proofs', 'proof.validity-period' ]); }); @@ -494,4 +501,138 @@ describe('Proof Verification Suite', () => { }); }); }); + + describe('unsupportedProofsCheck', () => { + const supported = { type: 'Ed25519Signature2020' }; + const alsoSupported = { + type: 'DataIntegrityProof', + cryptosuite: 'eddsa-rdfc-2022' + }; + const unsupported = { type: 'DataIntegrityProof', cryptosuite: 'bbs-2023' }; + + /** + * Rejects a lone bbs-2023 proof as unsupported and verifies any other, + * recording every proof it is handed. + */ + function bbsUnsupported(seen: unknown[]): CryptoService { + const answer = async (document: unknown) => { + const { proof } = document as { proof?: { cryptosuite?: string } }; + seen.push(proof); + return proof?.cryptosuite === 'bbs-2023' + ? { + verified: false as const, + problems: [ + { + type: ProblemTypes.PROOF_UNSUPPORTED, + title: 'Unsupported Proof', + detail: 'Not supported here.' + } + ] + } + : { verified: true as const }; + }; + return { + canVerify: () => true, + verifyCredential: answer, + verifyPresentation: answer + }; + } + + const withProof = (proof: unknown) => + createCredentialSubject( + CredentialFactory({ version: 'v2', credential: { proof } }) + ); + + it('is a non-fatal check between proof.signature and proof.validity-period', () => { + expect(unsupportedProofsCheck.fatal).toBe(false); + expect(proofSuite.checks.indexOf(unsupportedProofsCheck)).toBe(1); + }); + + it('warns about an unsupported proof beside a verified one, reusing the verification', async () => { + const seen: unknown[] = []; + const results = await runSuites( + [proofSuite], + withProof([supported, unsupported]), + buildTestContext({ cryptoServices: [bbsUnsupported(seen)] }) + ); + + expect(results[0].outcome.status).toBe('success'); + expect(results[1]).toMatchObject({ + id: 'cryptographic.proof.unsupported-proofs', + fatal: false, + outcome: { + status: 'failure', + problems: [ + { + type: ProblemTypes.PROOF_UNSUPPORTED, + title: 'Unsupported Proof', + detail: + 'Proof 2 of 2 (DataIntegrityProof, bbs-2023): Not supported here.' + } + ] + } + }); + // One call per proof, shared by both checks. + expect(seen).toEqual([supported, unsupported]); + }); + + it('succeeds when every proof of a set was checked', async () => { + const outcome = await unsupportedProofsCheck.execute( + withProof([supported, alsoSupported]), + buildTestContext({ cryptoServices: [bbsUnsupported([])] }) + ); + + expect(outcome).toEqual({ + status: 'success', + message: 'Every proof was checked.' + }); + }); + + it('succeeds for a single proof', async () => { + const outcome = await unsupportedProofsCheck.execute( + withProof(supported), + buildTestContext({ cryptoServices: [bbsUnsupported([])] }) + ); + + expect(outcome).toEqual({ + status: 'success', + message: 'Every proof was checked.' + }); + }); + + it('is not run when the only proofs are unsupported, since proof.signature fails', async () => { + const results = await runSuites( + [proofSuite], + withProof([unsupported, { ...unsupported, id: 'urn:proof:2' }]), + buildTestContext({ cryptoServices: [bbsUnsupported([])] }) + ); + + expect(results[0].outcome.status).toBe('failure'); + expect(results[1].outcome).toEqual({ + status: 'skipped', + reason: 'Not run: proof.signature failed' + }); + }); + + it('skips an unsigned presentation without asking the crypto service', async () => { + const service = FakeCryptoService({ verified: true }); + const verifySpy = vi.spyOn(service, 'verifyPresentation'); + const presentation = PresentationFactory(); + delete (presentation as { proof?: unknown }).proof; + + const outcome = await unsupportedProofsCheck.execute( + createPresentationSubject(presentation), + buildTestContext({ + cryptoServices: [service], + unsignedPresentation: true + }) + ); + + expect(outcome).toEqual({ + status: 'skipped', + reason: 'The document has no proof.' + }); + expect(verifySpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/util/jsigs-no-match.spec.ts b/test/util/jsigs-no-match.spec.ts new file mode 100644 index 0000000..a12f382 --- /dev/null +++ b/test/util/jsigs-no-match.spec.ts @@ -0,0 +1,105 @@ +/** + * Pins jsonld-signatures 12.0.1's "no proof matched" failure, copied + * verbatim from `lib/ProofSet.js` and wrapped the way `lib/jsonld-signatures.js` + * wraps it. An upgrade that rewords or rewraps it should fail here rather + * than silently report an unchecked proof as a bad signature. + */ + +import { describe, it, expect } from 'vitest'; +import jsonLdSignatures from '@digitalcredentials/jsonld-signatures'; +import { isJsigsNoMatch } from '../../src/util/jsigs-no-match.js'; + +const NO_MATCH_MESSAGE = + 'Did not verify any proofs; insufficient proofs matched the ' + + 'acceptable suite(s) and required purpose(s).'; + +function named(name: string, message: string): Error { + const error = new Error(message); + error.name = name; + return error; +} + +/** The wrapper `jsigs.verify` puts around every failure. */ +function wrapped(...errors: unknown[]): Error { + return Object.assign(named('VerificationError', 'Verification error(s).'), { + errors + }); +} + +describe('isJsigsNoMatch', () => { + it("accepts jsonld-signatures' error, as verify returns it", () => { + expect( + isJsigsNoMatch(wrapped(named('NotFoundError', NO_MATCH_MESSAGE))) + ).toBe(true); + }); + + it('accepts what jsonld-signatures actually returns when no suite matches', async () => { + const result = (await jsonLdSignatures.verify( + { + '@context': 'https://www.w3.org/ns/credentials/v2', + proof: { type: 'X' } + }, + { + suite: [{ type: 'Y', matchProof: async () => false }], + purpose: new jsonLdSignatures.purposes.AssertionProofPurpose(), + documentLoader: async (url: string) => ({ + documentUrl: url, + document: {} + }) + } + )) as { verified: boolean; error?: unknown }; + + expect(result.verified).toBe(false); + expect(isJsigsNoMatch(result.error)).toBe(true); + }); + + it('rejects the bare error, which never reaches the crypto service', () => { + expect(isJsigsNoMatch(named('NotFoundError', NO_MATCH_MESSAGE))).toBe( + false + ); + }); + + it('rejects the same message under another name', () => { + expect(isJsigsNoMatch(wrapped(new Error(NO_MATCH_MESSAGE)))).toBe(false); + }); + + it('rejects a NotFoundError with another message', () => { + expect( + isJsigsNoMatch( + wrapped( + named( + 'NotFoundError', + 'No matching proofs found in the given document.' + ) + ) + ) + ).toBe(false); + }); + + it("rejects the document loader's NotFoundError message", () => { + expect( + isJsigsNoMatch( + wrapped( + new Error('NotFoundError loading "https://example.test/ctx": 404') + ) + ) + ).toBe(false); + }); + + it('rejects a no-match error wrapped with others', () => { + expect( + isJsigsNoMatch( + wrapped(named('NotFoundError', NO_MATCH_MESSAGE), new Error('other')) + ) + ).toBe(false); + }); + + it('rejects a plain object shaped like the wrapper', () => { + expect( + isJsigsNoMatch({ + name: 'VerificationError', + errors: [named('NotFoundError', NO_MATCH_MESSAGE)] + }) + ).toBe(false); + }); +});