@learncard/helpers 1.7.3 → 1.8.0
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/dist/age-policy.d.ts +9 -0
- package/dist/age-policy.d.ts.map +1 -0
- package/dist/consent-flow-contract-updates.d.ts +29 -0
- package/dist/consent-flow-contract-updates.d.ts.map +1 -0
- package/dist/credential-refresh.d.ts +16 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers.cjs.development.cjs +4953 -3773
- package/dist/helpers.cjs.development.cjs.map +4 -4
- package/dist/helpers.cjs.production.min.cjs +36 -36
- package/dist/helpers.cjs.production.min.cjs.map +4 -4
- package/dist/helpers.esm.js +1171 -59
- package/dist/helpers.esm.js.map +4 -4
- package/dist/index.d.cts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/age-policy.ts +88 -0
- package/src/consent-flow-contract-updates.ts +472 -0
- package/src/credential-refresh.ts +99 -2
- package/src/index.ts +18 -0
|
@@ -3,7 +3,15 @@ import {
|
|
|
3
3
|
SupportedCredentialRefreshServiceValidator,
|
|
4
4
|
type ManagedCredentialRefreshService,
|
|
5
5
|
type SupportedCredentialRefreshService,
|
|
6
|
+
type CredentialRefreshReceipt,
|
|
7
|
+
type JWE,
|
|
6
8
|
} from '@learncard/types';
|
|
9
|
+
import { getBitstringStatusListEntries } from './bitstring-status-list';
|
|
10
|
+
|
|
11
|
+
// VC v1 credentials need the Bitstring Status List context URI for verifiers to accept a
|
|
12
|
+
// status entry. DIDKit (and most JSON-LD loaders) cache this context, so no remote fetch.
|
|
13
|
+
const BITSTRING_STATUS_LIST_CONTEXT = 'https://www.w3.org/ns/credentials/status/v1';
|
|
14
|
+
const VC_V2_CONTEXT = 'https://www.w3.org/ns/credentials/v2';
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
17
|
* Canonical helpers for credential refresh (LC-2117, LC-2135, LC-2136).
|
|
@@ -107,6 +115,78 @@ export const canonicalizeCredentialContent = <T>(value: T): T => {
|
|
|
107
115
|
export const canonicalizeCredentialJson = (value: unknown): string =>
|
|
108
116
|
JSON.stringify(canonicalizeCredentialContent(value));
|
|
109
117
|
|
|
118
|
+
/** Structural recipient check only; decryption and credential verification remain mandatory. */
|
|
119
|
+
export const assertExactJweRecipients = (jwe: JWE, recipientKeyIds: string[]): void => {
|
|
120
|
+
const expected = new Set(recipientKeyIds);
|
|
121
|
+
const recipients = jwe.recipients ?? [];
|
|
122
|
+
const actual = recipients.map(recipient => recipient.header.kid);
|
|
123
|
+
if (
|
|
124
|
+
expected.size === 0 ||
|
|
125
|
+
expected.size !== recipientKeyIds.length ||
|
|
126
|
+
actual.length !== expected.size ||
|
|
127
|
+
new Set(actual).size !== actual.length ||
|
|
128
|
+
actual.some(kid => !kid || !expected.has(kid))
|
|
129
|
+
) {
|
|
130
|
+
throw new Error('ENCRYPTION_RECIPIENTS_MISMATCH');
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Compares exact public bindings while ignoring JSON object-key order, never array order. */
|
|
135
|
+
export const credentialRefreshBindingsEqual = (
|
|
136
|
+
credential: { credentialStatus?: unknown; refreshService?: unknown },
|
|
137
|
+
baseline: { credentialStatus?: unknown; refreshService?: unknown }
|
|
138
|
+
): boolean =>
|
|
139
|
+
canonicalizeCredentialJson(credential.credentialStatus) ===
|
|
140
|
+
canonicalizeCredentialJson(baseline.credentialStatus) &&
|
|
141
|
+
canonicalizeCredentialJson(credential.refreshService) ===
|
|
142
|
+
canonicalizeCredentialJson(baseline.refreshService);
|
|
143
|
+
|
|
144
|
+
/** Checks signed identity and public bindings. Legacy managed-entry receipts bind only the
|
|
145
|
+
* unique managed service; exact receipts also bind additional services and array shape.
|
|
146
|
+
* This does not replace proof/status verification.
|
|
147
|
+
*/
|
|
148
|
+
export const assertManagedCredentialBinding = (
|
|
149
|
+
credential: RefreshableCredential,
|
|
150
|
+
receipt: CredentialRefreshReceipt
|
|
151
|
+
): void => {
|
|
152
|
+
const subjects = Array.isArray(credential.credentialSubject)
|
|
153
|
+
? credential.credentialSubject
|
|
154
|
+
: [credential.credentialSubject];
|
|
155
|
+
const holderMatches =
|
|
156
|
+
subjects.length > 0 &&
|
|
157
|
+
subjects.every(
|
|
158
|
+
(subject: unknown) =>
|
|
159
|
+
typeof subject === 'object' &&
|
|
160
|
+
subject !== null &&
|
|
161
|
+
'id' in subject &&
|
|
162
|
+
subject.id === receipt.holderDid
|
|
163
|
+
);
|
|
164
|
+
const managedServices = getManagedRefreshServices(credential);
|
|
165
|
+
const services = Array.isArray(credential.refreshService)
|
|
166
|
+
? credential.refreshService
|
|
167
|
+
: [credential.refreshService];
|
|
168
|
+
const managedEntryCount = services.filter(
|
|
169
|
+
service => service?.type === 'LearnCardCredentialRefresh2026'
|
|
170
|
+
).length;
|
|
171
|
+
const bindingsMatch =
|
|
172
|
+
receipt.refreshServiceBinding === 'managed-entry'
|
|
173
|
+
? managedServices.length === 1 &&
|
|
174
|
+
managedEntryCount === 1 &&
|
|
175
|
+
credentialRefreshBindingsEqual(
|
|
176
|
+
{ ...credential, refreshService: managedServices[0] },
|
|
177
|
+
receipt
|
|
178
|
+
)
|
|
179
|
+
: credentialRefreshBindingsEqual(credential, receipt);
|
|
180
|
+
if (
|
|
181
|
+
credential.id !== receipt.credentialId ||
|
|
182
|
+
getCredentialIssuerId(credential) !== receipt.issuerDid ||
|
|
183
|
+
!holderMatches ||
|
|
184
|
+
!bindingsMatch
|
|
185
|
+
) {
|
|
186
|
+
throw new Error('MANAGED_CREDENTIAL_BINDING_MISMATCH');
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
110
190
|
// --- Managed refresh JSON-LD context preparation (LC-2198) --------------------
|
|
111
191
|
|
|
112
192
|
/** Term used by the LearnCard-managed refresh service type. */
|
|
@@ -240,6 +320,22 @@ export const prepareManagedRefreshContext = <T>(credential: T): T => {
|
|
|
240
320
|
: [existingContext];
|
|
241
321
|
const inlineObjects = contextList.filter(isPlainObject);
|
|
242
322
|
|
|
323
|
+
let addedStatusContext = false;
|
|
324
|
+
if (
|
|
325
|
+
getBitstringStatusListEntries(credential).length > 0 &&
|
|
326
|
+
!contextList.some(
|
|
327
|
+
context => context === VC_V2_CONTEXT || context === BITSTRING_STATUS_LIST_CONTEXT
|
|
328
|
+
)
|
|
329
|
+
) {
|
|
330
|
+
const firstInline = contextList.findIndex(isPlainObject);
|
|
331
|
+
contextList.splice(
|
|
332
|
+
firstInline === -1 ? contextList.length : firstInline,
|
|
333
|
+
0,
|
|
334
|
+
BITSTRING_STATUS_LIST_CONTEXT
|
|
335
|
+
);
|
|
336
|
+
addedStatusContext = true;
|
|
337
|
+
}
|
|
338
|
+
|
|
243
339
|
const fragment: Record<string, unknown> = {};
|
|
244
340
|
|
|
245
341
|
// Managed refresh service type term
|
|
@@ -305,11 +401,12 @@ export const prepareManagedRefreshContext = <T>(credential: T): T => {
|
|
|
305
401
|
}
|
|
306
402
|
}
|
|
307
403
|
|
|
308
|
-
|
|
404
|
+
const hasFragment = Object.keys(fragment).length > 0;
|
|
405
|
+
if (!hasFragment && !addedStatusContext) return credential;
|
|
309
406
|
|
|
310
407
|
return {
|
|
311
408
|
...credential,
|
|
312
|
-
'@context': [...contextList, fragment],
|
|
409
|
+
'@context': hasFragment ? [...contextList, fragment] : contextList,
|
|
313
410
|
} as T;
|
|
314
411
|
};
|
|
315
412
|
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,22 @@ export const isVC2Format = (credential: unknown): boolean => {
|
|
|
61
61
|
: context === 'https://www.w3.org/ns/credentials/v2';
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The marker field on the placeholder credential JSON of a "display-only anchor"
|
|
66
|
+
* Boost (one created by a managed/signed-only send that had no explicit credential).
|
|
67
|
+
* `'display' in boost` is NOT a safe way to detect this: ordinary Boosts commonly have
|
|
68
|
+
* their own top-level `display` object too (e.g. the LearnCard app's appearance fields —
|
|
69
|
+
* backgroundImage, backgroundColor, displayType), so that check produces false
|
|
70
|
+
* positives on real credentials.
|
|
71
|
+
*/
|
|
72
|
+
export const DISPLAY_ONLY_ANCHOR_MARKER = 'displayOnlyAnchor' as const;
|
|
73
|
+
|
|
74
|
+
/** Detects a display-only anchor via its dedicated marker (see `DISPLAY_ONLY_ANCHOR_MARKER`). */
|
|
75
|
+
export const isDisplayOnlyAnchorTemplate = (credential: unknown): boolean =>
|
|
76
|
+
!!credential &&
|
|
77
|
+
typeof credential === 'object' &&
|
|
78
|
+
(credential as Record<string, unknown>)[DISPLAY_ONLY_ANCHOR_MARKER] === true;
|
|
79
|
+
|
|
64
80
|
/** Unwraps a boost credential from a CertifiedBoostCredential, if it is one */
|
|
65
81
|
export const unwrapBoostCredential = (vc?: VC | UnsignedVC) => {
|
|
66
82
|
if (vc?.type?.includes('CertifiedBoostCredential') && vc?.boostCredential) {
|
|
@@ -108,5 +124,7 @@ export const isAppDidWeb = (did?: string): boolean => {
|
|
|
108
124
|
};
|
|
109
125
|
|
|
110
126
|
export * from './share-content-canonical';
|
|
127
|
+
export * from './consent-flow-contract-updates';
|
|
111
128
|
|
|
112
129
|
export * from './credential-materialization';
|
|
130
|
+
export * from './age-policy';
|