@learncard/learn-card-plugin 1.2.24 → 1.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/dist/learn-card-plugin.cjs.development.cjs +36 -36
- package/dist/learn-card-plugin.cjs.development.cjs.map +1 -1
- package/dist/learn-card-plugin.cjs.production.min.cjs.map +1 -1
- package/dist/learn-card-plugin.esm.js +36 -36
- package/dist/learn-card-plugin.esm.js.map +1 -1
- package/package.json +59 -57
- package/src/index.ts +20 -0
- package/src/test/verificationPrettifier.test.ts +412 -0
- package/src/test/verify.test.ts +118 -0
- package/src/types.ts +27 -0
- package/src/verificationPrettifier.ts +160 -0
- package/src/verify.ts +194 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { VerificationItem, VerificationStatusEnum } from '@learncard/types';
|
|
2
|
+
|
|
3
|
+
type Label = { check: string; message: string };
|
|
4
|
+
|
|
5
|
+
const SUCCESS_LABELS: Record<string, Label> = {
|
|
6
|
+
parse: { check: 'Format', message: 'Valid' },
|
|
7
|
+
disclosure_hash_integrity: { check: 'Selective Disclosure', message: 'Claims verified' },
|
|
8
|
+
issuer_resolved: { check: 'Issuer', message: 'Identified' },
|
|
9
|
+
issuer_signature: { check: 'Signature', message: 'Valid' },
|
|
10
|
+
expiration: { check: 'Expiration', message: 'Does Not Expire' },
|
|
11
|
+
vct: { check: 'Credential Type', message: 'Verified' },
|
|
12
|
+
proof: { check: 'Proof', message: 'Valid' },
|
|
13
|
+
credentialStatus: { check: 'Status', message: 'Active' },
|
|
14
|
+
// The W3C verifier (learn-card/verify.ts) normalizes both `status` and
|
|
15
|
+
// `credentialStatus` success rows to check: 'status' (lowercase), so the
|
|
16
|
+
// bare `status` alias is required or those rows render unprettified.
|
|
17
|
+
status: { check: 'Status', message: 'Active' },
|
|
18
|
+
credentialSchema: { check: 'Schema', message: 'Valid' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ERROR_LABELS: Record<string, Label> = {
|
|
22
|
+
invalid_compact_form: { check: 'Format', message: 'Invalid' },
|
|
23
|
+
invalid_jwt: { check: 'Format', message: 'Invalid' },
|
|
24
|
+
invalid_typ: { check: 'Format', message: 'Invalid type' },
|
|
25
|
+
missing_iss: { check: 'Issuer', message: 'Missing' },
|
|
26
|
+
missing_vct: { check: 'Credential Type', message: 'Missing' },
|
|
27
|
+
missing_alg: { check: 'Signature', message: 'Algorithm missing' },
|
|
28
|
+
unsupported_alg: { check: 'Signature', message: 'Unsupported algorithm' },
|
|
29
|
+
unsupported_sd_alg: { check: 'Selective Disclosure', message: 'Unsupported algorithm' },
|
|
30
|
+
invalid_disclosure: { check: 'Selective Disclosure', message: 'Invalid' },
|
|
31
|
+
disclosure_hash_mismatch: { check: 'Selective Disclosure', message: 'Tampered' },
|
|
32
|
+
issuer_resolution_failed: { check: 'Issuer', message: 'Could not be found' },
|
|
33
|
+
verification_method_not_found: { check: 'Issuer', message: 'Key not found' },
|
|
34
|
+
verification_method_not_authorized: { check: 'Issuer', message: 'Key not authorized' },
|
|
35
|
+
signature_invalid: { check: 'Signature', message: 'Invalid' },
|
|
36
|
+
expired: { check: 'Expiration', message: 'Expired' },
|
|
37
|
+
not_yet_valid: { check: 'Expiration', message: 'Not yet valid' },
|
|
38
|
+
vct_mismatch: { check: 'Credential Type', message: 'Mismatch' },
|
|
39
|
+
status_check_failed: { check: 'Status', message: 'Revoked or suspended' },
|
|
40
|
+
kb_jwt_invalid: { check: 'Key Binding', message: 'Invalid' },
|
|
41
|
+
presentation_verification_failed: { check: 'Presentation', message: 'Invalid' },
|
|
42
|
+
key_binding_mismatch: { check: 'Holder Key', message: 'Mismatch' },
|
|
43
|
+
unsupported_cnf_confirmation_type: { check: 'Holder Key', message: 'Unsupported type' },
|
|
44
|
+
internal_error: { check: 'Verification', message: 'Something went wrong' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isAlreadyPrettified = (check: string | undefined): boolean => {
|
|
48
|
+
if (!check) return true;
|
|
49
|
+
return /^[A-Z]/.test(check) || /\s/.test(check);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const extractErrorCode = (message: string | undefined): string | undefined => {
|
|
53
|
+
if (!message) return undefined;
|
|
54
|
+
const match = message.match(/^([a-z][a-z0-9_]*):/);
|
|
55
|
+
return match?.[1];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Derive a snake_case error code from a `check` that may already be a raw
|
|
60
|
+
* diagnostic phrase. The W3C verifier's `transformErrorCheck` returns
|
|
61
|
+
* `error.split(' error')[0]`, which can be e.g. `"signature_invalid: bad
|
|
62
|
+
* signature"` — the leading token before `:`/whitespace is the real code.
|
|
63
|
+
*/
|
|
64
|
+
const codeFromCheck = (check: string | undefined): string | undefined => {
|
|
65
|
+
if (!check) return undefined;
|
|
66
|
+
const token = check.split(/[:\s]/)[0];
|
|
67
|
+
return /^[a-z][a-z0-9_]*$/.test(token) ? token : undefined;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve a known ERROR_LABELS code from a failed item. Layer 1
|
|
72
|
+
* (verify.ts) puts the code in `check` (possibly containing spaces) and
|
|
73
|
+
* the human diagnostic in `details`, NOT `message` — so scan all three
|
|
74
|
+
* and only accept a candidate that maps to a known label.
|
|
75
|
+
*/
|
|
76
|
+
const resolveErrorCode = (item: VerificationItem): string | undefined => {
|
|
77
|
+
const candidates = [
|
|
78
|
+
extractErrorCode(item.message),
|
|
79
|
+
extractErrorCode(item.details),
|
|
80
|
+
codeFromCheck(item.check),
|
|
81
|
+
];
|
|
82
|
+
return candidates.find((code): code is string => code !== undefined && code in ERROR_LABELS);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A `message` is "raw" when it carries no human-meaningful detail
|
|
87
|
+
* beyond the check code itself. Upstream verifiers vary:
|
|
88
|
+
*
|
|
89
|
+
* - SD-JWT-VC plugin emits `{ check: 'expiration', message: 'expiration' }`
|
|
90
|
+
* (or message === undefined) — the message is a placeholder and we
|
|
91
|
+
* should replace it with the prettified label.
|
|
92
|
+
* - The W3C VC verifier (`packages/plugins/learn-card/src/verify.ts`)
|
|
93
|
+
* emits `{ check: 'expiration', message: 'Expires 28 FEB 2023' }` —
|
|
94
|
+
* the message is already humanized with credential-specific detail
|
|
95
|
+
* (the actual expiration date), and we MUST preserve it.
|
|
96
|
+
* - SD-JWT-VC failures arrive as `{ check: 'signature_invalid',
|
|
97
|
+
* message: 'signature_invalid: details' }` — the message is raw
|
|
98
|
+
* diagnostic prefixed with the code; replace with the label.
|
|
99
|
+
*
|
|
100
|
+
* Treating "message looks raw" as the trigger keeps the prettifier
|
|
101
|
+
* idempotent and non-destructive for any verifier that has already
|
|
102
|
+
* done its own humanization.
|
|
103
|
+
*/
|
|
104
|
+
const isMessageRaw = (message: string | undefined, check: string): boolean => {
|
|
105
|
+
if (!message) return true;
|
|
106
|
+
if (message === check) return true;
|
|
107
|
+
if (/^[a-z][a-z0-9_]*$/.test(message)) return true;
|
|
108
|
+
if (message.startsWith(`${check}:`)) return true;
|
|
109
|
+
return false;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const prettifyVerificationItem = (item: VerificationItem): VerificationItem => {
|
|
113
|
+
const isFailed = item.status === VerificationStatusEnum.Failed;
|
|
114
|
+
|
|
115
|
+
if (isFailed) {
|
|
116
|
+
// Failures are resolved before the `isAlreadyPrettified` guard
|
|
117
|
+
// because Layer 1 emits a `check` that often contains whitespace
|
|
118
|
+
// (e.g. "signature_invalid: bad signature"), which the guard would
|
|
119
|
+
// otherwise treat as already-prettified and skip.
|
|
120
|
+
const code = resolveErrorCode(item);
|
|
121
|
+
if (!code) return item;
|
|
122
|
+
const label = ERROR_LABELS[code];
|
|
123
|
+
|
|
124
|
+
// The UI renders failed rows as `message ?? details`. If Layer 1
|
|
125
|
+
// humanized the diagnostic into `details` (e.g. "Expired 28 FEB
|
|
126
|
+
// 2023", "Status: Revoked"), preserve it: keep `message` empty so
|
|
127
|
+
// the UI falls through to `details`. If `message` itself is already
|
|
128
|
+
// humanized, keep it. Only when neither carries human detail do we
|
|
129
|
+
// synthesize the generic label, so a real diagnostic is never shadowed.
|
|
130
|
+
const detailsHumanized = Boolean(item.details) && !isMessageRaw(item.details, item.check);
|
|
131
|
+
const messageHumanized = Boolean(item.message) && !isMessageRaw(item.message, item.check);
|
|
132
|
+
|
|
133
|
+
let message: string | undefined;
|
|
134
|
+
if (messageHumanized) message = item.message;
|
|
135
|
+
else if (detailsHumanized) message = undefined;
|
|
136
|
+
else message = label.message;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
...item,
|
|
140
|
+
check: label.check,
|
|
141
|
+
message,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isAlreadyPrettified(item.check)) return item;
|
|
146
|
+
|
|
147
|
+
const label = SUCCESS_LABELS[item.check];
|
|
148
|
+
if (label) {
|
|
149
|
+
return {
|
|
150
|
+
...item,
|
|
151
|
+
check: label.check,
|
|
152
|
+
message: isMessageRaw(item.message, item.check) ? label.message : item.message,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return item;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const prettifyVerificationItems = (items: VerificationItem[]): VerificationItem[] =>
|
|
160
|
+
items.map(prettifyVerificationItem);
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
StatusCheckEntry,
|
|
3
|
+
VC,
|
|
4
|
+
VerificationCheck,
|
|
5
|
+
VerificationItem,
|
|
6
|
+
VerificationStatusEnum,
|
|
7
|
+
} from '@learncard/types';
|
|
8
|
+
import { format } from 'date-fns';
|
|
9
|
+
|
|
10
|
+
import { LearnCard } from '@learncard/core';
|
|
11
|
+
import { ProofOptions } from '@learncard/didkit-plugin';
|
|
12
|
+
|
|
13
|
+
const STATUS_CHECKS = new Set(['status', 'credentialStatus']);
|
|
14
|
+
|
|
15
|
+
const isStatusCheck = (check: string): boolean => STATUS_CHECKS.has(check);
|
|
16
|
+
|
|
17
|
+
const getStatusPurposeLabel = (statusPurpose: string): string => {
|
|
18
|
+
switch (statusPurpose) {
|
|
19
|
+
case 'revocation':
|
|
20
|
+
return 'Revocation';
|
|
21
|
+
case 'suspension':
|
|
22
|
+
return 'Suspension';
|
|
23
|
+
default:
|
|
24
|
+
return statusPurpose
|
|
25
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
26
|
+
.replace(/[-_]+/g, ' ')
|
|
27
|
+
.replace(/\s+/g, ' ')
|
|
28
|
+
.trim()
|
|
29
|
+
.replace(/\b\w/g, letter => letter.toUpperCase());
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const getClearStatusMessage = (statusPurpose: string): string => {
|
|
34
|
+
switch (statusPurpose) {
|
|
35
|
+
case 'revocation':
|
|
36
|
+
return 'Not Revoked';
|
|
37
|
+
case 'suspension':
|
|
38
|
+
return 'Not Suspended';
|
|
39
|
+
default:
|
|
40
|
+
return `${getStatusPurposeLabel(statusPurpose)} Clear`;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const getSetStatusMessage = (statusPurpose: string): string => {
|
|
45
|
+
switch (statusPurpose) {
|
|
46
|
+
case 'revocation':
|
|
47
|
+
return 'Revoked';
|
|
48
|
+
case 'suspension':
|
|
49
|
+
return 'Suspended';
|
|
50
|
+
default:
|
|
51
|
+
return `${getStatusPurposeLabel(statusPurpose)} Set`;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const getUniqueStatusPurposes = (statuses: StatusCheckEntry[]): string[] => [
|
|
56
|
+
...new Set(statuses.map(status => status.statusPurpose)),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const transformStatusCheckMessage = (statusEntries: StatusCheckEntry[] = []): string => {
|
|
60
|
+
const setStatuses = statusEntries.filter(status => status.isSet);
|
|
61
|
+
if (setStatuses.length > 0) {
|
|
62
|
+
return getUniqueStatusPurposes(setStatuses).map(getSetStatusMessage).join(', ');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const clearPurposes = getUniqueStatusPurposes(statusEntries);
|
|
66
|
+
if (clearPurposes.length === 1) return getClearStatusMessage(clearPurposes[0]!);
|
|
67
|
+
|
|
68
|
+
return 'Active';
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const getStatusErrorMessage = (
|
|
72
|
+
error: string,
|
|
73
|
+
verificationCheck?: VerificationCheck
|
|
74
|
+
): string | undefined => {
|
|
75
|
+
if (/credential is revoked/i.test(error)) return 'Status: Revoked';
|
|
76
|
+
if (/credential is suspended/i.test(error)) return 'Status: Suspended';
|
|
77
|
+
|
|
78
|
+
const statusPurpose = error.match(/credential status is set for purpose:\s*([^.]+)/i)?.[1];
|
|
79
|
+
if (statusPurpose) return `Status: ${getSetStatusMessage(statusPurpose.trim())}`;
|
|
80
|
+
|
|
81
|
+
if (!/status/i.test(error)) return;
|
|
82
|
+
|
|
83
|
+
const setStatuses = verificationCheck?.status?.filter(status => status.isSet) ?? [];
|
|
84
|
+
if (setStatuses.length > 0) return `Status: ${transformStatusCheckMessage(setStatuses)}`;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const transformErrorCheck = (
|
|
88
|
+
error: string,
|
|
89
|
+
_credential: VC,
|
|
90
|
+
verificationCheck?: VerificationCheck
|
|
91
|
+
): string => {
|
|
92
|
+
if (getStatusErrorMessage(error, verificationCheck)) return 'status';
|
|
93
|
+
|
|
94
|
+
const prefix = error.split(' error')[0];
|
|
95
|
+
|
|
96
|
+
return prefix || error;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const transformErrorMessage = (
|
|
100
|
+
error: string,
|
|
101
|
+
credential: VC,
|
|
102
|
+
verificationCheck?: VerificationCheck
|
|
103
|
+
): string => {
|
|
104
|
+
const statusErrorMessage = getStatusErrorMessage(error, verificationCheck);
|
|
105
|
+
if (statusErrorMessage) return statusErrorMessage;
|
|
106
|
+
|
|
107
|
+
if (error.startsWith('expiration')) {
|
|
108
|
+
return credential.expirationDate
|
|
109
|
+
? `Expired ${format(new Date(credential.expirationDate), 'dd MMM yyyy').toUpperCase()}`
|
|
110
|
+
: 'Expired';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return error;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const transformWarningCheck = (warning: string, _credential: VC): string => {
|
|
117
|
+
if (warning.includes('Boost Authenticity')) return 'Boost Authenticity';
|
|
118
|
+
|
|
119
|
+
const prefix = warning.split(' warning')[0];
|
|
120
|
+
|
|
121
|
+
return prefix || warning;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const transformCheckMessage = (
|
|
125
|
+
check: string,
|
|
126
|
+
credential: VC,
|
|
127
|
+
verificationCheck: VerificationCheck
|
|
128
|
+
): string => {
|
|
129
|
+
if (isStatusCheck(check)) return transformStatusCheckMessage(verificationCheck.status);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
{
|
|
133
|
+
proof: 'Valid',
|
|
134
|
+
expiration: credential.expirationDate
|
|
135
|
+
? `Expires ${format(
|
|
136
|
+
new Date(credential.expirationDate),
|
|
137
|
+
'dd MMM yyyy'
|
|
138
|
+
).toUpperCase()}`
|
|
139
|
+
: 'Does Not Expire',
|
|
140
|
+
}[check] || check
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const verifyCredential = (
|
|
145
|
+
learnCard: LearnCard<
|
|
146
|
+
any,
|
|
147
|
+
any,
|
|
148
|
+
{
|
|
149
|
+
verifyCredential: (
|
|
150
|
+
credential: VC,
|
|
151
|
+
options?: Partial<ProofOptions>
|
|
152
|
+
) => Promise<VerificationCheck>;
|
|
153
|
+
}
|
|
154
|
+
>
|
|
155
|
+
): ((
|
|
156
|
+
_learnCard: LearnCard<any, any, any>,
|
|
157
|
+
credential: VC,
|
|
158
|
+
options?: Partial<ProofOptions>,
|
|
159
|
+
prettify?: boolean
|
|
160
|
+
) => Promise<VerificationItem[] | VerificationCheck>) => {
|
|
161
|
+
return async (_learnCard, credential, options, prettify = false) => {
|
|
162
|
+
const rawVerificationCheck = await learnCard.invoke.verifyCredential(credential, options);
|
|
163
|
+
|
|
164
|
+
if (!prettify) return rawVerificationCheck;
|
|
165
|
+
|
|
166
|
+
const verificationItems: VerificationItem[] = [];
|
|
167
|
+
|
|
168
|
+
rawVerificationCheck.errors.forEach(error => {
|
|
169
|
+
verificationItems.push({
|
|
170
|
+
status: VerificationStatusEnum.Failed,
|
|
171
|
+
check: transformErrorCheck(error, credential, rawVerificationCheck),
|
|
172
|
+
details: transformErrorMessage(error, credential, rawVerificationCheck),
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
rawVerificationCheck.warnings.forEach(warning => {
|
|
177
|
+
verificationItems.push({
|
|
178
|
+
status: VerificationStatusEnum.Error,
|
|
179
|
+
check: transformWarningCheck(warning, credential),
|
|
180
|
+
message: warning,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
rawVerificationCheck.checks.forEach(check => {
|
|
185
|
+
verificationItems.push({
|
|
186
|
+
status: VerificationStatusEnum.Success,
|
|
187
|
+
check: isStatusCheck(check) ? 'status' : check,
|
|
188
|
+
message: transformCheckMessage(check, credential, rawVerificationCheck),
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return verificationItems;
|
|
193
|
+
};
|
|
194
|
+
};
|