From da4ee3c6ce496466d3a3048596661f5fd5b24a20 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Tue, 20 Oct 2026 17:01:42 -0500 Subject: [PATCH 1/3] feat(proof): verify a proof set one proof at a time jsonld-signatures reports a proof set verified when any one of its proofs verifies, and silently skips proofs that no registered suite or expected purpose matches. This adds the pieces to check each proof on its own; nothing calls them yet. - New `PROOF_UNSUPPORTED` problem type. - The Data Integrity crypto service reports jsonld-signatures' "no proof matched" failure as `PROOF_UNSUPPORTED` rather than `INVALID_SIGNATURE`, and says whether the proof's cryptosuite is not supported or its purpose is not the one being checked. The error is matched strictly: a `VerificationError` wrapping exactly one `NotFoundError` with the library's full message. A presentation checked with a challenge whose only proof is `assertionMethod` now fails this way, still fatally. - `dispatchProofSetVerification` dispatches each proof of a set as its own shallow copy through the existing `CryptoService` port and combines the verdicts: any failing proof fails the set, naming the proof; a date-only rejection is reported as before; unsupported proofs beside a checked one are left for the caller to report. Documents with zero or one proof are dispatched as before. Co-Authored-By: Claude Opus 5.5 --- src/crypto-dispatch.ts | 150 ++++++++ src/problem-types.ts | 8 + src/services/data-integrity-crypto.ts | 111 ++++++ src/util/jsigs-no-match.ts | 43 +++ src/util/proof-label.ts | 16 + test/challenge-enforcement.spec.ts | 20 +- test/crypto-dispatch.spec.ts | 334 +++++++++++++++++- test/factories/data/did-key-signers.ts | 54 +++ .../data-integrity-unsupported-proof.spec.ts | 161 +++++++++ test/util/jsigs-no-match.spec.ts | 105 ++++++ 10 files changed, 999 insertions(+), 3 deletions(-) create mode 100644 src/util/jsigs-no-match.ts create mode 100644 src/util/proof-label.ts create mode 100644 test/factories/data/did-key-signers.ts create mode 100644 test/services/data-integrity-unsupported-proof.spec.ts create mode 100644 test/util/jsigs-no-match.spec.ts diff --git a/src/crypto-dispatch.ts b/src/crypto-dispatch.ts index 361fc53..01b5dde 100644 --- a/src/crypto-dispatch.ts +++ b/src/crypto-dispatch.ts @@ -19,6 +19,9 @@ 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 { proofLabel } from './util/proof-label.js'; +import { isValidityDateOnly } from './util/validity-date-problems.js'; /** * Outcome of dispatching a proof to the registered {@link CryptoService}s. @@ -85,3 +88,150 @@ 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 }; +} + +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..94126ae 100644 --- a/src/services/data-integrity-crypto.ts +++ b/src/services/data-integrity-crypto.ts @@ -16,6 +16,8 @@ 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 { proofLabel } from '../util/proof-label.js'; import { isVerificationMethodMessage } from '../util/verification-method-messages.js'; import { vcDateFault } from '../util/vc-date-messages.js'; @@ -201,6 +203,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 (${proofLabel(proof)}).`; + } 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 +364,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 +428,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/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/src/util/proof-label.ts b/src/util/proof-label.ts new file mode 100644 index 0000000..4907597 --- /dev/null +++ b/src/util/proof-label.ts @@ -0,0 +1,16 @@ +/** + * 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. + */ +export 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; +} 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/services/data-integrity-unsupported-proof.spec.ts b/test/services/data-integrity-unsupported-proof.spec.ts new file mode 100644 index 0000000..c24b40c --- /dev/null +++ b/test/services/data-integrity-unsupported-proof.spec.ts @@ -0,0 +1,161 @@ +/** + * 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 (DataIntegrityProof, bbs-2023)." + }); + }); + + 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 (DataIntegrityProof, bbs-2023)." + ); + }); + + 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/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); + }); +}); From 8ecda33c1fe538294190e49ee919672dbe546790 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Tue, 20 Oct 2026 17:20:22 -0500 Subject: [PATCH 2/3] fix(proof): fail a proof set when any checkable proof fails A credential, presentation or status list credential carrying a proof set passed `proof.signature` when only one of its proofs verified, because jsonld-signatures reports a set verified when any proof does. Breaking either proof of a two-proof credential still read as "Signature verified successfully." - `proof.signature` verifies each proof on its own through `dispatchProofSetVerification`, via a per-verification memo so a later check can read each proof's verdict without verifying again. A failing proof fails the check, and its problem names the proof: "Proof 2 of 2 (DataIntegrityProof, eddsa-rdfc-2022): ...". - An expired credential with one broken proof now reports the broken proof rather than reading as authentic and merely expired. - `verifyStatusList` does the same for status list credentials, so a list with one broken proof is a bad list signature. - The status-list parity test signed its list templates in place; vc's `issue` adds the proof to the object it is given, so lists spread from them carried a stale second proof. It now signs copies. Offline tests with real signatures cover v1 and v2 credentials, expired credentials, status lists and presentations. Closes #67 Co-Authored-By: Claude Opus 5.5 --- src/suites/proof/proof-set-verification.ts | 64 +++ src/suites/proof/signature-check.ts | 21 +- src/suites/status/status-lists.ts | 9 +- test/proof-sets.spec.ts | 493 ++++++++++++++++++++ test/status-list-cryptosuite-parity.spec.ts | 6 +- 5 files changed, 574 insertions(+), 19 deletions(-) create mode 100644 src/suites/proof/proof-set-verification.ts create mode 100644 test/proof-sets.spec.ts 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/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/test/proof-sets.spec.ts b/test/proof-sets.spec.ts new file mode 100644 index 0000000..42ff0d9 --- /dev/null +++ b/test/proof-sets.spec.ts @@ -0,0 +1,493 @@ +/** + * 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. + * + * 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.' + }); + }); + + it('asks the crypto service once per proof', 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/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; From 322beeb517ebb9a3672ef29f1cc7a9d010841491 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Tue, 20 Oct 2026 17:27:16 -0500 Subject: [PATCH 3/3] feat(proof): warn about proofs in a proof set that cannot be checked A proof set often carries proofs for different audiences, and not every verifier supports every cryptosuite. A proof that no registered suite supports, or that was made for a different proof purpose, now no longer vanishes silently beside a proof that verified. - New non-fatal `proof.unsupported-proofs` check, between `proof.signature` and `proof.validity-period`. It reads the proof verification `proof.signature` already ran and fails with one `PROOF_UNSUPPORTED` problem per unchecked proof, so the credential stays verified and the proof suite shows a warning. It succeeds ("Every proof was checked.") otherwise, is skipped for an unsigned presentation, and is not run when the signature check failed. Every credential and presentation result has this one more row. - An unsupported cryptosuite is worded without repeating the proof's label, since the proof-set prefix already names it. - README, verification-results and architecture docs, the `CryptoResult` TSDoc and the CHANGELOG describe proof sets, the new check and `PROOF_UNSUPPORTED`. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 25 +++ README.md | 67 +++++--- docs/api/verification-results.md | 36 ++++- docs/architecture.md | 6 +- src/crypto-dispatch.ts | 22 ++- src/services/data-integrity-crypto.ts | 3 +- src/suites/proof/index.ts | 10 +- src/suites/proof/unsupported-proofs-check.ts | 53 ++++++ src/types/crypto-service.ts | 8 + src/util/proof-label.ts | 16 -- test/proof-sets.spec.ts | 86 +++++++++- .../data-integrity-unsupported-proof.spec.ts | 5 +- test/suites/proof.spec.ts | 153 +++++++++++++++++- 13 files changed, 424 insertions(+), 66 deletions(-) create mode 100644 src/suites/proof/unsupported-proofs-check.ts delete mode 100644 src/util/proof-label.ts 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 01b5dde..28e7746 100644 --- a/src/crypto-dispatch.ts +++ b/src/crypto-dispatch.ts @@ -20,7 +20,6 @@ import type { import type { ProblemDetail } from './types/problem-detail.js'; import type { VerificationSubject } from './types/subject.js'; import { ProblemTypes } from './problem-types.js'; -import { proofLabel } from './util/proof-label.js'; import { isValidityDateOnly } from './util/validity-date-problems.js'; /** @@ -156,8 +155,8 @@ export function problemsOfProof(outcome: ProofOutcome): ProblemDetail[] { * 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. + * 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 @@ -194,6 +193,23 @@ export async function dispatchProofSetVerification( 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) { diff --git a/src/services/data-integrity-crypto.ts b/src/services/data-integrity-crypto.ts index 94126ae..fdee735 100644 --- a/src/services/data-integrity-crypto.ts +++ b/src/services/data-integrity-crypto.ts @@ -17,7 +17,6 @@ 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 { proofLabel } from '../util/proof-label.js'; import { isVerificationMethodMessage } from '../util/verification-method-messages.js'; import { vcDateFault } from '../util/vc-date-messages.js'; @@ -272,7 +271,7 @@ async function unsupportedProofProblem( const proof = singleProof(document); if (document && proof) { if (!(await anySuiteMatches(proof, document, suites, documentLoader))) { - detail = `This verifier does not support this proof's cryptosuite (${proofLabel(proof)}).`; + 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}).`; } 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/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/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/proof-label.ts b/src/util/proof-label.ts deleted file mode 100644 index 4907597..0000000 --- a/src/util/proof-label.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * 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. - */ -export 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; -} diff --git a/test/proof-sets.spec.ts b/test/proof-sets.spec.ts index 42ff0d9..731ec7c 100644 --- a/test/proof-sets.spec.ts +++ b/test/proof-sets.spec.ts @@ -3,7 +3,8 @@ * 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. + * 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 @@ -470,7 +471,88 @@ describe('proof sets: every proof must verify', () => { }); }); - it('asks the crypto service once per proof', async () => { + 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 = { diff --git a/test/services/data-integrity-unsupported-proof.spec.ts b/test/services/data-integrity-unsupported-proof.spec.ts index c24b40c..7b2231f 100644 --- a/test/services/data-integrity-unsupported-proof.spec.ts +++ b/test/services/data-integrity-unsupported-proof.spec.ts @@ -86,8 +86,7 @@ describe('DataIntegrityCryptoService: proofs it cannot check', () => { expect(problem).toEqual({ type: ProblemTypes.PROOF_UNSUPPORTED, title: 'Unsupported Proof', - detail: - "This verifier does not support this proof's cryptosuite (DataIntegrityProof, bbs-2023)." + detail: "This verifier does not support this proof's cryptosuite." }); }); @@ -145,7 +144,7 @@ describe('DataIntegrityCryptoService: proofs it cannot check', () => { ) ); expect(problem?.detail).toBe( - "This verifier does not support this proof's cryptosuite (DataIntegrityProof, bbs-2023)." + "This verifier does not support this proof's cryptosuite." ); }); 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(); + }); + }); });