@learncard/holder-continuity 0.1.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.
@@ -0,0 +1,743 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/exportBundle.ts
5
+ import { lookup } from "node:dns/promises";
6
+ import { mkdir, writeFile } from "node:fs/promises";
7
+ import { isIP } from "node:net";
8
+ import { dirname } from "node:path";
9
+ import JSZip from "jszip";
10
+ import { shareToRecoveryPhrase, splitPrivateKey } from "@learncard/sss-key-manager";
11
+
12
+ // src/crypto.ts
13
+ import { createHash } from "node:crypto";
14
+ import { decryptWithPassword, encryptWithPassword } from "@learncard/sss-key-manager";
15
+ var sha256Hex = /* @__PURE__ */ __name((bytes) => createHash("sha256").update(bytes).digest("hex"), "sha256Hex");
16
+ var stableStringify = /* @__PURE__ */ __name((value) => {
17
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
18
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
19
+ const object = value;
20
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
21
+ }, "stableStringify");
22
+ var encodePayload = /* @__PURE__ */ __name(async (plaintext, options) => {
23
+ if (!options.encrypt) return { stored: plaintext, encrypted: false };
24
+ if (!options.password) throw new Error("A password is required for encrypted LearnCard exports");
25
+ return {
26
+ stored: JSON.stringify(await encryptWithPassword(plaintext, options.password), null, 2),
27
+ encrypted: true
28
+ };
29
+ }, "encodePayload");
30
+ var decodePayload = /* @__PURE__ */ __name(async (stored, options) => {
31
+ if (!options.encrypted) return stored;
32
+ if (!options.password) throw new Error("A password is required to decrypt this LearnCard export");
33
+ const envelope = JSON.parse(stored);
34
+ return decryptWithPassword(
35
+ envelope.ciphertext,
36
+ envelope.iv,
37
+ envelope.salt,
38
+ options.password,
39
+ envelope.kdfParams
40
+ );
41
+ }, "decodePayload");
42
+
43
+ // src/manifest.ts
44
+ var SPEC_VERSION = "1.0.0";
45
+ var BUNDLE_SPEC_MD = `# LearnCard Holder Continuity Bundle v1.0.0
46
+
47
+ A LearnCard holder continuity bundle is a ZIP file with readable metadata and encrypted holder payloads.
48
+
49
+ ## Container
50
+
51
+ Required readable entries:
52
+
53
+ - \`manifest.json\` \u2014 inventory, hashes, warnings, and encryption metadata.
54
+ - \`README.md\` \u2014 human-readable recovery notes.
55
+ - \`BUNDLE_SPEC.md\` \u2014 this format description.
56
+
57
+ Sensitive entries use JSON encryption envelopes produced by \`@learncard/sss-key-manager\` \`encryptWithPassword\`: Argon2id key derivation and AES-GCM authenticated encryption. The ZIP itself is not password encrypted.
58
+
59
+ ## Security model
60
+
61
+ This bundle exports the wallet's full raw private-key seed at \`keys/private-key-seed.txt.enc\`. LearnCard's live wallet protects the key with 2-of-4 Shamir Secret Sharing, where no single share can reconstruct it. The bundle does NOT preserve that threshold: the exported seed alone is sufficient to take full control of the identity, and the bundle password is the only barrier protecting it. \`keys/recovery-phrase.txt.enc\` is derived from the current recovery share for reference and is not independently sufficient to recover the key. Treat the bundle like a password-vault backup, use a strong unique password, and rotate the wallet if the bundle is exposed.
62
+
63
+ ## Paths
64
+
65
+ - \`keys/recovery-phrase.txt.enc\`
66
+ - \`keys/private-key-seed.txt.enc\`
67
+ - \`keys/jwks.json.enc\`
68
+ - \`keys/did-document.json\`
69
+ - \`credentials/<sha256>.json.enc\`
70
+ - \`presentations/<sha256>.json.enc\`
71
+ - \`index-records/<sha256>.json.enc\`
72
+ - \`consent-records/<sha256>.json.enc\`
73
+ - \`status-cache/<sha256>.json.enc\`
74
+
75
+ Debug exports MAY use plaintext payloads by setting \`encrypt: false\`; production exports MUST encrypt sensitive payloads.
76
+
77
+ Status-list snapshot fetching is HTTPS-only and rejects private, loopback, link-local, and single-label hosts. Exporters SHOULD keep the default timeout and response-size caps unless they are running in a trusted local environment.
78
+
79
+ ## Manifest hashing
80
+
81
+ Each \`contents[]\` entry contains the SHA-256 hash of the bytes stored at \`path\`. \`payloadSha256\` is SHA-256 over a deterministic JSON serialization of \`contents[]\` with entries sorted by path.
82
+
83
+ Each credential or presentation entry MAY reference an encrypted \`index-record\` companion entry via \`indexRecordRef\`; the readable manifest does not embed the original index record JSON.
84
+
85
+ ## Restore vs import
86
+
87
+ \`restoreLearnCardFromBundle(...)\` decrypts \`keys/private-key-seed.txt.enc\` and passes that seed to \`initLearnCard(...)\`. It recreates the original wallet identity; it does not upload payloads or recreate index records.
88
+
89
+ \`importLearnCardBundle(...)\` decrypts credential and presentation payloads, uploads them to the target wallet's LearnCloud store, and recreates index records from the encrypted \`index-record\` companions.
90
+
91
+ Import writes bundle contents into the target wallet. A bundle author who knows the password can include arbitrary credentials, presentations, and index metadata. Use \`verifyBeforeImport: true\` to verify VC/VP signatures before upload when the target wallet exposes \`invoke.verifyCredential\` and \`invoke.verifyPresentation\`.
92
+
93
+ ## Size limits
94
+
95
+ Readers enforce default compressed-bundle, per-entry, and JSON parse limits to avoid accidentally processing oversized ZIP or JSON payloads. Callers can override these with \`maxBundleBytes\`, \`maxEntryBytes\`, and \`maxJsonBytes\` for trusted local workflows.
96
+
97
+ ## Import expectations
98
+
99
+ Importers MUST verify the stored bytes against each entry hash before trusting decrypted content. Importers SHOULD verify issuer signatures before upload and preserve issuer-signed credential and presentation payloads exactly.
100
+ `;
101
+ var BUNDLE_README_MD = `# LearnCard Holder Continuity Export
102
+
103
+ This archive contains a point-in-time holder export from a LearnCard wallet.
104
+
105
+ Keep the password separately. Without it, encrypted credentials, key material, consent records, and status-list snapshots cannot be recovered.
106
+
107
+ This archive contains your full private-key seed (encrypted). Anyone with both this file and its password can take complete control of your wallet identity, so store it like a password-vault backup and rotate your wallet if it is exposed.
108
+
109
+ The readable manifest lists every payload and its SHA-256 hash. Third-party wallets can import individual W3C Verifiable Credentials or Verifiable Presentations from the decrypted JSON files even when they do not support the LearnCard ZIP bundle directly.
110
+ `;
111
+ var sortContents = /* @__PURE__ */ __name((contents) => [...contents].sort((a, b) => a.path.localeCompare(b.path)), "sortContents");
112
+ var normalizeContents = /* @__PURE__ */ __name((contents) => JSON.parse(JSON.stringify(sortContents(contents))), "normalizeContents");
113
+ var computePayloadSha256 = /* @__PURE__ */ __name((contents) => sha256Hex(stableStringify(normalizeContents(contents))), "computePayloadSha256");
114
+ var finalizeManifest = /* @__PURE__ */ __name((manifest) => ({
115
+ ...manifest,
116
+ contents: normalizeContents(manifest.contents),
117
+ payloadSha256: computePayloadSha256(manifest.contents)
118
+ }), "finalizeManifest");
119
+ var assertValidManifest = /* @__PURE__ */ __name((manifest) => {
120
+ if (manifest.specVersion !== SPEC_VERSION) {
121
+ throw new Error(`Unsupported LearnCard bundle version: ${manifest.specVersion}`);
122
+ }
123
+ const expected = computePayloadSha256(manifest.contents);
124
+ if (manifest.payloadSha256 !== expected)
125
+ throw new Error("LearnCard bundle manifest hash mismatch");
126
+ }, "assertValidManifest");
127
+
128
+ // src/exportBundle.ts
129
+ var ZIP_DATE = /* @__PURE__ */ new Date("2024-01-01T00:00:00.000Z");
130
+ var DEFAULT_STATUS_LIST_FETCH_TIMEOUT_MS = 5e3;
131
+ var DEFAULT_MAX_STATUS_LIST_BYTES = 5 * 1024 * 1024;
132
+ var stripIpv6Brackets = /* @__PURE__ */ __name((hostname) => hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname, "stripIpv6Brackets");
133
+ var redactMalformedUrl = /* @__PURE__ */ __name((value) => {
134
+ const queryIndex = value.indexOf("?");
135
+ const ampIndex = value.indexOf("&");
136
+ if (queryIndex === -1 && ampIndex === -1) return value;
137
+ const redactionIndex = queryIndex === -1 ? ampIndex : ampIndex === -1 ? queryIndex : Math.min(queryIndex, ampIndex);
138
+ return `${value.slice(0, redactionIndex)}?[redacted]`;
139
+ }, "redactMalformedUrl");
140
+ var redactUrl = /* @__PURE__ */ __name((value) => {
141
+ try {
142
+ const url = new URL(value);
143
+ const hostname = stripIpv6Brackets(url.hostname.toLowerCase());
144
+ const shouldRedactHost = !hostname.includes(".") || hostname === "localhost" || hostname.endsWith(".localhost") || Boolean(isIP(hostname) && isPrivateAddress(hostname));
145
+ const host = shouldRedactHost ? "[redacted-host]" : url.host;
146
+ return `${url.protocol}//${host}${url.pathname}${url.search ? "?[redacted]" : ""}`;
147
+ } catch {
148
+ return redactMalformedUrl(value);
149
+ }
150
+ }, "redactUrl");
151
+ var urlEndDelimiters = /* @__PURE__ */ new Set([" ", "\n", "\r", " ", "'", '"', "<", ">"]);
152
+ var findNextUrlStart = /* @__PURE__ */ __name((value, fromIndex) => {
153
+ const httpIndex = value.indexOf("http://", fromIndex);
154
+ const httpsIndex = value.indexOf("https://", fromIndex);
155
+ if (httpIndex === -1) return httpsIndex;
156
+ if (httpsIndex === -1) return httpIndex;
157
+ return Math.min(httpIndex, httpsIndex);
158
+ }, "findNextUrlStart");
159
+ var findUrlEnd = /* @__PURE__ */ __name((value, fromIndex) => {
160
+ let index = fromIndex;
161
+ while (index < value.length && !urlEndDelimiters.has(value[index])) index += 1;
162
+ return index;
163
+ }, "findUrlEnd");
164
+ var redactText = /* @__PURE__ */ __name((value) => {
165
+ let redacted = "";
166
+ let index = 0;
167
+ while (index < value.length) {
168
+ const urlStart = findNextUrlStart(value, index);
169
+ if (urlStart === -1) {
170
+ redacted += value.slice(index);
171
+ break;
172
+ }
173
+ const urlEnd = findUrlEnd(value, urlStart);
174
+ redacted += value.slice(index, urlStart);
175
+ redacted += redactUrl(value.slice(urlStart, urlEnd));
176
+ index = urlEnd;
177
+ }
178
+ return redacted;
179
+ }, "redactText");
180
+ var safeMessage = /* @__PURE__ */ __name((error) => redactText(error instanceof Error ? error.message : String(error)), "safeMessage");
181
+ var isPrivateIPv4 = /* @__PURE__ */ __name((address) => {
182
+ const parts = address.split(".").map((part) => Number(part));
183
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
184
+ return true;
185
+ const [a, b] = parts;
186
+ if (a === 0 || a === 10 || a === 127 || a >= 224) return true;
187
+ if (a === 100 && b >= 64 && b <= 127) return true;
188
+ if (a === 169 && b === 254) return true;
189
+ if (a === 172 && b >= 16 && b <= 31) return true;
190
+ if (a === 192 && b === 168) return true;
191
+ if (a === 198 && (b === 18 || b === 19)) return true;
192
+ return false;
193
+ }, "isPrivateIPv4");
194
+ var isPrivateIPv6 = /* @__PURE__ */ __name((address) => {
195
+ const normalized = address.toLowerCase();
196
+ if (normalized === "::" || normalized === "::1") return true;
197
+ if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
198
+ if (normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb"))
199
+ return true;
200
+ const ipv4Mapped = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
201
+ return ipv4Mapped ? isPrivateIPv4(ipv4Mapped[1]) : false;
202
+ }, "isPrivateIPv6");
203
+ var isPrivateAddress = /* @__PURE__ */ __name((address) => {
204
+ const version = isIP(address);
205
+ if (version === 4) return isPrivateIPv4(address);
206
+ if (version === 6) return isPrivateIPv6(address);
207
+ return true;
208
+ }, "isPrivateAddress");
209
+ var assertPublicHttpsUrl = /* @__PURE__ */ __name(async (uri) => {
210
+ const url = new URL(uri);
211
+ const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
212
+ if (url.protocol !== "https:") throw new Error("status-list URL must use https");
213
+ if (!hostname.includes(".") || hostname === "localhost" || hostname.endsWith(".localhost")) {
214
+ throw new Error("status-list URL host must be public");
215
+ }
216
+ if (isIP(hostname)) {
217
+ if (isPrivateAddress(hostname)) throw new Error("status-list URL host must be public");
218
+ return url;
219
+ }
220
+ const addresses = await lookup(hostname, { all: true, verbatim: true });
221
+ if (addresses.length === 0 || addresses.some((address) => isPrivateAddress(address.address))) {
222
+ throw new Error("status-list URL host must resolve to public addresses");
223
+ }
224
+ return url;
225
+ }, "assertPublicHttpsUrl");
226
+ var readResponseText = /* @__PURE__ */ __name(async (response, maxBytes) => {
227
+ const contentLength = Number(response.headers.get("content-length"));
228
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
229
+ throw new Error(`status-list response exceeds ${maxBytes} bytes`);
230
+ }
231
+ if (!response.body) {
232
+ const text = await response.text();
233
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
234
+ throw new Error(`status-list response exceeds ${maxBytes} bytes`);
235
+ }
236
+ return text;
237
+ }
238
+ const reader = response.body.getReader();
239
+ const chunks = [];
240
+ let total = 0;
241
+ while (true) {
242
+ const { done, value } = await reader.read();
243
+ if (done) break;
244
+ total += value.byteLength;
245
+ if (total > maxBytes) {
246
+ await reader.cancel();
247
+ throw new Error(`status-list response exceeds ${maxBytes} bytes`);
248
+ }
249
+ chunks.push(Buffer.from(value));
250
+ }
251
+ return Buffer.concat(chunks).toString("utf8");
252
+ }, "readResponseText");
253
+ var fetchJsonWithTimeout = /* @__PURE__ */ __name(async (url, timeoutMs, maxBytes) => {
254
+ const controller = new AbortController();
255
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
256
+ try {
257
+ const response = await fetch(url, { signal: controller.signal });
258
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
259
+ return JSON.parse(await readResponseText(response, maxBytes));
260
+ } finally {
261
+ clearTimeout(timeout);
262
+ }
263
+ }, "fetchJsonWithTimeout");
264
+ var isRecord = /* @__PURE__ */ __name((value) => Boolean(value) && typeof value === "object" && !Array.isArray(value), "isRecord");
265
+ var stringArrayIncludes = /* @__PURE__ */ __name((value, expected) => Array.isArray(value) && value.some((item) => item === expected), "stringArrayIncludes");
266
+ var json = /* @__PURE__ */ __name((value) => `${stableStringify(value)}
267
+ `, "json");
268
+ var classifyPayload = /* @__PURE__ */ __name((payload) => {
269
+ if (!isRecord(payload)) return "unknown-json";
270
+ if (stringArrayIncludes(payload.type, "VerifiablePresentation") || payload.type === "VerifiablePresentation") {
271
+ return "presentation";
272
+ }
273
+ if (stringArrayIncludes(payload.type, "VerifiableCredential") || payload.type === "VerifiableCredential") {
274
+ return "credential";
275
+ }
276
+ return "unknown-json";
277
+ }, "classifyPayload");
278
+ var pathForType = /* @__PURE__ */ __name((type, digest, encrypted) => {
279
+ const suffix = encrypted ? ".enc" : "";
280
+ if (type === "presentation") return `presentations/${digest}.json${suffix}`;
281
+ if (type === "consent-record") return `consent-records/${digest}.json${suffix}`;
282
+ if (type === "index-record") return `index-records/${digest}.json${suffix}`;
283
+ if (type === "status-cache") return `status-cache/${digest}.json${suffix}`;
284
+ return `credentials/${digest}.json${suffix}`;
285
+ }, "pathForType");
286
+ var getCredentialId = /* @__PURE__ */ __name((payload) => isRecord(payload) && typeof payload.id === "string" ? payload.id : void 0, "getCredentialId");
287
+ var addZipText = /* @__PURE__ */ __name((zip, path, content) => {
288
+ zip.file(path, content, { date: ZIP_DATE });
289
+ }, "addZipText");
290
+ var collectDids = /* @__PURE__ */ __name((wallet, warnings) => {
291
+ const dids = {};
292
+ for (const method of [void 0, "key", "pkh:sol", "tz", "pkh:tz"]) {
293
+ try {
294
+ dids[method ?? "default"] = wallet.id.did(method);
295
+ } catch (error) {
296
+ warnings.push(
297
+ `Could not derive DID method ${method ?? "default"}: ${safeMessage(error)}`
298
+ );
299
+ }
300
+ }
301
+ return dids;
302
+ }, "collectDids");
303
+ var collectDidDocument = /* @__PURE__ */ __name(async (wallet, primaryDid, dids, warnings) => {
304
+ if (!wallet.invoke.resolveDid) return { primaryDid, dids };
305
+ try {
306
+ return { primaryDid, dids, primaryDidDocument: await wallet.invoke.resolveDid(primaryDid) };
307
+ } catch (error) {
308
+ warnings.push(`Could not resolve primary DID document: ${safeMessage(error)}`);
309
+ return { primaryDid, dids };
310
+ }
311
+ }, "collectDidDocument");
312
+ var collectKeyPayloads = /* @__PURE__ */ __name(async (wallet, warnings) => {
313
+ const payloads = [];
314
+ if (wallet.invoke.getKey) {
315
+ try {
316
+ const seed = wallet.invoke.getKey();
317
+ const shares = await splitPrivateKey(seed);
318
+ payloads.push({
319
+ path: "keys/private-key-seed.txt",
320
+ type: "key-private-seed",
321
+ content: `${seed}
322
+ `,
323
+ encrypted: true
324
+ });
325
+ payloads.push({
326
+ path: "keys/recovery-phrase.txt",
327
+ type: "key-recovery-phrase",
328
+ content: `${await shareToRecoveryPhrase(shares.recoveryShare)}
329
+ `,
330
+ encrypted: true
331
+ });
332
+ } catch (error) {
333
+ warnings.push(`Could not export seed or recovery phrase: ${safeMessage(error)}`);
334
+ }
335
+ } else {
336
+ warnings.push(
337
+ "Wallet does not expose invoke.getKey(); private seed and recovery phrase were not exported"
338
+ );
339
+ }
340
+ if (wallet.id.keypair) {
341
+ const jwks = {};
342
+ for (const algorithm of ["ed25519", "secp256k1"]) {
343
+ try {
344
+ jwks[algorithm] = wallet.id.keypair(algorithm);
345
+ } catch (error) {
346
+ warnings.push(`Could not export ${algorithm} JWK: ${safeMessage(error)}`);
347
+ }
348
+ }
349
+ if (Object.keys(jwks).length > 0) {
350
+ payloads.push({
351
+ path: "keys/jwks.json",
352
+ type: "key-jwks",
353
+ content: json(jwks),
354
+ encrypted: true
355
+ });
356
+ }
357
+ } else {
358
+ warnings.push("Wallet does not expose id.keypair(); JWK key export was skipped");
359
+ }
360
+ return payloads;
361
+ }, "collectKeyPayloads");
362
+ var extractStatusListUrls = /* @__PURE__ */ __name((payload) => {
363
+ if (!isRecord(payload)) return [];
364
+ const statuses = Array.isArray(payload.credentialStatus) ? payload.credentialStatus : payload.credentialStatus ? [payload.credentialStatus] : [];
365
+ return statuses.flatMap(
366
+ (status) => isRecord(status) && typeof status.statusListCredential === "string" ? [status.statusListCredential] : []
367
+ );
368
+ }, "extractStatusListUrls");
369
+ var collectStatusLists = /* @__PURE__ */ __name(async (payloads, enabled, warnings, timeoutMs = DEFAULT_STATUS_LIST_FETCH_TIMEOUT_MS, maxBytes = DEFAULT_MAX_STATUS_LIST_BYTES) => {
370
+ if (!enabled) return [];
371
+ const urls = [...new Set(payloads.flatMap(extractStatusListUrls))];
372
+ const results = [];
373
+ for (const uri of urls) {
374
+ try {
375
+ const url = await assertPublicHttpsUrl(uri);
376
+ results.push({ uri, content: await fetchJsonWithTimeout(url, timeoutMs, maxBytes) });
377
+ } catch (error) {
378
+ warnings.push(
379
+ `Could not cache status-list credential ${redactUrl(uri)}: ${safeMessage(error)}`
380
+ );
381
+ }
382
+ }
383
+ return results;
384
+ }, "collectStatusLists");
385
+ var collectConsentRecords = /* @__PURE__ */ __name(async (wallet, warnings) => {
386
+ if (wallet.invoke.getHolderExportMetadata) {
387
+ try {
388
+ const metadata = await wallet.invoke.getHolderExportMetadata();
389
+ if (isRecord(metadata) && Array.isArray(metadata.warnings)) {
390
+ warnings.push(
391
+ ...metadata.warnings.filter((warning) => typeof warning === "string").map((warning) => redactText(warning))
392
+ );
393
+ }
394
+ return isRecord(metadata) && Array.isArray(metadata.consentRecords) ? metadata.consentRecords : [];
395
+ } catch (error) {
396
+ warnings.push(`Could not fetch holder export metadata: ${safeMessage(error)}`);
397
+ }
398
+ }
399
+ if (!wallet.invoke.getConsentedContracts) return [];
400
+ try {
401
+ const response = await wallet.invoke.getConsentedContracts();
402
+ if (!isRecord(response) || !Array.isArray(response.records)) return [];
403
+ return response.records;
404
+ } catch (error) {
405
+ warnings.push(`Could not fetch consented contracts fallback: ${safeMessage(error)}`);
406
+ return [];
407
+ }
408
+ }, "collectConsentRecords");
409
+ var createLearnCardBundle = /* @__PURE__ */ __name(async (wallet, options = {}) => {
410
+ const encrypt = options.encrypt ?? true;
411
+ if (encrypt && !options.password)
412
+ throw new Error("A password is required for LearnCard export");
413
+ const warnings = [];
414
+ const zip = new JSZip();
415
+ const contents = [];
416
+ const primaryDid = wallet.id.did();
417
+ const dids = collectDids(wallet, warnings);
418
+ const didDocument = await collectDidDocument(wallet, primaryDid, dids, warnings);
419
+ const credentialPayloads = [];
420
+ const addStoredEntry = /* @__PURE__ */ __name(async (entry) => {
421
+ const encoded = await encodePayload(entry.content, {
422
+ encrypt: entry.encrypted && encrypt,
423
+ password: options.password
424
+ });
425
+ const path = encoded.encrypted && !entry.path.endsWith(".enc") ? `${entry.path}.enc` : entry.path;
426
+ addZipText(zip, path, encoded.stored);
427
+ contents.push({
428
+ id: entry.id,
429
+ type: entry.type,
430
+ path,
431
+ mediaType: entry.mediaType ?? "application/json",
432
+ sha256: sha256Hex(encoded.stored),
433
+ encrypted: encoded.encrypted,
434
+ sourceUri: entry.sourceUri,
435
+ credentialId: entry.credentialId,
436
+ indexRecordRef: entry.indexRecordRef,
437
+ warnings: entry.warnings
438
+ });
439
+ }, "addStoredEntry");
440
+ addZipText(zip, "README.md", BUNDLE_README_MD);
441
+ addZipText(zip, "BUNDLE_SPEC.md", BUNDLE_SPEC_MD);
442
+ await addStoredEntry({
443
+ id: "did-document",
444
+ type: "did-document",
445
+ path: "keys/did-document.json",
446
+ content: json(didDocument),
447
+ encrypted: false
448
+ });
449
+ for (const payload of await collectKeyPayloads(wallet, warnings)) {
450
+ await addStoredEntry({ ...payload, id: payload.path, mediaType: "text/plain" });
451
+ }
452
+ const records = await wallet.index.LearnCloud.get();
453
+ for (const [recordIndex, record] of records.entries()) {
454
+ const entryWarnings = [];
455
+ const digest = sha256Hex(
456
+ stableStringify({ recordIndex, id: record.id ?? null, uri: record.uri ?? null })
457
+ );
458
+ try {
459
+ const resolved = await wallet.read.get(record.uri);
460
+ if (!resolved) {
461
+ warnings.push(`Could not resolve wallet index URI ${redactUrl(record.uri)}`);
462
+ continue;
463
+ }
464
+ credentialPayloads.push(resolved);
465
+ const type = classifyPayload(resolved);
466
+ const credentialId = getCredentialId(resolved);
467
+ if (type === "unknown-json") entryWarnings.push("Payload is not a recognized VC or VP");
468
+ const indexRecordId = `urn:sha256:${digest}:index-record`;
469
+ await addStoredEntry({
470
+ id: indexRecordId,
471
+ type: "index-record",
472
+ path: pathForType("index-record", digest, encrypt),
473
+ content: json(record),
474
+ encrypted: true,
475
+ sourceUri: record.uri,
476
+ credentialId
477
+ });
478
+ await addStoredEntry({
479
+ id: `urn:sha256:${digest}`,
480
+ type,
481
+ path: pathForType(type, digest, encrypt),
482
+ content: json(resolved),
483
+ encrypted: true,
484
+ sourceUri: record.uri,
485
+ credentialId,
486
+ indexRecordRef: indexRecordId,
487
+ warnings: entryWarnings.length > 0 ? entryWarnings : void 0
488
+ });
489
+ } catch (error) {
490
+ warnings.push(
491
+ `Could not export wallet index URI ${redactUrl(record.uri)}: ${safeMessage(error)}`
492
+ );
493
+ }
494
+ }
495
+ const consentRecords = await collectConsentRecords(wallet, warnings);
496
+ for (const consentRecord of consentRecords) {
497
+ const digest = sha256Hex(stableStringify(consentRecord));
498
+ await addStoredEntry({
499
+ id: `urn:sha256:${digest}`,
500
+ type: "consent-record",
501
+ path: pathForType("consent-record", digest, encrypt),
502
+ content: json(consentRecord),
503
+ encrypted: true
504
+ });
505
+ }
506
+ for (const statusList of await collectStatusLists(
507
+ credentialPayloads,
508
+ options.fetchStatusLists ?? true,
509
+ warnings,
510
+ options.statusListFetchTimeoutMs,
511
+ options.maxStatusListBytes
512
+ )) {
513
+ const digest = sha256Hex(statusList.uri);
514
+ await addStoredEntry({
515
+ id: `urn:sha256:${digest}`,
516
+ type: "status-cache",
517
+ path: pathForType("status-cache", digest, encrypt),
518
+ content: json(statusList.content),
519
+ encrypted: true,
520
+ sourceUri: statusList.uri
521
+ });
522
+ }
523
+ const manifest = finalizeManifest({
524
+ specVersion: SPEC_VERSION,
525
+ createdAt: options.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
526
+ primaryDid,
527
+ walletName: "LearnCard",
528
+ encryption: encrypt ? {
529
+ mode: "argon2id-aes-256-gcm",
530
+ encryptedPayloads: true,
531
+ envelope: "sss-key-manager-encryptWithPassword-v1",
532
+ kdf: "argon2id",
533
+ cipher: "AES-256-GCM"
534
+ } : { mode: "none", encryptedPayloads: false },
535
+ contents,
536
+ warnings
537
+ });
538
+ addZipText(zip, "manifest.json", `${JSON.stringify(manifest, null, 2)}
539
+ `);
540
+ return {
541
+ data: await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }),
542
+ manifest,
543
+ warnings
544
+ };
545
+ }, "createLearnCardBundle");
546
+ var exportLearnCardBundle = /* @__PURE__ */ __name(async (wallet, options) => {
547
+ const bundle = await createLearnCardBundle(wallet, options);
548
+ await mkdir(dirname(options.out), { recursive: true });
549
+ await writeFile(options.out, bundle.data);
550
+ return bundle;
551
+ }, "exportLearnCardBundle");
552
+
553
+ // src/importBundle.ts
554
+ import { readFile } from "node:fs/promises";
555
+ import JSZip2 from "jszip";
556
+ var DEFAULT_MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
557
+ var DEFAULT_MAX_ENTRY_BYTES = 25 * 1024 * 1024;
558
+ var DEFAULT_MAX_JSON_BYTES = 25 * 1024 * 1024;
559
+ var byteLength = /* @__PURE__ */ __name((content) => Buffer.byteLength(content, "utf8"), "byteLength");
560
+ var assertSize = /* @__PURE__ */ __name((label, size, max) => {
561
+ if (size > max) throw new Error(`${label} exceeds ${max} bytes`);
562
+ }, "assertSize");
563
+ var parseJson = /* @__PURE__ */ __name((content, label, maxBytes) => {
564
+ assertSize(label, byteLength(content), maxBytes);
565
+ return JSON.parse(content);
566
+ }, "parseJson");
567
+ var safeMessage2 = /* @__PURE__ */ __name((error) => error instanceof Error ? error.message : String(error), "safeMessage");
568
+ var parseObject = /* @__PURE__ */ __name((content, label, maxBytes) => {
569
+ const value = parseJson(content, label, maxBytes);
570
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
571
+ throw new Error("Expected bundle metadata entry to contain a JSON object");
572
+ }
573
+ return value;
574
+ }, "parseObject");
575
+ var isImportableCredentialEntry = /* @__PURE__ */ __name((entry) => entry.type === "credential" || entry.type === "presentation", "isImportableCredentialEntry");
576
+ var isFailedVerification = /* @__PURE__ */ __name((result) => {
577
+ if (Array.isArray(result)) {
578
+ return result.some((item) => {
579
+ if (!item || typeof item !== "object") return true;
580
+ const status = "status" in item ? item.status : void 0;
581
+ return status === "Failed" || status === "Error";
582
+ });
583
+ }
584
+ if (!result || typeof result !== "object") return true;
585
+ const errors = "errors" in result ? result.errors : void 0;
586
+ return !Array.isArray(errors) || errors.length > 0;
587
+ }, "isFailedVerification");
588
+ var verifyImportableEntry = /* @__PURE__ */ __name(async (entry, content, options) => {
589
+ if (!options.verifyBeforeImport) return;
590
+ if (entry.type === "credential") {
591
+ if (!options.wallet.invoke.verifyCredential) {
592
+ throw new Error("Target wallet does not expose invoke.verifyCredential");
593
+ }
594
+ const verification2 = await options.wallet.invoke.verifyCredential(content);
595
+ if (isFailedVerification(verification2)) throw new Error("Credential verification failed");
596
+ return;
597
+ }
598
+ if (!options.wallet.invoke.verifyPresentation) {
599
+ throw new Error("Target wallet does not expose invoke.verifyPresentation");
600
+ }
601
+ const verification = await options.wallet.invoke.verifyPresentation(content);
602
+ if (isFailedVerification(verification)) throw new Error("Presentation verification failed");
603
+ }, "verifyImportableEntry");
604
+ var readLearnCardBundleData = /* @__PURE__ */ __name(async (data, options = {}) => {
605
+ const maxBundleBytes = options.maxBundleBytes ?? DEFAULT_MAX_BUNDLE_BYTES;
606
+ const maxEntryBytes = options.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
607
+ const maxJsonBytes = options.maxJsonBytes ?? DEFAULT_MAX_JSON_BYTES;
608
+ assertSize("LearnCard bundle", data.byteLength, maxBundleBytes);
609
+ const zip = await JSZip2.loadAsync(data);
610
+ const manifestFile = zip.file("manifest.json");
611
+ if (!manifestFile) throw new Error("LearnCard bundle is missing manifest.json");
612
+ const manifestContent = await manifestFile.async("string");
613
+ assertSize("manifest.json", byteLength(manifestContent), maxJsonBytes);
614
+ const manifest = JSON.parse(manifestContent);
615
+ assertValidManifest(manifest);
616
+ const warnings = [...manifest.warnings];
617
+ const entries = [];
618
+ const shouldDecrypt = options.decrypt ?? true;
619
+ let totalEntryBytes = 0;
620
+ for (const entry of manifest.contents) {
621
+ const file = zip.file(entry.path);
622
+ if (!file) throw new Error(`LearnCard bundle is missing ${entry.path}`);
623
+ const stored = await file.async("string");
624
+ const storedBytes = byteLength(stored);
625
+ assertSize(entry.path, storedBytes, maxEntryBytes);
626
+ totalEntryBytes += storedBytes;
627
+ assertSize("LearnCard bundle entries", totalEntryBytes, maxBundleBytes);
628
+ const actualSha = sha256Hex(stored);
629
+ if (actualSha !== entry.sha256) {
630
+ throw new Error(`SHA-256 mismatch for ${entry.path}`);
631
+ }
632
+ const content = shouldDecrypt ? await decodePayload(stored, {
633
+ encrypted: entry.encrypted,
634
+ password: options.password
635
+ }) : stored;
636
+ assertSize(`${entry.path} content`, byteLength(content), maxEntryBytes);
637
+ entries.push({ ...entry, content });
638
+ }
639
+ return { manifest, entries, warnings };
640
+ }, "readLearnCardBundleData");
641
+ var readLearnCardBundle = /* @__PURE__ */ __name(async (path, options = {}) => readLearnCardBundleData(await readFile(path), options), "readLearnCardBundle");
642
+ var importLearnCardBundle = /* @__PURE__ */ __name(async (path, options) => {
643
+ const bundle = await readLearnCardBundle(path, options);
644
+ const maxJsonBytes = options.maxJsonBytes ?? DEFAULT_MAX_JSON_BYTES;
645
+ const report = {
646
+ importedCredentials: 0,
647
+ importedPresentations: 0,
648
+ skipped: 0,
649
+ skippedByType: {},
650
+ errors: [],
651
+ warnings: [...bundle.warnings]
652
+ };
653
+ if (!options.verifyBeforeImport) {
654
+ report.warnings.push(
655
+ "Bundle credential signatures were not verified before import; only import bundles from sources you trust."
656
+ );
657
+ }
658
+ const entriesById = new Map(bundle.entries.map((entry) => [entry.id, entry]));
659
+ for (const entry of bundle.entries) {
660
+ if (!isImportableCredentialEntry(entry)) {
661
+ report.skipped += 1;
662
+ report.skippedByType[entry.type] = (report.skippedByType[entry.type] ?? 0) + 1;
663
+ continue;
664
+ }
665
+ try {
666
+ const content = parseJson(entry.content, entry.path, maxJsonBytes);
667
+ await verifyImportableEntry(entry, content, options);
668
+ const upload = options.wallet.store.LearnCloud.uploadEncrypted ?? options.wallet.store.LearnCloud.upload;
669
+ if (!upload)
670
+ throw new Error("Target wallet does not expose a LearnCloud upload method");
671
+ const uri = await upload(content);
672
+ const referencedIndexRecord = entry.indexRecordRef ? entriesById.get(entry.indexRecordRef) : void 0;
673
+ if (entry.indexRecordRef && !referencedIndexRecord) {
674
+ throw new Error(`Referenced index record ${entry.indexRecordRef} is missing`);
675
+ }
676
+ const indexRecord = referencedIndexRecord ? parseObject(
677
+ referencedIndexRecord.content,
678
+ referencedIndexRecord.path,
679
+ maxJsonBytes
680
+ ) : { id: entry.id, uri };
681
+ const recordId = typeof indexRecord.id === "string" ? indexRecord.id : entry.id;
682
+ await options.wallet.index.LearnCloud.add({
683
+ ...indexRecord,
684
+ id: recordId,
685
+ uri,
686
+ sourceExport: {
687
+ manifestCreatedAt: bundle.manifest.createdAt,
688
+ sourceUri: entry.sourceUri,
689
+ sourcePath: entry.path,
690
+ credentialId: entry.credentialId
691
+ }
692
+ });
693
+ if (entry.type === "presentation") report.importedPresentations += 1;
694
+ else report.importedCredentials += 1;
695
+ } catch (error) {
696
+ report.errors.push({ path: entry.path, message: safeMessage2(error) });
697
+ }
698
+ }
699
+ return report;
700
+ }, "importLearnCardBundle");
701
+
702
+ // src/restoreBundle.ts
703
+ import { initLearnCard } from "@learncard/init";
704
+ var getSeedEntry = /* @__PURE__ */ __name((bundle) => {
705
+ const seedEntry = bundle.entries.find((entry) => entry.type === "key-private-seed");
706
+ if (!seedEntry) {
707
+ throw new Error(
708
+ "LearnCard bundle does not contain key-private-seed and cannot be restored"
709
+ );
710
+ }
711
+ const seed = seedEntry.content.trim();
712
+ if (!/^[0-9a-f]{64}$/i.test(seed)) {
713
+ throw new Error(
714
+ "LearnCard bundle key-private-seed must be exactly 64 hexadecimal characters"
715
+ );
716
+ }
717
+ return seed;
718
+ }, "getSeedEntry");
719
+ var readLearnCardBundleSeedData = /* @__PURE__ */ __name(async (data, options = {}) => getSeedEntry(await readLearnCardBundleData(data, options)), "readLearnCardBundleSeedData");
720
+ var readLearnCardBundleSeed = /* @__PURE__ */ __name(async (path, options = {}) => getSeedEntry(await readLearnCardBundle(path, options)), "readLearnCardBundleSeed");
721
+ var restoreLearnCardFromBundleData = /* @__PURE__ */ __name(async (data, options) => {
722
+ const seed = await readLearnCardBundleSeedData(data, options);
723
+ return initLearnCard({ ...options.init, seed });
724
+ }, "restoreLearnCardFromBundleData");
725
+ var restoreLearnCardFromBundle = /* @__PURE__ */ __name(async (path, options) => {
726
+ const seed = await readLearnCardBundleSeed(path, options);
727
+ return initLearnCard({ ...options.init, seed });
728
+ }, "restoreLearnCardFromBundle");
729
+ export {
730
+ assertValidManifest,
731
+ computePayloadSha256,
732
+ createLearnCardBundle,
733
+ exportLearnCardBundle,
734
+ finalizeManifest,
735
+ importLearnCardBundle,
736
+ readLearnCardBundle,
737
+ readLearnCardBundleData,
738
+ readLearnCardBundleSeed,
739
+ readLearnCardBundleSeedData,
740
+ restoreLearnCardFromBundle,
741
+ restoreLearnCardFromBundleData
742
+ };
743
+ //# sourceMappingURL=holder-continuity.esm.js.map