diff --git a/docs/planning/requirements.md b/docs/planning/requirements.md index b9d4170..7a8c290 100644 --- a/docs/planning/requirements.md +++ b/docs/planning/requirements.md @@ -438,7 +438,11 @@ The consistency suite holds every verdict to this. The advice has to match the cause, though. For a **withdrawn** credential the issuer already made their decision, so the useful advice is to ask for a replacement. For a **signature that doesn't match**, a fresh copy genuinely -helps. Those shouldn't share wording. +helps. Those shouldn't share wording. Nor should a check that can never +finish — a field the credential's vocabulary doesn't define, or a kind of +seal we can't check — share "try again" with one that might: trying again +gives the same answer every time, so it sends the person to the issuer +instead (#40). ### Two accessibility rules diff --git a/src/outcomes.ts b/src/outcomes.ts index 36d5b4f..b44be76 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -473,6 +473,16 @@ const isTampered = (checks: Map): boolean => const isKeyMismatch = (checks: Map): boolean => !isTampered(checks) && hasProblem(checks.get(CHECK.signature), PROBLEM.verificationMethod); +/** + * No seal on it is one verifier-core can check. Every problem, as the library + * itself decides it: one unsupported seal beside a failure we can't name is + * still a failure we can't name. + */ +const onlyUnsupportedProofs = (check: CheckResult | undefined): boolean => { + const problems = problemsOf(check); + return problems.length > 0 && problems.every((p) => p.type === PROBLEM.proofUnsupported); +}; + // --------------------------------------------------------------------------- // The issuer: a name, plus where the name came from // --------------------------------------------------------------------------- @@ -695,13 +705,37 @@ export const COULD_NOT_CHECK = { action: TRY_AGAIN, } as const; +/** + * A field the credential's vocabulary doesn't define, wherever it's found: + * while computing the seal (#40), or before it, a path 2.0.0-beta.1's + * context check doesn't reach but `unreadable_vocabulary` still covers. Same + * cause, same words. Not "read": the card shows the credential's title, so + * we plainly read it. "Usually" leaves room for a forgery, which adds such a + * field to soften "tampered", without accusing anyone. + */ +const UNRECOGNISED_CONTENT = { + headline: "We can't check this credential", + detail: + "It contains something we don't recognise, so we can't tell whether it's genuine. This is usually a problem with how it was made.", + action: 'Ask the issuer for a replacement.', +} as const; + const FATAL: Record> = { - unreadable_vocabulary: { - severity: 'error', - headline: "We can't read this credential", - detail: - "It uses vocabulary we couldn't process, so none of it could be checked. This is usually a problem with how it was built.", - action: 'Ask the issuer for a replacement.', + unreadable_vocabulary: { severity: 'error', ...UNRECOGNISED_CONTENT }, + // The same found at the seal, grey: the rows below it all read "not + // checked", and a red verdict needs a red row to show what's wrong. The + // words, not the colour, say trying again won't help. + signature_unreadable: { severity: 'unchecked', ...UNRECOGNISED_CONTENT }, + // A seal of a kind verifier-core can't check (#40), or one made for + // another purpose. Grey for the same reason. Not "a replacement": a new + // copy would most likely carry the same kind of seal, and either way the + // issuer is who can fix it. Nor "yet": that promises support nobody has + // planned. + signature_unsupported: { + severity: 'unchecked', + headline: "We can't check this credential", + detail: "It has a kind of digital signature we can't check, so we can't tell whether it's genuine.", + action: 'Let the issuer know.', }, invalid_jsonld: { severity: 'error', @@ -1038,12 +1072,24 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) // and verifier-core halts the withdrawal check after it, so "the withdrawal // check never ran" would be true but beside the point. Everything below // this has a seal that held. + // + // Two reasons the seal can't be computed are permanent, and "try again" + // would be advice that never works (#40): a field the vocabulary doesn't + // define, and a kind of seal verifier-core can't check. While both fell + // through to the catch-all, a forger could turn red "tampered" into grey + // "try again" by adding one made-up field; it still turns grey, but no + // longer blames a glitch. A reason that may pass still comes first: while + // the issuer's site is down, trying again is the right advice. if (!sealHeld(checks)) { const code = hasProblem(signature, PROBLEM.didWebUnresolved) ? 'did_web_unresolved' : hasProblem(signature, PROBLEM.httpError) ? 'http_error_with_signature_check' - : undefined; + : hasProblem(signature, PROBLEM.parsingError) + ? 'signature_unreadable' + : onlyUnsupportedProofs(signature) + ? 'signature_unsupported' + : undefined; return code ? { code, ...FATAL[code]! } : { code: 'signature_unchecked', ...UNCHECKED_SIGNATURE }; } diff --git a/src/types.ts b/src/types.ts index e4cbd08..f6f5cb2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -161,6 +161,17 @@ export const PROBLEM = { didWebUnresolved: 'https://www.w3.org/TR/vc-data-model#DID_WEB_UNRESOLVED', /** Something the signature check needed would not load. */ httpError: 'https://www.w3.org/TR/vc-data-model#HTTP_ERROR', + /** + * The content could not be put into the form the seal is computed over — + * on the signature check, usually a field its `@context` doesn't define + * ("Safe mode validation error"). The same answer every time. + */ + parsingError: 'https://www.w3.org/TR/vc-data-model#PARSING_ERROR', + /** + * No seal on it is one this verifier can check: an unknown cryptosuite, or + * one made for another purpose. Also the same answer every time. + */ + proofUnsupported: 'https://www.w3.org/TR/vc-data-model#PROOF_UNSUPPORTED', issuerNotRegistered: 'https://www.w3.org/TR/vc-data-model#ISSUER_NOT_REGISTERED', registryUnchecked: 'https://www.w3.org/TR/vc-data-model#REGISTRY_UNCHECKED', schemaValidationFailed: 'https://www.w3.org/TR/vc-data-model#SCHEMA_VALIDATION_FAILED', diff --git a/test/consistency-combinations.ts b/test/consistency-combinations.ts index e187504..ac69484 100644 --- a/test/consistency-combinations.ts +++ b/test/consistency-combinations.ts @@ -12,11 +12,12 @@ import type { VerificationResponse, CheckResult } from '../src/types.js'; * tampering detected") is still a reassurance. The old "changed since it was * issued" phrasings stay, because a claim written the old way is still a * claim. "Genuine" counts unless it is asked about — "we can't tell if this is - * genuine" is the opposite of a claim, and dropping the word instead would - * stop this seeing "Genuine, but we can't confirm who issued it". + * genuine", or "whether it's genuine", is the opposite of a claim, and + * dropping the word instead would stop this seeing "Genuine, but we can't + * confirm who issued it". */ export const CLAIMS_UNCHANGED = - /(? { }, true, ); + case 'undefined_field': + return check( + CHECK.signature, + { + status: 'failure', + problems: [ + { type: PROBLEM.parsingError, title: 'JSON-LD Validation Error', detail: 'Safe mode validation error.' }, + ], + }, + true, + ); + case 'unsupported': + return check( + CHECK.signature, + { + status: 'failure', + problems: [ + { + type: PROBLEM.proofUnsupported, + title: 'Unsupported Proof', + detail: "This verifier does not support this proof's cryptosuite.", + }, + ], + }, + true, + ); } }; @@ -521,7 +551,7 @@ const listsFor = (rev: Rev): Lists[] => * contradiction in the input, not a combination the rules owe an answer to, * so it is left out — everything else, possible or not, stays in. */ -const SIG_FATAL: Sig[] = ['tampered', 'key_mismatch', 'unattributable']; +const SIG_FATAL: Sig[] = ['tampered', 'key_mismatch', 'unattributable', 'undefined_field', 'unsupported']; const coherent = (sig: Sig, val: Val, rev: Rev, iss: Iss, sch: Sch): boolean => SIG_FATAL.includes(sig) || ![val, rev, iss, sch].includes('halted'); @@ -837,6 +867,8 @@ export const agreementTests = (file: string): void => { signature_unchecked: [CHECK.signature, 'unchecked'], did_web_unresolved: [CHECK.signature, 'unchecked'], http_error_with_signature_check: [CHECK.signature, 'unchecked'], + signature_unreadable: [CHECK.signature, 'unchecked'], + signature_unsupported: [CHECK.signature, 'unchecked'], withdrawn: [CHECK.status, 'error'], suspended: [CHECK.status, 'error'], withdrawal_unknown: [CHECK.status, 'unchecked'], @@ -846,6 +878,20 @@ export const agreementTests = (file: string): void => { key_mismatch: [CHECK.registeredIssuer, 'error'], malformed: [CHECK.schema, 'warning'], }; + // A seal that can never be computed says so (#40): trying again would + // give the same answer every time. Unless the schema check reported a + // finding, which leads over every unknown about the seal, as it does + // for `signature_unchecked`; the library halts that check after a + // failed seal, so only these combinations can reach it. + if (sig === 'undefined_field' || sig === 'unsupported') { + if (sch !== 'invalid') { + expect(out.code, `${where}: a seal that can never be computed`).toBe( + sig === 'undefined_field' ? 'signature_unreadable' : 'signature_unsupported', + ); + } + expect(out.action ?? '', `${where}: sends someone back to try again for good`).not.toMatch(/try again/i); + } + const expected = expectations[out.code]; if (expected) { const [id, severity] = expected; diff --git a/test/consistency.test.ts b/test/consistency.test.ts index 92bd2bd..265e77b 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -22,6 +22,8 @@ describe('the claim patterns themselves', () => { 'The credential is untampered.', "Genuine, but we can't confirm who issued it", 'It looks genuine.', + "It's genuine.", + "We checked whether it's signed, and it's genuine.", 'Nothing has changed since it was issued.', "This credential hasn't been changed since it was issued.", "It hasn't been altered.", @@ -52,6 +54,11 @@ describe('the claim patterns themselves', () => { "This copy is no longer valid. That doesn't always mean the achievement was taken back; issuers sometimes deactivate a copy to replace it, for example to correct a detail.", "This copy can't be relied on while it's on hold. This may be temporary.", "We couldn't check whether this copy is still active", + // A seal that can never be computed (#40). + "We can't check this credential", + "It contains something we don't recognise, so we can't tell whether it's genuine. This is usually a problem with how it was made.", + "It has a kind of digital signature we can't check, so we can't tell whether it's genuine.", + 'Let the issuer know.', ])('does not read "%s" as a claim', (text) => { expect(text.toLowerCase()).not.toMatch(CLAIMS_UNCHANGED); expect(text.toLowerCase()).not.toMatch(CLAIMS_NOT_WITHDRAWN); diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index d4f92dc..c52025e 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -2079,3 +2079,80 @@ describe('findings from the review of the verifier-core main adaptation', () => expect(out.action).not.toContain('Try again'); }); }); + +describe('a seal that can never be computed (#40)', () => { + const NOT_RUN = (id: string) => `Not run: ${id} failed`; + const sealFailed = (...problems: [string, string][]): VerificationResponse => { + const r = ok(); + set(r, fail(CHECK.signature, problems.map(([type, detail]) => ({ type, title: 't', detail })), true)); + set(r, skip(CHECK.status, NOT_RUN('proof.signature'))); + return r; + }; + const UNDEFINED_FIELD: [string, string] = [PROBLEM.parsingError, 'Safe mode validation error.']; + const UNKNOWN_SEAL: [string, string] = [PROBLEM.proofUnsupported, "This verifier does not support this proof's cryptosuite."]; + + it('says a field the vocabulary does not define is permanent, without saying tampered', () => { + // Adding one such field to a tampered credential used to turn its red + // "tampered" into grey "try again". It is still grey, since every row + // below reads "not checked", but it no longer blames a glitch, and neither + // accuses nor reassures: a carelessly built genuine credential lands here + // too. + expect(summarise(sealFailed(UNDEFINED_FIELD))).toEqual({ + code: 'signature_unreadable', + severity: 'unchecked', + headline: "We can't check this credential", + detail: + "It contains something we don't recognise, so we can't tell whether it's genuine. This is usually a problem with how it was made.", + action: 'Ask the issuer for a replacement.', + }); + }); + + it('says a kind of seal we cannot check is permanent, and sends the earner to the issuer', () => { + // Not "a replacement": a new copy would carry the same kind of seal. + expect(summarise(sealFailed(UNKNOWN_SEAL))).toEqual({ + code: 'signature_unsupported', + severity: 'unchecked', + headline: "We can't check this credential", + detail: "It has a kind of digital signature we can't check, so we can't tell whether it's genuine.", + action: 'Let the issuer know.', + }); + }); + + it.each([ + ['an undefined field', UNDEFINED_FIELD], + ['an unknown kind of seal', UNKNOWN_SEAL], + ])('leaves the breakdown saying the seal was not checked, for %s', (_, problem) => { + const r = sealFailed(problem); + const out = summarise(r); + expect(`${out.headline} ${out.detail} ${out.action}`).not.toMatch(/tamper|try again/i); + expect(row(r, CHECK.signature)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); + expect(row(r, CHECK.registeredIssuer)?.value).toBe("can't confirm — we couldn't check the digital signature"); + }); + + it('words an undefined field the same whichever check finds it', () => { + // Same cause, same words: before the seal, or while computing it. Only + // the colour differs: caught early there is no breakdown, so it stays red. + const { code: _, severity: early, ...earlyWords } = summarise(stoppedAt(CHECK.contextExists, 'Invalid JSON-LD')); + const { code: __, severity: atSeal, ...atSealWords } = summarise(sealFailed(UNDEFINED_FIELD)); + expect(atSealWords).toEqual(earlyWords); + expect([early, atSeal]).toEqual(['error', 'unchecked']); + }); + + it.each([ + ['the issuer website', PROBLEM.didWebUnresolved, 'did_web_unresolved'], + ['something else it needed', PROBLEM.httpError, 'http_error_with_signature_check'], + ])('still says try again when %s did not load as well', (_, type, code) => { + // A reason that may pass outranks one that won't. + expect(summarise(sealFailed(UNDEFINED_FIELD, [type, 'd'])).code).toBe(code); + expect(summarise(sealFailed(UNKNOWN_SEAL, [type, 'd'])).code).toBe(code); + }); + + it('keeps the catch-all when an unknown seal sits beside a failure we cannot name', () => { + expect(summarise(sealFailed(UNKNOWN_SEAL, [PROBLEM.proofVerification, 'd'])).code).toBe('signature_unchecked'); + }); + + it('still says tampered when one seal of two does not match and the other cannot be computed', () => { + // The library reports both for such a proof set; the finding wins. + expect(summarise(sealFailed([PROBLEM.invalidSignature, 'd'], UNDEFINED_FIELD)).code).toBe('invalid_signature'); + }); +}); diff --git a/test/problem-types.test.ts b/test/problem-types.test.ts index 93895e3..dc025e9 100644 --- a/test/problem-types.test.ts +++ b/test/problem-types.test.ts @@ -147,3 +147,23 @@ describe('a credential the library rejects for its shape', () => { expect(summarise(r).code).toBe('invalid_structure'); }); }); + +/** + * A seal that can never be computed (#40). Both outcomes rest on the library + * keeping these two types: if it renamed either, a forgery with one made-up + * field would quietly go back to "try again", and only this would notice. + */ +describe('a seal that can never be computed', () => { + it('reports a field the vocabulary does not define as a parsing error, and we say so', async () => { + const r = await verify({ ...fixture('tampered'), notInAnyContext: 'x' }); + expect(problemTypes(r, CHECK.signature)).toEqual([PROBLEM.parsingError]); + expect(summarise(r).code).toBe('signature_unreadable'); + }); + + it('reports a kind of seal it cannot check as unsupported, and we say so', async () => { + const sealed = fixture('verified'); + const r = await verify({ ...sealed, proof: { ...(sealed['proof'] as Record), type: 'MadeUpSignature2026' } }); + expect(problemTypes(r, CHECK.signature)).toEqual([PROBLEM.proofUnsupported]); + expect(summarise(r).code).toBe('signature_unsupported'); + }); +});