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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/planning/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 53 additions & 7 deletions src/outcomes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,16 @@ const isTampered = (checks: Map<string, CheckResult>): boolean =>
const isKeyMismatch = (checks: Map<string, CheckResult>): 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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<string, Omit<Outcome, 'code'>> = {
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',
Expand Down Expand Up @@ -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 };
}

Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
58 changes: 52 additions & 6 deletions test/consistency-combinations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
/(?<!\b(?:if|whether) (?:this|it) is )\b(?:genuine|authentic)\b|n['’]t been (?:tampered|changed|altered|modified)|\bnot been (?:tampered|changed|altered|modified)|was(?:n['’]t| not) (?:tampered|changed|altered|modified)|\bun(?:tampered|altered|modified|changed)\b|\bintact\b|\bno tampering\b|\bnothing has changed/;
/(?<!\b(?:if|whether) (?:this|it) is |\b(?:if|whether) (?:this|it)['’]s )\b(?:genuine|authentic)\b|n['’]t been (?:tampered|changed|altered|modified)|\bnot been (?:tampered|changed|altered|modified)|was(?:n['’]t| not) (?:tampered|changed|altered|modified)|\bun(?:tampered|altered|modified|changed)\b|\bintact\b|\bno tampering\b|\bnothing has changed/;
// The earner reads "deactivated" now (8 October 2026), so that counts too,
// however the object is put ("it", "this copy"). "Still active" counts
// unless it is asked about: "We couldn't check whether this copy is still
Expand Down Expand Up @@ -56,8 +57,11 @@ export const LEANS_POSITIVE =
/**
* Every way the signature check can end. Since verifier-core #59 a key that
* isn't the issuer's has its own problem type, apart from tampering.
* `undefined_field` and `unsupported` are the two seals that can never be
* computed: a field the vocabulary doesn't define, and a kind of seal the
* library can't check (#40).
*/
type Sig = 'passed' | 'tampered' | 'key_mismatch' | 'unattributable' | 'missing';
type Sig = 'passed' | 'tampered' | 'key_mismatch' | 'unattributable' | 'undefined_field' | 'unsupported' | 'missing';
/**
* The validity period, a check of its own since 2.0.0-beta.1: before, the
* dates were judged inside the signature check. The end date stays a
Expand Down Expand Up @@ -113,7 +117,7 @@ type Sch = 'valid' | 'invalid' | 'no_schema' | 'unavailable' | 'missing' | 'halt
*/
const NOT_RUN = 'Not run: proof.signature failed';

const SIGNATURES: Sig[] = ['passed', 'tampered', 'key_mismatch', 'unattributable', 'missing'];
const SIGNATURES: Sig[] = ['passed', 'tampered', 'key_mismatch', 'unattributable', 'undefined_field', 'unsupported', 'missing'];
const VALIDITIES: Val[] = ['in_date', 'expired', 'not_yet_valid', 'errored', 'halted', 'missing'];
const REVOCATIONS: Rev[] = [
'passed',
Expand Down Expand Up @@ -200,6 +204,32 @@ const signatureCheck = (sig: Sig): CheckResult | undefined => {
},
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,
);
}
};

Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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'],
Expand All @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions test/consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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);
Expand Down
77 changes: 77 additions & 0 deletions test/outcomes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
20 changes: 20 additions & 0 deletions test/problem-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>), type: 'MadeUpSignature2026' } });
expect(problemTypes(r, CHECK.signature)).toEqual([PROBLEM.proofUnsupported]);
expect(summarise(r).code).toBe('signature_unsupported');
});
});
Loading