@learncard/vc-plugin 1.5.12 → 1.6.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/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/refreshCredential.d.ts +13 -0
- package/dist/refreshCredential.d.ts.map +1 -0
- package/dist/refreshCredential.fetch.d.ts +13 -0
- package/dist/refreshCredential.fetch.d.ts.map +1 -0
- package/dist/refreshCredential.fetch.test.d.ts +2 -0
- package/dist/refreshCredential.fetch.test.d.ts.map +1 -0
- package/dist/refreshCredential.test.d.ts +2 -0
- package/dist/refreshCredential.test.d.ts.map +1 -0
- package/dist/types.d.ts +32 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vc-plugin.cjs.development.cjs +626 -1
- package/dist/vc-plugin.cjs.development.cjs.map +4 -4
- package/dist/vc-plugin.cjs.production.min.cjs +1 -1
- package/dist/vc-plugin.cjs.production.min.cjs.map +4 -4
- package/dist/vc-plugin.esm.js +635 -1
- package/dist/vc-plugin.esm.js.map +4 -4
- package/dist/vc.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/index.ts +1 -0
- package/src/refreshCredential.fetch.test.ts +110 -0
- package/src/refreshCredential.fetch.ts +159 -0
- package/src/refreshCredential.test.ts +1144 -0
- package/src/refreshCredential.ts +931 -0
- package/src/types.ts +36 -0
- package/src/vc.ts +2 -0
|
@@ -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
|
+
};
|