@learncard/vc-plugin 1.5.13 → 1.6.1

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.
@@ -0,0 +1,931 @@
1
+ import {
2
+ CredentialRefreshChallengeValidator,
3
+ CredentialRefreshFailureCode,
4
+ CredentialRefreshResult,
5
+ JWE,
6
+ JWEValidator,
7
+ VC,
8
+ VCValidator,
9
+ VP,
10
+ } from '@learncard/types';
11
+ import {
12
+ credentialContentsEqual,
13
+ getCredentialEffectiveTime,
14
+ getCredentialIssuerId,
15
+ getSupportedRefreshService,
16
+ } from '@learncard/helpers';
17
+
18
+ import { fetchWithPinnedAddress, type PinnedAddress } from './refreshCredential.fetch';
19
+ import { RefreshCredentialOptions, VCDependentLearnCard, VCImplicitLearnCard } from './types';
20
+
21
+ /**
22
+ * Generic holder-side credential refresh primitive (LC-2117, LC-2135, LC-2136).
23
+ *
24
+ * `refreshCredential` fetches a candidate replacement from a credential's
25
+ * `1EdTechCredentialRefresh` (JSON only) or `LearnCardCredentialRefresh2026` service,
26
+ * verifies both the current and candidate
27
+ * proofs, enforces identity stability and non-regressing freshness, and returns a
28
+ * typed result. It performs no storage or index mutation.
29
+ *
30
+ * The generic primitive permits same-issuer-signed changes to credentialStatus and
31
+ * refreshService. It does not pin those fields; applications requiring immutable
32
+ * revocation/refresh handles must enforce that policy. Managed brain-service
33
+ * publication enforces both. Any new endpoint is revalidated on the next refresh.
34
+ *
35
+ * Because `refreshService.id` is credential-controlled input, every request is
36
+ * SSRF-hardened: HTTPS-only by default, private/loopback/link-local destinations are
37
+ * rejected (after DNS resolution in Node runtimes), redirects are followed manually
38
+ * and revalidated, timeouts abort the request, response bytes are counted while
39
+ * streaming, and only JSON VC/JWE representations implemented by the decoder are
40
+ * accepted. DID auth is retried exactly once for the recognized `LearnCardDIDAuth`
41
+ * challenge scheme and is never forwarded across origins.
42
+ */
43
+
44
+ const DID_AUTH_SCHEME = 'learncarddidauth';
45
+
46
+ const DEFAULT_TIMEOUT_MS = 10_000;
47
+ const HEADER_CHALLENGE_TTL_MS = 300_000;
48
+ const MAX_TRACKED_CHALLENGES = 1024;
49
+ const DEFAULT_MAX_REDIRECTS = 3;
50
+ const DEFAULT_MAX_RESPONSE_BYTES = 1_048_576; // 1 MiB
51
+
52
+ /** JSON representations implemented by the response decoder */
53
+ const ACCEPTED_MEDIA_TYPES = new Set([
54
+ 'application/json',
55
+ 'application/ld+json',
56
+ 'application/vc+ld+json',
57
+ 'application/vc-ld+json',
58
+ 'application/jwe+json',
59
+ ]);
60
+
61
+ const ACCEPT_HEADER = [...ACCEPTED_MEDIA_TYPES].join(', ');
62
+
63
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
64
+
65
+ const failed = (
66
+ code: CredentialRefreshFailureCode,
67
+ retryable: boolean
68
+ ): CredentialRefreshResult => ({
69
+ status: 'failed',
70
+ code,
71
+ retryable,
72
+ });
73
+
74
+ const unsafeEndpoint = () => failed('UNSAFE_ENDPOINT', false);
75
+ const malformedResponse = () => failed('MALFORMED_RESPONSE', false);
76
+ const invalidProof = () => failed('INVALID_PROOF', false);
77
+ const unauthorized = () => failed('UNAUTHORIZED', false);
78
+ // Deliberately fail closed on verifier warnings until each warning is reviewed.
79
+ const proofVerified = (
80
+ check: { checks: string[]; warnings: string[]; errors: string[] } | null | undefined
81
+ ): boolean =>
82
+ !!check &&
83
+ check.errors.length === 0 &&
84
+ check.warnings.length === 0 &&
85
+ check.checks.includes('proof');
86
+
87
+ type ResolvedRefreshOptions = {
88
+ etag?: string;
89
+ timeoutMs: number;
90
+ maxRedirects: number;
91
+ maxResponseBytes: number;
92
+ allowInsecureHttp: boolean;
93
+ allowPrivateAddresses: boolean;
94
+ resolveHost?: (hostname: string) => Promise<string[]>;
95
+ };
96
+
97
+ // --- SSRF guards --------------------------------------------------------------
98
+
99
+ const parseIPv4Octets = (ip: string): [number, number, number, number] | undefined => {
100
+ const parts = ip.split('.');
101
+
102
+ if (
103
+ parts.length !== 4 ||
104
+ parts.some(part => !/^\d{1,3}$/.test(part) || Number(part) < 0 || Number(part) > 255)
105
+ ) {
106
+ return undefined;
107
+ }
108
+
109
+ return parts.map(Number) as [number, number, number, number];
110
+ };
111
+
112
+ /** Rejects loopback, private, link-local, multicast, reserved, and cloud-metadata IPv4 ranges */
113
+ export const isPrivateOrReservedIPv4 = (ip: string): boolean => {
114
+ const octets = parseIPv4Octets(ip);
115
+
116
+ if (!octets) return true; // malformed literals are unsafe
117
+
118
+ const [a, b, c] = octets;
119
+
120
+ return (
121
+ a === 0 || // "this" network
122
+ a === 10 || // RFC 1918
123
+ a === 127 || // loopback
124
+ (a === 100 && b >= 64 && b <= 127) || // CGNAT
125
+ (a === 169 && b === 254) || // link-local + cloud metadata
126
+ (a === 172 && b >= 16 && b <= 31) || // RFC 1918
127
+ (a === 192 && b === 0) || // IETF protocol assignments + TEST-NET-1
128
+ (a === 192 && b === 168) || // RFC 1918
129
+ (a === 198 && (b === 18 || b === 19)) || // benchmarking
130
+ (a === 198 && b === 51 && c === 100) || // TEST-NET-2
131
+ (a === 203 && b === 0 && c === 113) || // TEST-NET-3
132
+ a >= 224 // multicast + reserved
133
+ );
134
+ };
135
+
136
+ const parseIPv6Hextets = (raw: string): number[] | undefined => {
137
+ const hasOpeningBracket = raw.startsWith('[');
138
+ const hasClosingBracket = raw.endsWith(']');
139
+
140
+ if (hasOpeningBracket !== hasClosingBracket) return undefined;
141
+
142
+ const ip = (hasOpeningBracket ? raw.slice(1, -1) : raw).toLowerCase();
143
+
144
+ if (!ip || ip.includes('%') || (ip.match(/::/g)?.length ?? 0) > 1) return undefined;
145
+
146
+ let normalized = ip;
147
+ const dottedTail = normalized.match(/(?:^|:)(\d{1,3}(?:\.\d{1,3}){3})$/)?.[1];
148
+
149
+ if (dottedTail) {
150
+ const octets = parseIPv4Octets(dottedTail);
151
+
152
+ if (!octets) return undefined;
153
+
154
+ normalized = normalized.slice(0, -dottedTail.length);
155
+ normalized += `${((octets[0] << 8) | octets[1]).toString(16)}:${(
156
+ (octets[2] << 8) |
157
+ octets[3]
158
+ ).toString(16)}`;
159
+ }
160
+
161
+ const hasCompression = normalized.includes('::');
162
+ const [leftRaw, rightRaw = ''] = normalized.split('::');
163
+ const left = leftRaw ? leftRaw.split(':') : [];
164
+ const right = rightRaw ? rightRaw.split(':') : [];
165
+ const tokens = [...left, ...right];
166
+
167
+ if (
168
+ tokens.some(token => !/^[0-9a-f]{1,4}$/.test(token)) ||
169
+ (hasCompression ? tokens.length >= 8 : tokens.length !== 8)
170
+ ) {
171
+ return undefined;
172
+ }
173
+
174
+ const omitted = hasCompression ? 8 - tokens.length : 0;
175
+
176
+ return [...left, ...Array<number>(omitted).fill(0), ...right].map(token =>
177
+ typeof token === 'number' ? token : Number.parseInt(token, 16)
178
+ );
179
+ };
180
+
181
+ /**
182
+ * Translation/transition ranges can route an apparently public IPv6 literal to an
183
+ * embedded IPv4 destination. Reject the standardized ranges rather than trusting
184
+ * an intermediary gateway not to expose private or link-local IPv4 services.
185
+ */
186
+ const isIPv4TranslationOrTransitionPrefix = (hextets: number[]): boolean => {
187
+ const [first, second, third] = hextets;
188
+ const isNat64WellKnown =
189
+ first === 0x0064 && second === 0xff9b && hextets.slice(2, 6).every(part => part === 0); // 64:ff9b::/96
190
+ const isNat64Local = first === 0x0064 && second === 0xff9b && third === 0x0001; // 64:ff9b:1::/48
191
+ const is6to4 = first === 0x2002; // 2002::/16
192
+
193
+ return isNat64WellKnown || isNat64Local || is6to4;
194
+ };
195
+
196
+ /** Rejects loopback, private/transition, link-local, multicast, documentation, and mapped-private IPv6 */
197
+ export const isPrivateOrReservedIPv6 = (raw: string): boolean => {
198
+ const hextets = parseIPv6Hextets(raw);
199
+
200
+ if (!hextets) return true;
201
+
202
+ if (isIPv4TranslationOrTransitionPrefix(hextets)) return true;
203
+
204
+ const [first = 0, second = 0] = hextets;
205
+ const isEmbeddedIPv4Prefix = hextets.slice(0, 5).every(part => part === 0);
206
+
207
+ if (isEmbeddedIPv4Prefix && (hextets[5] === 0 || hextets[5] === 0xffff)) {
208
+ const embedded = `${(hextets[6] ?? 0) >> 8}.${(hextets[6] ?? 0) & 0xff}.${
209
+ (hextets[7] ?? 0) >> 8
210
+ }.${(hextets[7] ?? 0) & 0xff}`;
211
+
212
+ if (isPrivateOrReservedIPv4(embedded)) return true;
213
+ }
214
+
215
+ if (hextets.every(part => part === 0)) return true;
216
+ if (hextets.slice(0, 7).every(part => part === 0) && hextets[7] === 1) return true;
217
+ if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
218
+ if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
219
+ if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast
220
+ if (first === 0x2001 && second === 0x0db8) return true; // docs
221
+
222
+ return false;
223
+ };
224
+
225
+ const parsePinnedAddress = (raw: string): PinnedAddress | undefined => {
226
+ if (raw.includes(':')) {
227
+ const hextets = parseIPv6Hextets(raw);
228
+
229
+ if (!hextets) return undefined;
230
+
231
+ return { address: hextets.map(part => part.toString(16)).join(':'), family: 6 };
232
+ }
233
+
234
+ const octets = parseIPv4Octets(raw);
235
+
236
+ if (!octets) return undefined;
237
+
238
+ return { address: octets.join('.'), family: 4 };
239
+ };
240
+
241
+ const isNodeRuntime = typeof process !== 'undefined' && !!process.versions?.node;
242
+
243
+ /**
244
+ * Resolves a hostname with Node's DNS resolver. The specifier is intentionally
245
+ * computed so browser bundlers leave the Node builtin external.
246
+ */
247
+ const resolveHostnameWithNodeDns = async (hostname: string): Promise<string[]> => {
248
+ const specifier = 'node:dns/promises';
249
+ const dns = (await import(/* @vite-ignore */ specifier)) as typeof import('node:dns/promises');
250
+ const answers = await dns.lookup(hostname, { all: true, verbatim: true });
251
+
252
+ return answers.map(answer => answer.address);
253
+ };
254
+
255
+ type ValidatedRefreshUrl = { url: URL; pinnedAddress?: PinnedAddress };
256
+ type UrlSafety = ValidatedRefreshUrl | { result: CredentialRefreshResult };
257
+
258
+ /**
259
+ * Validates a refresh endpoint URL before any request is made.
260
+ *
261
+ * Requires HTTPS unless the explicit local-development opt-in allows HTTP, rejects
262
+ * userinfo, and rejects private/loopback/link-local host literals. In Node-capable
263
+ * runtimes the hostname is resolved and every private answer rejects the endpoint;
264
+ * in browsers only unsafe host literals are rejected and the platform plus CORS are
265
+ * relied upon for host resolution.
266
+ */
267
+ const validateRefreshUrl = async (
268
+ rawUrl: string,
269
+ options: ResolvedRefreshOptions
270
+ ): Promise<UrlSafety> => {
271
+ let url: URL;
272
+
273
+ try {
274
+ url = new URL(rawUrl);
275
+ } catch {
276
+ return { result: unsafeEndpoint() };
277
+ }
278
+
279
+ if (url.username || url.password) return { result: unsafeEndpoint() };
280
+
281
+ const isHttps = url.protocol === 'https:';
282
+ const isHttp = url.protocol === 'http:';
283
+
284
+ if (!isHttps && !(isHttp && options.allowInsecureHttp)) return { result: unsafeEndpoint() };
285
+
286
+ const isIpv6Literal = url.hostname.startsWith('[');
287
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
288
+
289
+ if (!hostname) return { result: unsafeEndpoint() };
290
+
291
+ if (isIpv6Literal) {
292
+ if (!options.allowPrivateAddresses && isPrivateOrReservedIPv6(hostname)) {
293
+ return { result: unsafeEndpoint() };
294
+ }
295
+ } else if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname)) {
296
+ if (!options.allowPrivateAddresses && isPrivateOrReservedIPv4(hostname)) {
297
+ return { result: unsafeEndpoint() };
298
+ }
299
+ } else if (options.resolveHost) {
300
+ let addresses: string[];
301
+
302
+ try {
303
+ addresses = await options.resolveHost(hostname);
304
+ } catch {
305
+ return { result: failed('UNAVAILABLE', true) };
306
+ }
307
+
308
+ if (addresses.length === 0) return { result: failed('UNAVAILABLE', true) };
309
+
310
+ const parsedAddresses = addresses.map(parsePinnedAddress);
311
+ const pinnedAddress = parsedAddresses[0];
312
+ const hasUnsafeAnswer = parsedAddresses.some(
313
+ address =>
314
+ !address ||
315
+ (address.family === 6
316
+ ? isPrivateOrReservedIPv6(address.address)
317
+ : isPrivateOrReservedIPv4(address.address))
318
+ );
319
+
320
+ if (!pinnedAddress || (hasUnsafeAnswer && !options.allowPrivateAddresses)) {
321
+ return { result: unsafeEndpoint() };
322
+ }
323
+
324
+ return { url, pinnedAddress };
325
+ }
326
+
327
+ return { url };
328
+ };
329
+
330
+ // --- Safe fetching --------------------------------------------------------------
331
+
332
+ type GuardedResponse = {
333
+ response: Response;
334
+ endpoint: ValidatedRefreshUrl;
335
+ signal: AbortSignal;
336
+ finish: () => void;
337
+ };
338
+ type FetchOutcome = GuardedResponse | { result: CredentialRefreshResult };
339
+
340
+ const discardGuardedResponse = async (guarded: GuardedResponse): Promise<void> => {
341
+ try {
342
+ await guarded.response.body?.cancel();
343
+ } catch {
344
+ // The response is already terminal; cancellation is best-effort cleanup.
345
+ } finally {
346
+ guarded.finish();
347
+ }
348
+ };
349
+
350
+ /**
351
+ * Performs a guarded GET: manual redirect handling with full revalidation of every
352
+ * redirect target, a redirect cap, a shared timeout across hops, and no forwarding of
353
+ * LearnCard authorization across origins.
354
+ */
355
+ const fetchWithGuards = async (
356
+ initialEndpoint: ValidatedRefreshUrl,
357
+ options: ResolvedRefreshOptions,
358
+ authorization?: string
359
+ ): Promise<FetchOutcome> => {
360
+ if (typeof globalThis.fetch !== 'function') {
361
+ return { result: failed('UNAVAILABLE', false) };
362
+ }
363
+
364
+ let current = initialEndpoint;
365
+ const initialOrigin = initialEndpoint.url.origin;
366
+ let redirectsRemaining = options.maxRedirects;
367
+ let auth = authorization;
368
+ const deadline = Date.now() + options.timeoutMs;
369
+
370
+ for (;;) {
371
+ const remainingMs = deadline - Date.now();
372
+ if (remainingMs <= 0) return { result: failed('TIMEOUT', true) };
373
+ const controller = new AbortController();
374
+ const timer = setTimeout(() => controller.abort(), remainingMs);
375
+
376
+ let response: Response;
377
+
378
+ try {
379
+ const headers: Record<string, string> = { accept: ACCEPT_HEADER };
380
+
381
+ if (options.etag) headers['if-none-match'] = options.etag;
382
+ if (auth) headers.authorization = auth;
383
+
384
+ response = await fetchWithPinnedAddress(
385
+ current.url,
386
+ {
387
+ method: 'GET',
388
+ redirect: 'manual',
389
+ signal: controller.signal,
390
+ headers,
391
+ },
392
+ current.pinnedAddress
393
+ );
394
+ } catch (error) {
395
+ clearTimeout(timer);
396
+
397
+ const timedOut =
398
+ controller.signal.aborted || (error as Error | null)?.name === 'AbortError';
399
+
400
+ return { result: failed(timedOut ? 'TIMEOUT' : 'UNAVAILABLE', true) };
401
+ }
402
+
403
+ if (!REDIRECT_STATUSES.has(response.status)) {
404
+ return {
405
+ response,
406
+ endpoint: current,
407
+ signal: controller.signal,
408
+ finish: () => clearTimeout(timer),
409
+ };
410
+ }
411
+
412
+ clearTimeout(timer);
413
+ await response.body?.cancel().catch(() => undefined);
414
+
415
+ if (redirectsRemaining <= 0) return { result: failed('UNAVAILABLE', false) };
416
+
417
+ const location = response.headers.get('location');
418
+
419
+ if (!location) return { result: malformedResponse() };
420
+
421
+ let next: URL;
422
+
423
+ try {
424
+ next = new URL(location, current.url);
425
+ } catch {
426
+ return { result: unsafeEndpoint() };
427
+ }
428
+
429
+ const validated = await validateRefreshUrl(next.href, options);
430
+
431
+ if ('result' in validated) return validated;
432
+
433
+ if (validated.url.origin !== initialOrigin) auth = undefined;
434
+
435
+ redirectsRemaining -= 1;
436
+ current = validated;
437
+ }
438
+ };
439
+
440
+ /**
441
+ * Reads a response body while counting streamed bytes and keeps the request's abort
442
+ * signal observable until streaming completes.
443
+ */
444
+ type BodyReadOutcome =
445
+ { body: string } | { result: CredentialRefreshResult; reason: 'timeout' | 'malformed' };
446
+
447
+ const readBodyWithLimit = async (
448
+ response: Response,
449
+ maxBytes: number,
450
+ signal: AbortSignal
451
+ ): Promise<BodyReadOutcome> => {
452
+ const declared = Number(response.headers.get('content-length'));
453
+
454
+ if (Number.isFinite(declared) && declared > maxBytes) {
455
+ return { result: malformedResponse(), reason: 'malformed' };
456
+ }
457
+
458
+ if (!response.body) {
459
+ try {
460
+ const body = await response.text();
461
+
462
+ return new TextEncoder().encode(body).byteLength > maxBytes
463
+ ? { result: malformedResponse(), reason: 'malformed' }
464
+ : { body };
465
+ } catch {
466
+ return signal.aborted
467
+ ? { result: failed('TIMEOUT', true), reason: 'timeout' }
468
+ : { result: malformedResponse(), reason: 'malformed' };
469
+ }
470
+ }
471
+
472
+ const reader = response.body.getReader();
473
+ const chunks: Uint8Array[] = [];
474
+ let total = 0;
475
+
476
+ try {
477
+ for (;;) {
478
+ const { done, value } = await reader.read();
479
+
480
+ if (done) break;
481
+
482
+ total += value?.byteLength ?? 0;
483
+
484
+ if (total > maxBytes) {
485
+ await reader.cancel().catch(() => undefined);
486
+
487
+ return { result: malformedResponse(), reason: 'malformed' };
488
+ }
489
+
490
+ if (value) chunks.push(value);
491
+ }
492
+ } catch {
493
+ return signal.aborted
494
+ ? { result: failed('TIMEOUT', true), reason: 'timeout' }
495
+ : { result: malformedResponse(), reason: 'malformed' };
496
+ }
497
+
498
+ const merged = new Uint8Array(total);
499
+ let offset = 0;
500
+
501
+ for (const chunk of chunks) {
502
+ merged.set(chunk, offset);
503
+ offset += chunk.byteLength;
504
+ }
505
+
506
+ return { body: new TextDecoder().decode(merged) };
507
+ };
508
+
509
+ const isAcceptedMediaType = (contentType: string | null): boolean => {
510
+ if (!contentType) return false;
511
+
512
+ const mediaType = contentType.split(';', 1)[0]?.trim().toLowerCase() ?? '';
513
+
514
+ return ACCEPTED_MEDIA_TYPES.has(mediaType);
515
+ };
516
+
517
+ // --- Managed DID-auth challenge -------------------------------------------------
518
+
519
+ type ParsedChallenge = { challenge: string; domain?: string; expiresAt: number };
520
+
521
+ const parseAuthParams = (header: string): Record<string, string> => {
522
+ const params: Record<string, string> = {};
523
+ const pattern = /([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*"([^"]*)"/g;
524
+
525
+ let match: RegExpExecArray | null;
526
+
527
+ while ((match = pattern.exec(header))) {
528
+ const key = match[1]?.toLowerCase();
529
+
530
+ if (key && match[2]) params[key] = match[2];
531
+ }
532
+
533
+ return params;
534
+ };
535
+
536
+ /**
537
+ * Parses a `LearnCardDIDAuth` challenge from a 401 response. Challenge/domain
538
+ * parameters are read from the `WWW-Authenticate` header and/or the machine-readable
539
+ * JSON body; when both carry a challenge they must agree. Expired challenges are
540
+ * rejected. Returns `undefined` when the challenge is absent or malformed.
541
+ */
542
+ const parseDidAuthChallenge = (
543
+ response: Response,
544
+ bodyText: string,
545
+ expectedDomain: string
546
+ ): ParsedChallenge | undefined => {
547
+ const header = response.headers.get('www-authenticate') ?? '';
548
+
549
+ const scheme = header.trim().split(/\s/, 1)[0]?.toLowerCase();
550
+
551
+ if (scheme !== DID_AUTH_SCHEME) return undefined;
552
+
553
+ const headerParams = parseAuthParams(header);
554
+
555
+ let challenge = headerParams.challenge;
556
+ let domain = headerParams.domain;
557
+ let expiresAt = Date.now() + HEADER_CHALLENGE_TTL_MS;
558
+
559
+ if (bodyText) {
560
+ let body: unknown;
561
+
562
+ try {
563
+ body = JSON.parse(bodyText);
564
+ } catch {
565
+ return undefined; // machine-readable challenge body must be JSON
566
+ }
567
+
568
+ const parsed = CredentialRefreshChallengeValidator.safeParse(body);
569
+
570
+ if (!parsed.success) return undefined;
571
+
572
+ if (challenge && parsed.data.challenge !== challenge) return undefined; // ambiguous
573
+ if (domain && parsed.data.domain && parsed.data.domain !== domain) return undefined;
574
+
575
+ challenge = parsed.data.challenge;
576
+ domain = parsed.data.domain ?? domain;
577
+
578
+ expiresAt = Date.parse(parsed.data.expiresAt);
579
+
580
+ if (Number.isNaN(expiresAt) || expiresAt <= Date.now()) return undefined;
581
+ }
582
+
583
+ if (!challenge || (domain && domain !== expectedDomain)) return undefined;
584
+
585
+ return { challenge, domain: expectedDomain, expiresAt };
586
+ };
587
+
588
+ // --- Response decoding ------------------------------------------------------------
589
+
590
+ type DecodedCandidate = { credential: VC; etag?: string; managedVersion?: number };
591
+
592
+ const asManagedVersion = (value: unknown): number | undefined =>
593
+ typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
594
+
595
+ type InvokeBag = Record<string, ((...args: unknown[]) => Promise<unknown>) | undefined>;
596
+
597
+ /**
598
+ * Decodes a refresh response body into a candidate credential. Accepts a bare signed
599
+ * VC, a `{format:'vc'}` envelope, or a `{format:'jwe'}` envelope / bare JWE. Managed
600
+ * JWE payloads are decrypted only when the optional `decryptDagJwe` capability is
601
+ * present on the wallet.
602
+ */
603
+ const decodeCandidate = async (
604
+ learnCard: VCImplicitLearnCard,
605
+ json: unknown,
606
+ response: Response
607
+ ): Promise<DecodedCandidate | CredentialRefreshResult> => {
608
+ const headerEtag = response.headers.get('etag') ?? undefined;
609
+
610
+ // Tagged union: VCDM 2.0 credentials may legitimately carry a `status` field, so a
611
+ // bare `VC | CredentialRefreshResult` union cannot be discriminated by `'status' in`.
612
+ const decryptJwe = async (
613
+ jwe: JWE
614
+ ): Promise<{ credential: VC } | { failure: CredentialRefreshResult }> => {
615
+ const decrypt = (learnCard.invoke as unknown as InvokeBag).decryptDagJwe;
616
+
617
+ if (typeof decrypt !== 'function') return { failure: failed('UNSUPPORTED_SERVICE', false) };
618
+
619
+ let decrypted: unknown;
620
+
621
+ try {
622
+ decrypted = await decrypt(jwe);
623
+ } catch {
624
+ return { failure: malformedResponse() };
625
+ }
626
+
627
+ const parsed = VCValidator.safeParse(decrypted);
628
+
629
+ if (!parsed.success) return { failure: malformedResponse() };
630
+
631
+ return { credential: parsed.data as VC };
632
+ };
633
+
634
+ const envelopeEtag = (value: unknown) => (typeof value === 'string' ? value : headerEtag);
635
+
636
+ if (json !== null && typeof json === 'object' && 'format' in json) {
637
+ const envelope = json as Record<string, unknown>;
638
+
639
+ if (envelope.format === 'jwe') {
640
+ const jwe = JWEValidator.safeParse(envelope.jwe);
641
+
642
+ if (!jwe.success) return malformedResponse();
643
+
644
+ const decrypted = await decryptJwe(jwe.data);
645
+
646
+ if ('failure' in decrypted) return decrypted.failure;
647
+
648
+ return {
649
+ credential: decrypted.credential,
650
+ etag: envelopeEtag(envelope.etag),
651
+ managedVersion: asManagedVersion(envelope.version),
652
+ };
653
+ }
654
+
655
+ if (envelope.format === 'vc') {
656
+ const parsed = VCValidator.safeParse(envelope.credential);
657
+
658
+ if (!parsed.success) return malformedResponse();
659
+
660
+ return {
661
+ credential: parsed.data as VC,
662
+ etag: envelopeEtag(envelope.etag),
663
+ managedVersion: asManagedVersion(envelope.version),
664
+ };
665
+ }
666
+
667
+ return malformedResponse();
668
+ }
669
+
670
+ const bareVc = VCValidator.safeParse(json);
671
+
672
+ if (bareVc.success) return { credential: bareVc.data as VC, etag: headerEtag };
673
+
674
+ const bareJwe = JWEValidator.safeParse(json);
675
+
676
+ if (bareJwe.success) {
677
+ const decrypted = await decryptJwe(bareJwe.data);
678
+
679
+ if ('failure' in decrypted) return decrypted.failure;
680
+
681
+ return { credential: decrypted.credential, etag: headerEtag };
682
+ }
683
+
684
+ return malformedResponse();
685
+ };
686
+
687
+ // --- Stability and freshness --------------------------------------------------------
688
+
689
+ const getHolderIds = (vc: VC): string[] => {
690
+ const subject = vc.credentialSubject as { id?: unknown } | Array<{ id?: unknown }>;
691
+ const subjects = Array.isArray(subject) ? subject : [subject];
692
+
693
+ return subjects.map(entry => (typeof entry?.id === 'string' ? entry.id : '')).sort();
694
+ };
695
+
696
+ const holdersEqual = (first: VC, second: VC): boolean => {
697
+ const firstIds = getHolderIds(first);
698
+ const secondIds = getHolderIds(second);
699
+
700
+ return (
701
+ firstIds.length === secondIds.length &&
702
+ firstIds.every((id, index) => id === secondIds[index])
703
+ );
704
+ };
705
+
706
+ /**
707
+ * Holder-side refresh primitive. See the module docblock for the safety contract.
708
+ *
709
+ * Never includes the current credential or its claims in request headers or logs.
710
+ */
711
+ export const refreshCredential = (_initLearnCard: VCDependentLearnCard) => {
712
+ /**
713
+ * Challenges already consumed by this wallet instance. A managed endpoint issues
714
+ * single-use challenges; a repeated challenge value indicates a replay and is
715
+ * rejected without signing.
716
+ */
717
+ // Retain live entries until expiry; fail closed at capacity instead of evicting
718
+ // replay protection. Header-only challenges have a five-minute retention window.
719
+ const usedChallenges = new Map<string, number>();
720
+
721
+ return async (
722
+ _learnCard: VCImplicitLearnCard,
723
+ credential: VC,
724
+ options: RefreshCredentialOptions = {}
725
+ ): Promise<CredentialRefreshResult> => {
726
+ const resolved: ResolvedRefreshOptions = {
727
+ etag: options.etag,
728
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
729
+ maxRedirects: options.maxRedirects ?? DEFAULT_MAX_REDIRECTS,
730
+ maxResponseBytes: options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
731
+ allowInsecureHttp: options.allowInsecureHttp ?? false,
732
+ allowPrivateAddresses: options.allowPrivateAddresses ?? false,
733
+ resolveHost:
734
+ options.resolveHost ?? (isNodeRuntime ? resolveHostnameWithNodeDns : undefined),
735
+ };
736
+
737
+ const service = getSupportedRefreshService(credential);
738
+
739
+ if (!service) return { status: 'unsupported' };
740
+
741
+ // Verify the currently held credential before contacting the endpoint.
742
+ try {
743
+ const check = await _learnCard.invoke.verifyCredential(credential);
744
+
745
+ if (!proofVerified(check)) return invalidProof();
746
+ } catch {
747
+ return invalidProof();
748
+ }
749
+
750
+ // Managed publication requires a stable ID. Standard credentials may omit it.
751
+ if (
752
+ service.type === 'LearnCardCredentialRefresh2026' &&
753
+ (typeof credential.id !== 'string' || credential.id.length === 0)
754
+ ) {
755
+ return failed('ID_MISMATCH', false);
756
+ }
757
+
758
+ const validated = await validateRefreshUrl(service.id, resolved);
759
+
760
+ if ('result' in validated) return validated.result;
761
+
762
+ const initial = await fetchWithGuards(validated, resolved);
763
+
764
+ if ('result' in initial) return initial.result;
765
+
766
+ let guardedResponse = initial;
767
+ let response = guardedResponse.response;
768
+
769
+ // Managed endpoints authenticate with a single DID-auth retry.
770
+ if (response.status === 401 && service.type === 'LearnCardCredentialRefresh2026') {
771
+ // Only the origin signed into the credential may request a holder proof.
772
+ if (guardedResponse.endpoint.url.origin !== validated.url.origin) {
773
+ await discardGuardedResponse(guardedResponse);
774
+ return unauthorized();
775
+ }
776
+ const challengeRead = await readBodyWithLimit(
777
+ response,
778
+ resolved.maxResponseBytes,
779
+ guardedResponse.signal
780
+ );
781
+
782
+ guardedResponse.finish();
783
+
784
+ if ('result' in challengeRead) {
785
+ return challengeRead.reason === 'timeout' ? challengeRead.result : unauthorized();
786
+ }
787
+
788
+ const expectedDomain = guardedResponse.endpoint.url.host;
789
+ const challenge = parseDidAuthChallenge(response, challengeRead.body, expectedDomain);
790
+
791
+ if (!challenge) return unauthorized();
792
+
793
+ const challengeKey = `${guardedResponse.endpoint.url.origin}|${challenge.challenge}`;
794
+
795
+ const now = Date.now();
796
+ for (const [key, expiresAt] of usedChallenges) {
797
+ if (expiresAt <= now) usedChallenges.delete(key);
798
+ }
799
+ if (usedChallenges.has(challengeKey)) return unauthorized();
800
+ if (usedChallenges.size >= MAX_TRACKED_CHALLENGES) return unauthorized();
801
+ usedChallenges.set(challengeKey, challenge.expiresAt);
802
+
803
+ let vp: VP | string;
804
+
805
+ try {
806
+ vp = await _learnCard.invoke.getDidAuthVp({
807
+ proofFormat: 'jwt',
808
+ challenge: challenge.challenge,
809
+ domain: expectedDomain,
810
+ });
811
+ } catch {
812
+ return unauthorized();
813
+ }
814
+
815
+ const retry = await fetchWithGuards(
816
+ guardedResponse.endpoint,
817
+ resolved,
818
+ `Bearer ${typeof vp === 'string' ? vp : JSON.stringify(vp)}`
819
+ );
820
+
821
+ if ('result' in retry) return retry.result;
822
+
823
+ guardedResponse = retry;
824
+ response = guardedResponse.response;
825
+ }
826
+
827
+ if (response.status === 304) {
828
+ await discardGuardedResponse(guardedResponse);
829
+
830
+ return {
831
+ status: 'unchanged',
832
+ checkedAt: new Date().toISOString(),
833
+ etag: response.headers.get('etag') ?? resolved.etag,
834
+ };
835
+ }
836
+
837
+ if (response.status === 410) {
838
+ await discardGuardedResponse(guardedResponse);
839
+ return failed('REVOKED', false);
840
+ }
841
+
842
+ if (response.status === 401 || response.status === 403) {
843
+ await discardGuardedResponse(guardedResponse);
844
+ return unauthorized();
845
+ }
846
+
847
+ if (response.status < 200 || response.status > 299) {
848
+ await discardGuardedResponse(guardedResponse);
849
+ return failed('UNAVAILABLE', response.status >= 500);
850
+ }
851
+
852
+ if (!isAcceptedMediaType(response.headers.get('content-type'))) {
853
+ await discardGuardedResponse(guardedResponse);
854
+ return malformedResponse();
855
+ }
856
+
857
+ const bodyRead = await readBodyWithLimit(
858
+ response,
859
+ resolved.maxResponseBytes,
860
+ guardedResponse.signal
861
+ );
862
+
863
+ guardedResponse.finish();
864
+
865
+ if ('result' in bodyRead) return bodyRead.result;
866
+
867
+ let json: unknown;
868
+
869
+ try {
870
+ json = JSON.parse(bodyRead.body);
871
+ } catch {
872
+ return malformedResponse();
873
+ }
874
+
875
+ // The standard protocol returns a bare credential, never a LearnCard envelope.
876
+ if (service.type === '1EdTechCredentialRefresh' && !VCValidator.safeParse(json).success) {
877
+ return malformedResponse();
878
+ }
879
+ const decoded = await decodeCandidate(_learnCard, json, response);
880
+
881
+ if ('status' in decoded) return decoded;
882
+
883
+ const candidate = decoded.credential;
884
+
885
+ // Verify the replacement proof before any stability/freshness checks.
886
+ try {
887
+ const check = await _learnCard.invoke.verifyCredential(candidate);
888
+
889
+ if (!proofVerified(check)) return invalidProof();
890
+ } catch {
891
+ return invalidProof();
892
+ }
893
+
894
+ // Identity stability: same credential ID, normalized issuer, and holder. Holder
895
+ // changes are surfaced as ID_MISMATCH because the holder identity is part of the
896
+ // credential's identity for refresh purposes.
897
+ if (candidate.id !== credential.id) {
898
+ return failed('ID_MISMATCH', false);
899
+ }
900
+
901
+ if (getCredentialIssuerId(candidate) !== getCredentialIssuerId(credential)) {
902
+ return failed('ISSUER_MISMATCH', false);
903
+ }
904
+
905
+ if (!holdersEqual(credential, candidate)) return failed('ID_MISMATCH', false);
906
+
907
+ // Freshness: reject a strictly older effective timestamp.
908
+ const currentTime = getCredentialEffectiveTime(credential);
909
+ const candidateTime = getCredentialEffectiveTime(candidate);
910
+
911
+ if (
912
+ currentTime !== undefined &&
913
+ candidateTime !== undefined &&
914
+ candidateTime < currentTime
915
+ ) {
916
+ return failed('ROLLBACK', false);
917
+ }
918
+
919
+ // Proof-insensitive canonical comparison distinguishes updated from unchanged.
920
+ if (credentialContentsEqual(credential, candidate)) {
921
+ return { status: 'unchanged', checkedAt: new Date().toISOString(), etag: decoded.etag };
922
+ }
923
+
924
+ return {
925
+ status: 'updated',
926
+ credential: candidate,
927
+ ...(decoded.etag ? { etag: decoded.etag } : {}),
928
+ ...(decoded.managedVersion ? { managedVersion: decoded.managedVersion } : {}),
929
+ };
930
+ };
931
+ };