@learncard/network-plugin 3.2.3 → 4.0.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/README.md +18 -123
- package/dist/lcn-plugin.cjs.development.cjs +986 -79
- package/dist/lcn-plugin.cjs.development.cjs.map +3 -3
- package/dist/lcn-plugin.cjs.production.min.cjs +29 -29
- package/dist/lcn-plugin.cjs.production.min.cjs.map +3 -3
- package/dist/lcn-plugin.esm.js +992 -81
- package/dist/lcn-plugin.esm.js.map +3 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/test/holder-send.test.d.ts +2 -0
- package/dist/test/holder-send.test.d.ts.map +1 -0
- package/dist/types.d.ts +95 -12
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -9
- package/src/plugin.ts +604 -38
- package/src/test/campaigns.test.ts +87 -1
- package/src/test/holder-send.test.ts +735 -0
- package/src/test/index.test.ts +71 -0
- package/src/test/refresh-send.test.ts +149 -4
- package/src/types.ts +122 -10
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- plugin integration mocks intentionally expose dynamic method bags */
|
|
2
|
+
/**
|
|
3
|
+
* LC-2345 A3 — SDK holder-mode (student-only encrypted) managed sends.
|
|
4
|
+
*
|
|
5
|
+
* Covers the `encryption: 'holder'` branch of the network plugin's unified `send`
|
|
6
|
+
* method and the dedicated `publishHolderEncryptedRefreshable` method: client-side
|
|
7
|
+
* rejection of inbox/signedCredential/no-local-signing/no-Encryption-plugin inputs,
|
|
8
|
+
* refusal to fall back to network-mode plaintext against an old server, exact-recipient
|
|
9
|
+
* encryption with a real (unmocked) `assertExactJweRecipients`/`createDagJweExact`
|
|
10
|
+
* contract, and the `RECIPIENTS_CHANGED` retry-once semantics.
|
|
11
|
+
*
|
|
12
|
+
* Follows the mocking conventions of `refresh-send.test.ts`.
|
|
13
|
+
*/
|
|
14
|
+
import { getClient as getBrainClient } from '@learncard/network-brain-client';
|
|
15
|
+
import { vi } from 'vitest';
|
|
16
|
+
import { UnsignedVCValidator } from '@learncard/types';
|
|
17
|
+
|
|
18
|
+
import { getLearnCardNetworkPlugin } from '../';
|
|
19
|
+
|
|
20
|
+
vi.mock('@learncard/network-brain-client', () => ({
|
|
21
|
+
getClient: vi.fn(),
|
|
22
|
+
getApiTokenClient: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('@learncard/core', () => ({ generateLearnCard: vi.fn() }));
|
|
25
|
+
vi.mock('@learncard/didkit-plugin', () => ({ getDidKitPlugin: vi.fn() }));
|
|
26
|
+
vi.mock('@learncard/didkey-plugin', () => ({ getDidKeyPlugin: vi.fn() }));
|
|
27
|
+
vi.mock('@learncard/vc-plugin', () => ({ getVCPlugin: vi.fn() }));
|
|
28
|
+
vi.mock('@learncard/helpers', async importOriginal => ({
|
|
29
|
+
...(await importOriginal<typeof import('@learncard/helpers')>()),
|
|
30
|
+
getCredentialStatusArray: () => [],
|
|
31
|
+
resolveStorageReadResult: (value: any) => value,
|
|
32
|
+
}));
|
|
33
|
+
vi.mock('@learncard/types', async importOriginal => ({
|
|
34
|
+
...(await importOriginal<object>()),
|
|
35
|
+
VCValidator: {
|
|
36
|
+
parse: (value: any) => {
|
|
37
|
+
if (!value?.type) throw new Error('Invalid credential');
|
|
38
|
+
return value;
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
UnsignedVCValidator: {
|
|
42
|
+
parse: (await importOriginal<typeof import('@learncard/types')>()).UnsignedVCValidator
|
|
43
|
+
.parse,
|
|
44
|
+
spa: async (value: any) => ({
|
|
45
|
+
success: true,
|
|
46
|
+
data: typeof value === 'string' ? JSON.parse(value) : value,
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const PROFILE = {
|
|
52
|
+
profileId: 'usera',
|
|
53
|
+
displayName: 'User A',
|
|
54
|
+
shortBio: '',
|
|
55
|
+
bio: '',
|
|
56
|
+
did: 'did:web:network.example:users:usera',
|
|
57
|
+
isPrivate: false,
|
|
58
|
+
profileVisibility: 'public',
|
|
59
|
+
showEmail: false,
|
|
60
|
+
allowConnectionRequests: 'anyone',
|
|
61
|
+
isServiceProfile: false,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const TARGET_PROFILE = { profileId: 'userb', did: 'did:key:zTargetHolder' };
|
|
65
|
+
const REFRESH_ID = 'cmVjaXBpZW50c2NoYW5nZWRyZWZyZXNoaWQxMjM0NTY3OA';
|
|
66
|
+
|
|
67
|
+
const STATUS_ENTRY = {
|
|
68
|
+
id: 'https://network.example/status/3#94567',
|
|
69
|
+
type: 'BitstringStatusListEntry' as const,
|
|
70
|
+
statusPurpose: 'revocation' as const,
|
|
71
|
+
statusListIndex: '94567',
|
|
72
|
+
statusListCredential: 'https://network.example/status/3',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const REFRESH_SERVICE = {
|
|
76
|
+
id: `https://network.example/refresh/${REFRESH_ID}`,
|
|
77
|
+
type: 'LearnCardCredentialRefresh2026' as const,
|
|
78
|
+
authorization: { type: 'LearnCardDIDAuth' as const },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Appends a deterministic key-agreement fragment to a DID, mirroring did:key verification-method ids. */
|
|
82
|
+
const keyIdForDid = (did: string): string => `${did}#${did.split(':').pop()}-key`;
|
|
83
|
+
|
|
84
|
+
const HOLDER_DID = 'did:key:zHolder';
|
|
85
|
+
const MANAGER_DID = 'did:key:zManager';
|
|
86
|
+
const RECIPIENT_KEY_IDS = [keyIdForDid(HOLDER_DID), keyIdForDid(MANAGER_DID)];
|
|
87
|
+
|
|
88
|
+
const defaultPreparedHolder = {
|
|
89
|
+
boostUri: 'lc:network:localhost%3A3000/trpc:boost:prepared',
|
|
90
|
+
credentialId: 'urn:uuid:prepared-credential',
|
|
91
|
+
refreshId: REFRESH_ID,
|
|
92
|
+
refreshService: REFRESH_SERVICE,
|
|
93
|
+
holderDid: HOLDER_DID,
|
|
94
|
+
statusEntries: [STATUS_ENTRY],
|
|
95
|
+
recipientKeyIds: RECIPIENT_KEY_IDS,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Builds a fake JWE whose recipient `kid`s are exactly derived from the given DIDs. */
|
|
99
|
+
const jweForDids = (dids: string[]) => ({
|
|
100
|
+
protected: 'protected',
|
|
101
|
+
iv: 'iv',
|
|
102
|
+
ciphertext: 'encrypted-ciphertext',
|
|
103
|
+
tag: 'tag',
|
|
104
|
+
recipients: dids.map(did => ({
|
|
105
|
+
header: { kid: keyIdForDid(did), alg: 'ECDH-ES+A256KW', iv: 'iv', tag: 'tag' },
|
|
106
|
+
encrypted_key: 'encrypted-key',
|
|
107
|
+
})),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const getUnsignedBoostTemplate = (overrides: Record<string, unknown> = {}) => ({
|
|
111
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
112
|
+
type: ['VerifiableCredential', 'BoostCredential'],
|
|
113
|
+
issuer: 'did:example:template-issuer',
|
|
114
|
+
validFrom: '2020-01-01T00:00:00.000Z',
|
|
115
|
+
credentialSubject: { id: 'did:example:template-subject' },
|
|
116
|
+
...overrides,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const getMockIssuingLearnCard = (overrides: Record<string, any> = {}) =>
|
|
120
|
+
({
|
|
121
|
+
id: { did: () => PROFILE.did },
|
|
122
|
+
invoke: {
|
|
123
|
+
getDidAuthVp: vi.fn(),
|
|
124
|
+
getProfile: vi.fn().mockResolvedValue(TARGET_PROFILE),
|
|
125
|
+
issueCredential: vi.fn(async (credential: any) => ({
|
|
126
|
+
...credential,
|
|
127
|
+
proof: { type: 'DataIntegrityProof' },
|
|
128
|
+
})),
|
|
129
|
+
createDagJwe: vi.fn(),
|
|
130
|
+
createDagJweExact: vi.fn(async (_cleartext: unknown, recipients: string[]) =>
|
|
131
|
+
jweForDids(recipients)
|
|
132
|
+
),
|
|
133
|
+
...overrides,
|
|
134
|
+
},
|
|
135
|
+
debug: vi.fn(),
|
|
136
|
+
}) as any;
|
|
137
|
+
|
|
138
|
+
const getMockClient = (options: Record<string, any> = {}) => ({
|
|
139
|
+
profile: {
|
|
140
|
+
getProfile: { query: vi.fn().mockResolvedValue(PROFILE) },
|
|
141
|
+
},
|
|
142
|
+
boost: {
|
|
143
|
+
prepareRefreshableSend: {
|
|
144
|
+
mutate: vi.fn().mockResolvedValue(defaultPreparedHolder),
|
|
145
|
+
},
|
|
146
|
+
getBoost: { query: vi.fn().mockResolvedValue({ boost: getUnsignedBoostTemplate() }) },
|
|
147
|
+
send: { mutate: vi.fn() },
|
|
148
|
+
allocateCredentialStatus: { mutate: vi.fn().mockResolvedValue([]) },
|
|
149
|
+
},
|
|
150
|
+
utilities: { getDid: { query: vi.fn().mockResolvedValue('did:web:network.example') } },
|
|
151
|
+
credentialRefresh: {
|
|
152
|
+
allocateCredentialRefresh: { mutate: vi.fn() },
|
|
153
|
+
sendRefreshableCredential: { mutate: vi.fn() },
|
|
154
|
+
sendHolderEncryptedRefreshable: {
|
|
155
|
+
mutate: vi.fn().mockResolvedValue({
|
|
156
|
+
uri: 'did:web:network.example:credentials:holder-1',
|
|
157
|
+
activityId: 'activity-holder-1',
|
|
158
|
+
}),
|
|
159
|
+
},
|
|
160
|
+
getEncryptionRecipients: {
|
|
161
|
+
query: vi.fn().mockResolvedValue({ recipientKeyIds: RECIPIENT_KEY_IDS }),
|
|
162
|
+
},
|
|
163
|
+
publishHolderEncryptedRefreshable: {
|
|
164
|
+
mutate: vi.fn().mockResolvedValue({
|
|
165
|
+
refreshId: REFRESH_ID,
|
|
166
|
+
version: 2,
|
|
167
|
+
publishedAt: '2024-01-01T00:00:00.000Z',
|
|
168
|
+
notification: 'queued',
|
|
169
|
+
}),
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
...options,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/** Every argument ever passed to a `client.*.*.mutate`/`.query` mock, flattened for a single substring check. */
|
|
176
|
+
const allClientCallArgsJson = (client: Record<string, any>): string => {
|
|
177
|
+
const calls: unknown[] = [];
|
|
178
|
+
|
|
179
|
+
for (const namespace of Object.values(client)) {
|
|
180
|
+
for (const procedure of Object.values(namespace as Record<string, any>)) {
|
|
181
|
+
for (const key of ['mutate', 'query']) {
|
|
182
|
+
const fn = (procedure as Record<string, any>)[key];
|
|
183
|
+
if (fn?.mock?.calls) calls.push(...fn.mock.calls);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return JSON.stringify(calls);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const getPlugin = async (learnCard: any, client: any) => {
|
|
192
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
193
|
+
|
|
194
|
+
return getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const HOLDER_SEND_INPUT = {
|
|
198
|
+
type: 'boost' as const,
|
|
199
|
+
recipient: 'userb',
|
|
200
|
+
templateUri: 'did:web:network.example:boost:1',
|
|
201
|
+
refresh: true as const,
|
|
202
|
+
encryption: 'holder' as const,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
beforeEach(() => {
|
|
206
|
+
vi.clearAllMocks();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('unified send with encryption: "holder"', () => {
|
|
210
|
+
it('preserves additional services and forwards the preparation idempotency key', async () => {
|
|
211
|
+
const other = {
|
|
212
|
+
id: 'https://issuer.example/refresh/other',
|
|
213
|
+
type: '1EdTechCredentialRefresh',
|
|
214
|
+
};
|
|
215
|
+
const client = getMockClient();
|
|
216
|
+
client.boost.getBoost.query.mockResolvedValue({
|
|
217
|
+
boost: getUnsignedBoostTemplate({ refreshService: other }),
|
|
218
|
+
});
|
|
219
|
+
const learnCard = getMockIssuingLearnCard();
|
|
220
|
+
const plugin = await getPlugin(learnCard, client);
|
|
221
|
+
const result = await plugin.methods?.send(learnCard, {
|
|
222
|
+
...HOLDER_SEND_INPUT,
|
|
223
|
+
idempotencyKey: 'stable-key',
|
|
224
|
+
});
|
|
225
|
+
expect(result).toMatchObject({ refresh: { refreshService: [REFRESH_SERVICE, other] } });
|
|
226
|
+
expect(client.credentialRefresh.sendHolderEncryptedRefreshable.mutate).toHaveBeenCalledWith(
|
|
227
|
+
expect.objectContaining({
|
|
228
|
+
idempotencyKey: 'stable-key',
|
|
229
|
+
handoff: expect.objectContaining({ refreshService: [REFRESH_SERVICE, other] }),
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
it.each([false, true])('preserves the signed status shape (array=%s)', async arrayShape => {
|
|
234
|
+
const credentialStatus = arrayShape ? [STATUS_ENTRY] : STATUS_ENTRY;
|
|
235
|
+
const client = getMockClient();
|
|
236
|
+
client.boost.getBoost.query.mockResolvedValue({
|
|
237
|
+
boost: getUnsignedBoostTemplate({ credentialStatus }),
|
|
238
|
+
});
|
|
239
|
+
const learnCard = getMockIssuingLearnCard();
|
|
240
|
+
const plugin = await getPlugin(learnCard, client);
|
|
241
|
+
|
|
242
|
+
const result = await plugin.methods?.send(learnCard, HOLDER_SEND_INPUT);
|
|
243
|
+
|
|
244
|
+
expect(client.credentialRefresh.sendHolderEncryptedRefreshable.mutate).toHaveBeenCalledWith(
|
|
245
|
+
expect.objectContaining({ handoff: expect.objectContaining({ credentialStatus }) })
|
|
246
|
+
);
|
|
247
|
+
expect(result).toMatchObject({ refresh: { credentialStatus } });
|
|
248
|
+
expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
|
|
249
|
+
expect.objectContaining({ credentialStatus })
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('encrypts exactly to recipientKeyIds and sends only the ciphertext handoff', async () => {
|
|
254
|
+
const client = getMockClient();
|
|
255
|
+
const learnCard = getMockIssuingLearnCard();
|
|
256
|
+
const plugin = await getPlugin(learnCard, client);
|
|
257
|
+
|
|
258
|
+
const result = await plugin.methods?.send(learnCard, HOLDER_SEND_INPUT);
|
|
259
|
+
|
|
260
|
+
expect(client.boost.prepareRefreshableSend.mutate).toHaveBeenCalledExactlyOnceWith(
|
|
261
|
+
expect.objectContaining({ encryption: 'holder', recipient: 'userb' })
|
|
262
|
+
);
|
|
263
|
+
expect(client.boost.send.mutate).not.toHaveBeenCalled();
|
|
264
|
+
expect(client.credentialRefresh.sendRefreshableCredential.mutate).not.toHaveBeenCalled();
|
|
265
|
+
|
|
266
|
+
const handoffCall = client.credentialRefresh.sendHolderEncryptedRefreshable.mutate.mock
|
|
267
|
+
.calls[0]?.[0] as any;
|
|
268
|
+
|
|
269
|
+
expect(Object.keys(handoffCall.handoff).sort()).toEqual(
|
|
270
|
+
[
|
|
271
|
+
'credentialId',
|
|
272
|
+
'credentialStatus',
|
|
273
|
+
'holderDid',
|
|
274
|
+
'issuerDid',
|
|
275
|
+
'jwe',
|
|
276
|
+
'refreshId',
|
|
277
|
+
'refreshService',
|
|
278
|
+
].sort()
|
|
279
|
+
);
|
|
280
|
+
expect(handoffCall.handoff.credentialId).toBe(defaultPreparedHolder.credentialId);
|
|
281
|
+
expect(handoffCall.handoff.holderDid).toBe(defaultPreparedHolder.holderDid);
|
|
282
|
+
expect(handoffCall.handoff.issuerDid).toBe(PROFILE.did);
|
|
283
|
+
expect(
|
|
284
|
+
handoffCall.handoff.jwe.recipients.map((recipient: any) => recipient.header.kid).sort()
|
|
285
|
+
).toEqual([...RECIPIENT_KEY_IDS].sort());
|
|
286
|
+
expect(handoffCall.boostUri).toBe(defaultPreparedHolder.boostUri);
|
|
287
|
+
|
|
288
|
+
expect(result).toEqual({
|
|
289
|
+
type: 'boost',
|
|
290
|
+
uri: defaultPreparedHolder.boostUri,
|
|
291
|
+
credentialUri: 'did:web:network.example:credentials:holder-1',
|
|
292
|
+
activityId: 'activity-holder-1',
|
|
293
|
+
refresh: {
|
|
294
|
+
refreshId: defaultPreparedHolder.refreshId,
|
|
295
|
+
credentialId: defaultPreparedHolder.credentialId,
|
|
296
|
+
holderDid: defaultPreparedHolder.holderDid,
|
|
297
|
+
issuerDid: PROFILE.did,
|
|
298
|
+
credentialStatus: STATUS_ENTRY,
|
|
299
|
+
refreshService: defaultPreparedHolder.refreshService,
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('never includes templateData or any claim value derived from it in a client call payload', async () => {
|
|
305
|
+
const SENTINEL = 'SENTINEL_DO_NOT_LEAK_7f3a9c';
|
|
306
|
+
const client = getMockClient();
|
|
307
|
+
const learnCard = getMockIssuingLearnCard();
|
|
308
|
+
const plugin = await getPlugin(learnCard, client);
|
|
309
|
+
|
|
310
|
+
await plugin.methods?.send(learnCard, {
|
|
311
|
+
...HOLDER_SEND_INPUT,
|
|
312
|
+
template: {
|
|
313
|
+
credential: getUnsignedBoostTemplate({
|
|
314
|
+
credentialSubject: { id: 'did:example:template-subject', secret: '{{secret}}' },
|
|
315
|
+
}),
|
|
316
|
+
},
|
|
317
|
+
templateUri: undefined,
|
|
318
|
+
templateData: { secret: SENTINEL },
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Sanity check: templating actually ran locally and embedded the sentinel into
|
|
322
|
+
// what gets signed, so the negative assertion below isn't vacuous.
|
|
323
|
+
expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
|
|
324
|
+
expect.objectContaining({
|
|
325
|
+
credentialSubject: expect.objectContaining({ secret: SENTINEL }),
|
|
326
|
+
})
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
expect(allClientCallArgsJson(client)).not.toContain(SENTINEL);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('rejects encryption: "holder" without refresh: true, before any server call', async () => {
|
|
333
|
+
const client = getMockClient();
|
|
334
|
+
const learnCard = getMockIssuingLearnCard();
|
|
335
|
+
const plugin = await getPlugin(learnCard, client);
|
|
336
|
+
|
|
337
|
+
await expect(
|
|
338
|
+
plugin.methods?.send(learnCard, {
|
|
339
|
+
type: 'boost',
|
|
340
|
+
recipient: 'userb',
|
|
341
|
+
templateUri: 'did:web:network.example:boost:1',
|
|
342
|
+
encryption: 'holder',
|
|
343
|
+
})
|
|
344
|
+
).rejects.toThrow('requires refresh: true');
|
|
345
|
+
|
|
346
|
+
expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
|
|
347
|
+
expect(client.boost.send.mutate).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it.each(['holder@example.com', '+15551234567'])(
|
|
351
|
+
'rejects an inbox recipient (%s) client-side, before any server call',
|
|
352
|
+
async recipient => {
|
|
353
|
+
const client = getMockClient();
|
|
354
|
+
const learnCard = getMockIssuingLearnCard();
|
|
355
|
+
const plugin = await getPlugin(learnCard, client);
|
|
356
|
+
|
|
357
|
+
await expect(
|
|
358
|
+
plugin.methods?.send(learnCard, { ...HOLDER_SEND_INPUT, recipient })
|
|
359
|
+
).rejects.toThrow('inbox');
|
|
360
|
+
|
|
361
|
+
expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
|
|
362
|
+
expect(client.boost.send.mutate).not.toHaveBeenCalled();
|
|
363
|
+
}
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
it('rejects a signedCredential send client-side, before any server call', async () => {
|
|
367
|
+
const client = getMockClient();
|
|
368
|
+
const learnCard = getMockIssuingLearnCard();
|
|
369
|
+
const plugin = await getPlugin(learnCard, client);
|
|
370
|
+
|
|
371
|
+
const signedCredential = {
|
|
372
|
+
...getUnsignedBoostTemplate({ id: 'urn:uuid:caller-signed' }),
|
|
373
|
+
issuer: PROFILE.did,
|
|
374
|
+
credentialSubject: { id: TARGET_PROFILE.did },
|
|
375
|
+
refreshService: REFRESH_SERVICE,
|
|
376
|
+
proof: { type: 'DataIntegrityProof' },
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
await expect(
|
|
380
|
+
plugin.methods?.send(learnCard, {
|
|
381
|
+
type: 'boost',
|
|
382
|
+
recipient: 'userb',
|
|
383
|
+
signedCredential: signedCredential as any,
|
|
384
|
+
refresh: true,
|
|
385
|
+
encryption: 'holder',
|
|
386
|
+
})
|
|
387
|
+
).rejects.toThrow('signedCredential');
|
|
388
|
+
|
|
389
|
+
expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
|
|
390
|
+
expect(client.boost.send.mutate).not.toHaveBeenCalled();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('rejects holder-mode encryption when local signing is unavailable', async () => {
|
|
394
|
+
const client = getMockClient();
|
|
395
|
+
const learnCard = getMockIssuingLearnCard();
|
|
396
|
+
delete learnCard.invoke.issueCredential;
|
|
397
|
+
const plugin = await getPlugin(learnCard, client);
|
|
398
|
+
|
|
399
|
+
await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
|
|
400
|
+
'local signing'
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
expect(client.boost.send.mutate).not.toHaveBeenCalled();
|
|
404
|
+
expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('rejects holder-mode encryption when the Encryption plugin is unavailable', async () => {
|
|
408
|
+
const client = getMockClient();
|
|
409
|
+
const learnCard = getMockIssuingLearnCard();
|
|
410
|
+
delete learnCard.invoke.createDagJweExact;
|
|
411
|
+
const plugin = await getPlugin(learnCard, client);
|
|
412
|
+
|
|
413
|
+
await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
|
|
414
|
+
'Encryption plugin'
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
expect(client.boost.send.mutate).not.toHaveBeenCalled();
|
|
418
|
+
expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('throws against an old server missing recipientKeyIds, and never falls back to network-mode plaintext delivery', async () => {
|
|
422
|
+
const client = getMockClient();
|
|
423
|
+
const { recipientKeyIds: _omitted, ...oldServerPrepared } = defaultPreparedHolder;
|
|
424
|
+
client.boost.prepareRefreshableSend.mutate.mockResolvedValue(oldServerPrepared);
|
|
425
|
+
const learnCard = getMockIssuingLearnCard();
|
|
426
|
+
const plugin = await getPlugin(learnCard, client);
|
|
427
|
+
|
|
428
|
+
await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
|
|
429
|
+
'does not support holder-mode encryption'
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
expect(learnCard.invoke.issueCredential).not.toHaveBeenCalled();
|
|
433
|
+
expect(client.boost.send.mutate).not.toHaveBeenCalled();
|
|
434
|
+
expect(
|
|
435
|
+
client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
|
|
436
|
+
).not.toHaveBeenCalled();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('throws when the server did not bind status entries for the holder-mode send', async () => {
|
|
440
|
+
const client = getMockClient();
|
|
441
|
+
client.boost.prepareRefreshableSend.mutate.mockResolvedValue({
|
|
442
|
+
...defaultPreparedHolder,
|
|
443
|
+
statusEntries: undefined,
|
|
444
|
+
});
|
|
445
|
+
const learnCard = getMockIssuingLearnCard();
|
|
446
|
+
const plugin = await getPlugin(learnCard, client);
|
|
447
|
+
|
|
448
|
+
await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
|
|
449
|
+
'status binding'
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
expect(
|
|
453
|
+
client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
|
|
454
|
+
).not.toHaveBeenCalled();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('preserves external template status without requiring allocated entries', async () => {
|
|
458
|
+
const client = getMockClient();
|
|
459
|
+
const credentialStatus = {
|
|
460
|
+
...STATUS_ENTRY,
|
|
461
|
+
id: 'https://external.example/status#7',
|
|
462
|
+
statusListCredential: 'https://external.example/status',
|
|
463
|
+
statusListIndex: '7',
|
|
464
|
+
};
|
|
465
|
+
client.boost.prepareRefreshableSend.mutate.mockResolvedValue({
|
|
466
|
+
...defaultPreparedHolder,
|
|
467
|
+
statusEntries: undefined,
|
|
468
|
+
});
|
|
469
|
+
client.boost.getBoost.query.mockResolvedValue({
|
|
470
|
+
boost: getUnsignedBoostTemplate({ credentialStatus }),
|
|
471
|
+
});
|
|
472
|
+
client.credentialRefresh.sendHolderEncryptedRefreshable.mutate.mockResolvedValue({
|
|
473
|
+
uri: 'did:web:network.example:credentials:holder-1',
|
|
474
|
+
activityId: 'activity-holder-1',
|
|
475
|
+
statusManagement: 'external',
|
|
476
|
+
});
|
|
477
|
+
const learnCard = getMockIssuingLearnCard();
|
|
478
|
+
const plugin = await getPlugin(learnCard, client);
|
|
479
|
+
const result = await plugin.methods?.send(learnCard, HOLDER_SEND_INPUT);
|
|
480
|
+
expect(result?.statusManagement).toBe('external');
|
|
481
|
+
expect(result?.refresh?.credentialStatus).toEqual(credentialStatus);
|
|
482
|
+
expect(client.boost.allocateCredentialStatus.mutate).not.toHaveBeenCalled();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('aborts before sending when the produced JWE recipient set does not match recipientKeyIds', async () => {
|
|
486
|
+
const client = getMockClient();
|
|
487
|
+
const learnCard = getMockIssuingLearnCard({
|
|
488
|
+
createDagJweExact: vi.fn().mockResolvedValue(jweForDids([HOLDER_DID])), // missing the manager
|
|
489
|
+
});
|
|
490
|
+
const plugin = await getPlugin(learnCard, client);
|
|
491
|
+
|
|
492
|
+
await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
|
|
493
|
+
'ENCRYPTION_RECIPIENTS_MISMATCH'
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
expect(
|
|
497
|
+
client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
|
|
498
|
+
).not.toHaveBeenCalled();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('retries once after RECIPIENTS_CHANGED, re-encrypting to the freshly fetched recipient set', async () => {
|
|
502
|
+
const client = getMockClient();
|
|
503
|
+
const conflict = Object.assign(new Error('RECIPIENTS_CHANGED'), {
|
|
504
|
+
data: { code: 'CONFLICT' },
|
|
505
|
+
});
|
|
506
|
+
client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
|
|
507
|
+
.mockRejectedValueOnce(conflict)
|
|
508
|
+
.mockResolvedValueOnce({
|
|
509
|
+
uri: 'did:web:network.example:credentials:holder-2',
|
|
510
|
+
activityId: 'activity-holder-2',
|
|
511
|
+
});
|
|
512
|
+
const NEW_MANAGER_DID = 'did:key:zNewManager';
|
|
513
|
+
const freshKeyIds = [keyIdForDid(HOLDER_DID), keyIdForDid(NEW_MANAGER_DID)];
|
|
514
|
+
client.credentialRefresh.getEncryptionRecipients.query.mockResolvedValue({
|
|
515
|
+
recipientKeyIds: freshKeyIds,
|
|
516
|
+
});
|
|
517
|
+
const learnCard = getMockIssuingLearnCard();
|
|
518
|
+
const plugin = await getPlugin(learnCard, client);
|
|
519
|
+
|
|
520
|
+
const result = await plugin.methods?.send(learnCard, HOLDER_SEND_INPUT);
|
|
521
|
+
|
|
522
|
+
expect(
|
|
523
|
+
client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
|
|
524
|
+
).toHaveBeenCalledTimes(2);
|
|
525
|
+
expect(
|
|
526
|
+
client.credentialRefresh.getEncryptionRecipients.query
|
|
527
|
+
).toHaveBeenCalledExactlyOnceWith({ refreshId: defaultPreparedHolder.refreshId });
|
|
528
|
+
|
|
529
|
+
const retryHandoff = client.credentialRefresh.sendHolderEncryptedRefreshable.mutate.mock
|
|
530
|
+
.calls[1]?.[0] as any;
|
|
531
|
+
expect(
|
|
532
|
+
retryHandoff.handoff.jwe.recipients.map((recipient: any) => recipient.header.kid).sort()
|
|
533
|
+
).toEqual([...freshKeyIds].sort());
|
|
534
|
+
expect((result as any).credentialUri).toBe('did:web:network.example:credentials:holder-2');
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('fails after a second RECIPIENTS_CHANGED without a further retry', async () => {
|
|
538
|
+
const client = getMockClient();
|
|
539
|
+
const conflict = Object.assign(new Error('RECIPIENTS_CHANGED'), {
|
|
540
|
+
data: { code: 'CONFLICT' },
|
|
541
|
+
});
|
|
542
|
+
client.credentialRefresh.sendHolderEncryptedRefreshable.mutate.mockRejectedValue(conflict);
|
|
543
|
+
const learnCard = getMockIssuingLearnCard();
|
|
544
|
+
const plugin = await getPlugin(learnCard, client);
|
|
545
|
+
|
|
546
|
+
await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
|
|
547
|
+
'RECIPIENTS_CHANGED'
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
expect(
|
|
551
|
+
client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
|
|
552
|
+
).toHaveBeenCalledTimes(2);
|
|
553
|
+
expect(client.credentialRefresh.getEncryptionRecipients.query).toHaveBeenCalledTimes(1);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('propagates a non-conflict failure from sendHolderEncryptedRefreshable without retrying', async () => {
|
|
557
|
+
const client = getMockClient();
|
|
558
|
+
client.credentialRefresh.sendHolderEncryptedRefreshable.mutate.mockRejectedValue(
|
|
559
|
+
new Error('Credential refresh is not available')
|
|
560
|
+
);
|
|
561
|
+
const learnCard = getMockIssuingLearnCard();
|
|
562
|
+
const plugin = await getPlugin(learnCard, client);
|
|
563
|
+
|
|
564
|
+
await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
|
|
565
|
+
'Credential refresh is not available'
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
expect(
|
|
569
|
+
client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
|
|
570
|
+
).toHaveBeenCalledTimes(1);
|
|
571
|
+
expect(client.credentialRefresh.getEncryptionRecipients.query).not.toHaveBeenCalled();
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
describe('publishHolderEncryptedRefreshable', () => {
|
|
576
|
+
const RECEIPT = {
|
|
577
|
+
refreshId: defaultPreparedHolder.refreshId,
|
|
578
|
+
refreshService: defaultPreparedHolder.refreshService,
|
|
579
|
+
credentialId: defaultPreparedHolder.credentialId,
|
|
580
|
+
issuerDid: PROFILE.did,
|
|
581
|
+
holderDid: defaultPreparedHolder.holderDid,
|
|
582
|
+
credentialStatus: STATUS_ENTRY,
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
it.each([false, true])('publishes the receipt status shape (array=%s)', async arrayShape => {
|
|
586
|
+
const credentialStatus = arrayShape ? [STATUS_ENTRY] : STATUS_ENTRY;
|
|
587
|
+
const client = getMockClient();
|
|
588
|
+
const learnCard = getMockIssuingLearnCard();
|
|
589
|
+
const plugin = await getPlugin(learnCard, client);
|
|
590
|
+
|
|
591
|
+
await plugin.methods?.publishHolderEncryptedRefreshable(
|
|
592
|
+
learnCard,
|
|
593
|
+
{ ...RECEIPT, credentialStatus },
|
|
594
|
+
UnsignedVCValidator.parse(
|
|
595
|
+
getUnsignedBoostTemplate({
|
|
596
|
+
credentialStatus: arrayShape ? STATUS_ENTRY : [STATUS_ENTRY],
|
|
597
|
+
})
|
|
598
|
+
),
|
|
599
|
+
{ notify: false }
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
expect(
|
|
603
|
+
client.credentialRefresh.publishHolderEncryptedRefreshable.mutate
|
|
604
|
+
).toHaveBeenCalledWith(
|
|
605
|
+
expect.objectContaining({ handoff: expect.objectContaining({ credentialStatus }) })
|
|
606
|
+
);
|
|
607
|
+
expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
|
|
608
|
+
expect.objectContaining({ credentialStatus })
|
|
609
|
+
);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it.each([false, true])(
|
|
613
|
+
'publishes the receipt service shape rather than the template shape (array=%s)',
|
|
614
|
+
async arrayShape => {
|
|
615
|
+
const other = {
|
|
616
|
+
id: 'https://issuer.example/refresh/other',
|
|
617
|
+
type: '1EdTechCredentialRefresh',
|
|
618
|
+
};
|
|
619
|
+
const refreshService = arrayShape ? [REFRESH_SERVICE, other] : REFRESH_SERVICE;
|
|
620
|
+
const client = getMockClient();
|
|
621
|
+
const learnCard = getMockIssuingLearnCard();
|
|
622
|
+
const plugin = await getPlugin(learnCard, client);
|
|
623
|
+
await plugin.methods?.publishHolderEncryptedRefreshable(
|
|
624
|
+
learnCard,
|
|
625
|
+
{ ...RECEIPT, refreshService },
|
|
626
|
+
UnsignedVCValidator.parse(getUnsignedBoostTemplate({ refreshService: other })),
|
|
627
|
+
{ notify: false }
|
|
628
|
+
);
|
|
629
|
+
expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
|
|
630
|
+
expect.objectContaining({ refreshService })
|
|
631
|
+
);
|
|
632
|
+
expect(
|
|
633
|
+
client.credentialRefresh.publishHolderEncryptedRefreshable.mutate
|
|
634
|
+
).toHaveBeenCalledWith(
|
|
635
|
+
expect.objectContaining({ handoff: expect.objectContaining({ refreshService }) })
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
it('publishes a new version, encrypted to the current recipients, with explicit notify', async () => {
|
|
641
|
+
const client = getMockClient();
|
|
642
|
+
const learnCard = getMockIssuingLearnCard();
|
|
643
|
+
const plugin = await getPlugin(learnCard, client);
|
|
644
|
+
|
|
645
|
+
const result = await plugin.methods?.publishHolderEncryptedRefreshable(
|
|
646
|
+
learnCard,
|
|
647
|
+
RECEIPT,
|
|
648
|
+
getUnsignedBoostTemplate({ id: RECEIPT.credentialId }) as any,
|
|
649
|
+
{ notify: true }
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
expect(
|
|
653
|
+
client.credentialRefresh.getEncryptionRecipients.query
|
|
654
|
+
).toHaveBeenCalledExactlyOnceWith({ refreshId: RECEIPT.refreshId });
|
|
655
|
+
|
|
656
|
+
const publishCall = client.credentialRefresh.publishHolderEncryptedRefreshable.mutate.mock
|
|
657
|
+
.calls[0]?.[0] as any;
|
|
658
|
+
expect(publishCall.notify).toBe(true);
|
|
659
|
+
expect(publishCall.handoff.credentialId).toBe(RECEIPT.credentialId);
|
|
660
|
+
expect(publishCall.handoff.holderDid).toBe(RECEIPT.holderDid);
|
|
661
|
+
expect(
|
|
662
|
+
publishCall.handoff.jwe.recipients.map((recipient: any) => recipient.header.kid).sort()
|
|
663
|
+
).toEqual([...RECIPIENT_KEY_IDS].sort());
|
|
664
|
+
expect(result).toEqual({
|
|
665
|
+
refreshId: defaultPreparedHolder.refreshId,
|
|
666
|
+
version: 2,
|
|
667
|
+
publishedAt: '2024-01-01T00:00:00.000Z',
|
|
668
|
+
notification: 'queued',
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('never includes templateData-derived claim values in a client call payload', async () => {
|
|
673
|
+
const SENTINEL = 'SENTINEL_PUBLISH_DO_NOT_LEAK_2b61';
|
|
674
|
+
const client = getMockClient();
|
|
675
|
+
const learnCard = getMockIssuingLearnCard();
|
|
676
|
+
const plugin = await getPlugin(learnCard, client);
|
|
677
|
+
|
|
678
|
+
await plugin.methods?.publishHolderEncryptedRefreshable(
|
|
679
|
+
learnCard,
|
|
680
|
+
RECEIPT,
|
|
681
|
+
getUnsignedBoostTemplate({
|
|
682
|
+
id: RECEIPT.credentialId,
|
|
683
|
+
credentialSubject: { id: 'did:example:template-subject', secret: '{{secret}}' },
|
|
684
|
+
}) as any,
|
|
685
|
+
{ notify: false, templateData: { secret: SENTINEL } }
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
|
|
689
|
+
expect.objectContaining({
|
|
690
|
+
credentialSubject: expect.objectContaining({ secret: SENTINEL }),
|
|
691
|
+
})
|
|
692
|
+
);
|
|
693
|
+
expect(allClientCallArgsJson(client)).not.toContain(SENTINEL);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it('rejects when local signing is unavailable', async () => {
|
|
697
|
+
const client = getMockClient();
|
|
698
|
+
const learnCard = getMockIssuingLearnCard();
|
|
699
|
+
delete learnCard.invoke.issueCredential;
|
|
700
|
+
const plugin = await getPlugin(learnCard, client);
|
|
701
|
+
|
|
702
|
+
await expect(
|
|
703
|
+
plugin.methods?.publishHolderEncryptedRefreshable(
|
|
704
|
+
learnCard,
|
|
705
|
+
RECEIPT,
|
|
706
|
+
getUnsignedBoostTemplate({ id: RECEIPT.credentialId }) as any,
|
|
707
|
+
{ notify: true }
|
|
708
|
+
)
|
|
709
|
+
).rejects.toThrow('local signing');
|
|
710
|
+
|
|
711
|
+
expect(
|
|
712
|
+
client.credentialRefresh.publishHolderEncryptedRefreshable.mutate
|
|
713
|
+
).not.toHaveBeenCalled();
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('rejects when the receipt carries no credentialStatus binding', async () => {
|
|
717
|
+
const client = getMockClient();
|
|
718
|
+
const learnCard = getMockIssuingLearnCard();
|
|
719
|
+
const plugin = await getPlugin(learnCard, client);
|
|
720
|
+
const { credentialStatus: _omitted, ...receiptWithoutStatus } = RECEIPT;
|
|
721
|
+
|
|
722
|
+
await expect(
|
|
723
|
+
plugin.methods?.publishHolderEncryptedRefreshable(
|
|
724
|
+
learnCard,
|
|
725
|
+
receiptWithoutStatus as any,
|
|
726
|
+
getUnsignedBoostTemplate({ id: RECEIPT.credentialId }) as any,
|
|
727
|
+
{ notify: true }
|
|
728
|
+
)
|
|
729
|
+
).rejects.toThrow('credentialStatus');
|
|
730
|
+
|
|
731
|
+
expect(
|
|
732
|
+
client.credentialRefresh.publishHolderEncryptedRefreshable.mutate
|
|
733
|
+
).not.toHaveBeenCalled();
|
|
734
|
+
});
|
|
735
|
+
});
|