@learncard/holder-continuity 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +50 -41
- package/src/__tests__/bundle.test.ts +296 -0
- package/src/__tests__/restoreBundle.test.ts +121 -0
- package/src/crypto.ts +56 -0
- package/src/exportBundle.ts +652 -0
- package/src/importBundle.ts +242 -0
- package/src/index.ts +25 -0
- package/src/manifest.ts +103 -0
- package/src/restoreBundle.ts +62 -0
- package/src/types.ts +149 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
import JSZip from 'jszip';
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ImportLearnCardBundleOptions,
|
|
7
|
+
ImportLearnCardBundleReport,
|
|
8
|
+
JsonValue,
|
|
9
|
+
LearnCardBundleEntryMetadata,
|
|
10
|
+
LearnCardBundleManifest,
|
|
11
|
+
ReadLearnCardBundleOptions,
|
|
12
|
+
ReadLearnCardBundleResult,
|
|
13
|
+
} from './types';
|
|
14
|
+
import { assertValidManifest } from './manifest';
|
|
15
|
+
import { decodePayload, sha256Hex } from './crypto';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
|
|
18
|
+
const DEFAULT_MAX_ENTRY_BYTES = 25 * 1024 * 1024;
|
|
19
|
+
const DEFAULT_MAX_JSON_BYTES = 25 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
const byteLength = (content: string): number => Buffer.byteLength(content, 'utf8');
|
|
22
|
+
|
|
23
|
+
const assertSize = (label: string, size: number, max: number): void => {
|
|
24
|
+
if (size > max) throw new Error(`${label} exceeds ${max} bytes`);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const parseJson = (content: string, label: string, maxBytes: number): JsonValue => {
|
|
28
|
+
assertSize(label, byteLength(content), maxBytes);
|
|
29
|
+
|
|
30
|
+
return JSON.parse(content) as JsonValue;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const safeMessage = (error: unknown): string =>
|
|
34
|
+
error instanceof Error ? error.message : String(error);
|
|
35
|
+
|
|
36
|
+
const parseObject = (content: string, label: string, maxBytes: number): Record<string, unknown> => {
|
|
37
|
+
const value = parseJson(content, label, maxBytes);
|
|
38
|
+
|
|
39
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
40
|
+
throw new Error('Expected bundle metadata entry to contain a JSON object');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return value as Record<string, unknown>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const isImportableCredentialEntry = (entry: LearnCardBundleEntryMetadata): boolean =>
|
|
47
|
+
entry.type === 'credential' || entry.type === 'presentation';
|
|
48
|
+
|
|
49
|
+
const isFailedVerification = (result: unknown): boolean => {
|
|
50
|
+
if (Array.isArray(result)) {
|
|
51
|
+
return result.some(item => {
|
|
52
|
+
if (!item || typeof item !== 'object') return true;
|
|
53
|
+
|
|
54
|
+
const status = 'status' in item ? item.status : undefined;
|
|
55
|
+
|
|
56
|
+
return status === 'Failed' || status === 'Error';
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!result || typeof result !== 'object') return true;
|
|
61
|
+
|
|
62
|
+
const errors = 'errors' in result ? result.errors : undefined;
|
|
63
|
+
|
|
64
|
+
return !Array.isArray(errors) || errors.length > 0;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const verifyImportableEntry = async (
|
|
68
|
+
entry: LearnCardBundleEntryMetadata,
|
|
69
|
+
content: JsonValue,
|
|
70
|
+
options: ImportLearnCardBundleOptions
|
|
71
|
+
): Promise<void> => {
|
|
72
|
+
if (!options.verifyBeforeImport) return;
|
|
73
|
+
|
|
74
|
+
if (entry.type === 'credential') {
|
|
75
|
+
if (!options.wallet.invoke.verifyCredential) {
|
|
76
|
+
throw new Error('Target wallet does not expose invoke.verifyCredential');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const verification = await options.wallet.invoke.verifyCredential(content);
|
|
80
|
+
|
|
81
|
+
if (isFailedVerification(verification)) throw new Error('Credential verification failed');
|
|
82
|
+
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!options.wallet.invoke.verifyPresentation) {
|
|
87
|
+
throw new Error('Target wallet does not expose invoke.verifyPresentation');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const verification = await options.wallet.invoke.verifyPresentation(content);
|
|
91
|
+
|
|
92
|
+
if (isFailedVerification(verification)) throw new Error('Presentation verification failed');
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const readLearnCardBundleData = async (
|
|
96
|
+
data: Buffer,
|
|
97
|
+
options: ReadLearnCardBundleOptions = {}
|
|
98
|
+
): Promise<ReadLearnCardBundleResult> => {
|
|
99
|
+
const maxBundleBytes = options.maxBundleBytes ?? DEFAULT_MAX_BUNDLE_BYTES;
|
|
100
|
+
const maxEntryBytes = options.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
|
|
101
|
+
const maxJsonBytes = options.maxJsonBytes ?? DEFAULT_MAX_JSON_BYTES;
|
|
102
|
+
|
|
103
|
+
assertSize('LearnCard bundle', data.byteLength, maxBundleBytes);
|
|
104
|
+
|
|
105
|
+
const zip = await JSZip.loadAsync(data);
|
|
106
|
+
const manifestFile = zip.file('manifest.json');
|
|
107
|
+
|
|
108
|
+
if (!manifestFile) throw new Error('LearnCard bundle is missing manifest.json');
|
|
109
|
+
|
|
110
|
+
const manifestContent = await manifestFile.async('string');
|
|
111
|
+
|
|
112
|
+
assertSize('manifest.json', byteLength(manifestContent), maxJsonBytes);
|
|
113
|
+
|
|
114
|
+
const manifest = JSON.parse(manifestContent) as LearnCardBundleManifest;
|
|
115
|
+
|
|
116
|
+
assertValidManifest(manifest);
|
|
117
|
+
|
|
118
|
+
const warnings = [...manifest.warnings];
|
|
119
|
+
const entries: ReadLearnCardBundleResult['entries'] = [];
|
|
120
|
+
const shouldDecrypt = options.decrypt ?? true;
|
|
121
|
+
let totalEntryBytes = 0;
|
|
122
|
+
|
|
123
|
+
for (const entry of manifest.contents) {
|
|
124
|
+
const file = zip.file(entry.path);
|
|
125
|
+
|
|
126
|
+
if (!file) throw new Error(`LearnCard bundle is missing ${entry.path}`);
|
|
127
|
+
|
|
128
|
+
const stored = await file.async('string');
|
|
129
|
+
const storedBytes = byteLength(stored);
|
|
130
|
+
|
|
131
|
+
assertSize(entry.path, storedBytes, maxEntryBytes);
|
|
132
|
+
|
|
133
|
+
totalEntryBytes += storedBytes;
|
|
134
|
+
assertSize('LearnCard bundle entries', totalEntryBytes, maxBundleBytes);
|
|
135
|
+
|
|
136
|
+
const actualSha = sha256Hex(stored);
|
|
137
|
+
|
|
138
|
+
if (actualSha !== entry.sha256) {
|
|
139
|
+
throw new Error(`SHA-256 mismatch for ${entry.path}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const content = shouldDecrypt
|
|
143
|
+
? await decodePayload(stored, {
|
|
144
|
+
encrypted: entry.encrypted,
|
|
145
|
+
password: options.password,
|
|
146
|
+
})
|
|
147
|
+
: stored;
|
|
148
|
+
|
|
149
|
+
assertSize(`${entry.path} content`, byteLength(content), maxEntryBytes);
|
|
150
|
+
|
|
151
|
+
entries.push({ ...entry, content });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { manifest, entries, warnings };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const readLearnCardBundle = async (
|
|
158
|
+
path: string,
|
|
159
|
+
options: ReadLearnCardBundleOptions = {}
|
|
160
|
+
): Promise<ReadLearnCardBundleResult> => readLearnCardBundleData(await readFile(path), options);
|
|
161
|
+
|
|
162
|
+
export const importLearnCardBundle = async (
|
|
163
|
+
path: string,
|
|
164
|
+
options: ImportLearnCardBundleOptions
|
|
165
|
+
): Promise<ImportLearnCardBundleReport> => {
|
|
166
|
+
const bundle = await readLearnCardBundle(path, options);
|
|
167
|
+
const maxJsonBytes = options.maxJsonBytes ?? DEFAULT_MAX_JSON_BYTES;
|
|
168
|
+
const report: ImportLearnCardBundleReport = {
|
|
169
|
+
importedCredentials: 0,
|
|
170
|
+
importedPresentations: 0,
|
|
171
|
+
skipped: 0,
|
|
172
|
+
skippedByType: {},
|
|
173
|
+
errors: [],
|
|
174
|
+
warnings: [...bundle.warnings],
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (!options.verifyBeforeImport) {
|
|
178
|
+
report.warnings.push(
|
|
179
|
+
'Bundle credential signatures were not verified before import; only import bundles from sources you trust.'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const entriesById = new Map(bundle.entries.map(entry => [entry.id, entry]));
|
|
184
|
+
|
|
185
|
+
for (const entry of bundle.entries) {
|
|
186
|
+
if (!isImportableCredentialEntry(entry)) {
|
|
187
|
+
report.skipped += 1;
|
|
188
|
+
report.skippedByType[entry.type] = (report.skippedByType[entry.type] ?? 0) + 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const content = parseJson(entry.content, entry.path, maxJsonBytes);
|
|
194
|
+
|
|
195
|
+
await verifyImportableEntry(entry, content, options);
|
|
196
|
+
|
|
197
|
+
const upload =
|
|
198
|
+
options.wallet.store.LearnCloud.uploadEncrypted ??
|
|
199
|
+
options.wallet.store.LearnCloud.upload;
|
|
200
|
+
|
|
201
|
+
if (!upload)
|
|
202
|
+
throw new Error('Target wallet does not expose a LearnCloud upload method');
|
|
203
|
+
|
|
204
|
+
const uri = await upload(content);
|
|
205
|
+
const referencedIndexRecord = entry.indexRecordRef
|
|
206
|
+
? entriesById.get(entry.indexRecordRef)
|
|
207
|
+
: undefined;
|
|
208
|
+
|
|
209
|
+
if (entry.indexRecordRef && !referencedIndexRecord) {
|
|
210
|
+
throw new Error(`Referenced index record ${entry.indexRecordRef} is missing`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const indexRecord = referencedIndexRecord
|
|
214
|
+
? parseObject(
|
|
215
|
+
referencedIndexRecord.content,
|
|
216
|
+
referencedIndexRecord.path,
|
|
217
|
+
maxJsonBytes
|
|
218
|
+
)
|
|
219
|
+
: { id: entry.id, uri };
|
|
220
|
+
const recordId = typeof indexRecord.id === 'string' ? indexRecord.id : entry.id;
|
|
221
|
+
|
|
222
|
+
await options.wallet.index.LearnCloud.add({
|
|
223
|
+
...indexRecord,
|
|
224
|
+
id: recordId,
|
|
225
|
+
uri,
|
|
226
|
+
sourceExport: {
|
|
227
|
+
manifestCreatedAt: bundle.manifest.createdAt,
|
|
228
|
+
sourceUri: entry.sourceUri,
|
|
229
|
+
sourcePath: entry.path,
|
|
230
|
+
credentialId: entry.credentialId,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (entry.type === 'presentation') report.importedPresentations += 1;
|
|
235
|
+
else report.importedCredentials += 1;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
report.errors.push({ path: entry.path, message: safeMessage(error) });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return report;
|
|
242
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { createLearnCardBundle, exportLearnCardBundle } from './exportBundle';
|
|
2
|
+
export {
|
|
3
|
+
importLearnCardBundle,
|
|
4
|
+
readLearnCardBundle,
|
|
5
|
+
readLearnCardBundleData,
|
|
6
|
+
} from './importBundle';
|
|
7
|
+
export { assertValidManifest, computePayloadSha256, finalizeManifest } from './manifest';
|
|
8
|
+
export {
|
|
9
|
+
readLearnCardBundleSeed,
|
|
10
|
+
readLearnCardBundleSeedData,
|
|
11
|
+
restoreLearnCardFromBundle,
|
|
12
|
+
restoreLearnCardFromBundleData,
|
|
13
|
+
} from './restoreBundle';
|
|
14
|
+
export type {
|
|
15
|
+
ExportLearnCardBundleOptions,
|
|
16
|
+
ImportLearnCardBundleOptions,
|
|
17
|
+
ImportLearnCardBundleReport,
|
|
18
|
+
LearnCardBundleManifest,
|
|
19
|
+
LearnCardBundleOptions,
|
|
20
|
+
LearnCardBundleResult,
|
|
21
|
+
LearnCardBundleWallet,
|
|
22
|
+
ReadLearnCardBundleOptions,
|
|
23
|
+
ReadLearnCardBundleResult,
|
|
24
|
+
} from './types';
|
|
25
|
+
export type { RestoreLearnCardFromBundleOptions, RestoreLearnCardInit } from './restoreBundle';
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { LearnCardBundleEntryMetadata, LearnCardBundleManifest } from './types';
|
|
2
|
+
import { stableStringify, sha256Hex } from './crypto';
|
|
3
|
+
|
|
4
|
+
export const SPEC_VERSION = '1.0.0' as const;
|
|
5
|
+
|
|
6
|
+
export const BUNDLE_SPEC_MD = `# LearnCard Holder Continuity Bundle v1.0.0
|
|
7
|
+
|
|
8
|
+
A LearnCard holder continuity bundle is a ZIP file with readable metadata and encrypted holder payloads.
|
|
9
|
+
|
|
10
|
+
## Container
|
|
11
|
+
|
|
12
|
+
Required readable entries:
|
|
13
|
+
|
|
14
|
+
- \`manifest.json\` — inventory, hashes, warnings, and encryption metadata.
|
|
15
|
+
- \`README.md\` — human-readable recovery notes.
|
|
16
|
+
- \`BUNDLE_SPEC.md\` — this format description.
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
## Security model
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
|
|
24
|
+
## Paths
|
|
25
|
+
|
|
26
|
+
- \`keys/recovery-phrase.txt.enc\`
|
|
27
|
+
- \`keys/private-key-seed.txt.enc\`
|
|
28
|
+
- \`keys/jwks.json.enc\`
|
|
29
|
+
- \`keys/did-document.json\`
|
|
30
|
+
- \`credentials/<sha256>.json.enc\`
|
|
31
|
+
- \`presentations/<sha256>.json.enc\`
|
|
32
|
+
- \`index-records/<sha256>.json.enc\`
|
|
33
|
+
- \`consent-records/<sha256>.json.enc\`
|
|
34
|
+
- \`status-cache/<sha256>.json.enc\`
|
|
35
|
+
|
|
36
|
+
Debug exports MAY use plaintext payloads by setting \`encrypt: false\`; production exports MUST encrypt sensitive payloads.
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
## Manifest hashing
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
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.
|
|
45
|
+
|
|
46
|
+
## Restore vs import
|
|
47
|
+
|
|
48
|
+
\`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.
|
|
49
|
+
|
|
50
|
+
\`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.
|
|
51
|
+
|
|
52
|
+
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\`.
|
|
53
|
+
|
|
54
|
+
## Size limits
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
## Import expectations
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
export const BUNDLE_README_MD = `# LearnCard Holder Continuity Export
|
|
64
|
+
|
|
65
|
+
This archive contains a point-in-time holder export from a LearnCard wallet.
|
|
66
|
+
|
|
67
|
+
Keep the password separately. Without it, encrypted credentials, key material, consent records, and status-list snapshots cannot be recovered.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
export const sortContents = (
|
|
75
|
+
contents: LearnCardBundleEntryMetadata[]
|
|
76
|
+
): LearnCardBundleEntryMetadata[] => [...contents].sort((a, b) => a.path.localeCompare(b.path));
|
|
77
|
+
|
|
78
|
+
const normalizeContents = (
|
|
79
|
+
contents: LearnCardBundleEntryMetadata[]
|
|
80
|
+
): LearnCardBundleEntryMetadata[] =>
|
|
81
|
+
JSON.parse(JSON.stringify(sortContents(contents))) as LearnCardBundleEntryMetadata[];
|
|
82
|
+
|
|
83
|
+
export const computePayloadSha256 = (contents: LearnCardBundleEntryMetadata[]): string =>
|
|
84
|
+
sha256Hex(stableStringify(normalizeContents(contents)));
|
|
85
|
+
|
|
86
|
+
export const finalizeManifest = (
|
|
87
|
+
manifest: Omit<LearnCardBundleManifest, 'payloadSha256'>
|
|
88
|
+
): LearnCardBundleManifest => ({
|
|
89
|
+
...manifest,
|
|
90
|
+
contents: normalizeContents(manifest.contents),
|
|
91
|
+
payloadSha256: computePayloadSha256(manifest.contents),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const assertValidManifest = (manifest: LearnCardBundleManifest): void => {
|
|
95
|
+
if (manifest.specVersion !== SPEC_VERSION) {
|
|
96
|
+
throw new Error(`Unsupported LearnCard bundle version: ${manifest.specVersion}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const expected = computePayloadSha256(manifest.contents);
|
|
100
|
+
|
|
101
|
+
if (manifest.payloadSha256 !== expected)
|
|
102
|
+
throw new Error('LearnCard bundle manifest hash mismatch');
|
|
103
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { initLearnCard } from '@learncard/init';
|
|
2
|
+
import type { InitLearnCard } from '@learncard/init';
|
|
3
|
+
|
|
4
|
+
import { readLearnCardBundle, readLearnCardBundleData } from './importBundle';
|
|
5
|
+
import type { ReadLearnCardBundleOptions, ReadLearnCardBundleResult } from './types';
|
|
6
|
+
|
|
7
|
+
type SeededInitConfig = Extract<InitLearnCard['args'], { seed: string }>;
|
|
8
|
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
|
9
|
+
|
|
10
|
+
export type RestoreLearnCardInit = DistributiveOmit<SeededInitConfig, 'seed'>;
|
|
11
|
+
|
|
12
|
+
export type RestoreLearnCardFromBundleOptions = ReadLearnCardBundleOptions & {
|
|
13
|
+
init: RestoreLearnCardInit;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const getSeedEntry = (bundle: ReadLearnCardBundleResult): string => {
|
|
17
|
+
const seedEntry = bundle.entries.find(entry => entry.type === 'key-private-seed');
|
|
18
|
+
|
|
19
|
+
if (!seedEntry) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
'LearnCard bundle does not contain key-private-seed and cannot be restored'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const seed = seedEntry.content.trim();
|
|
26
|
+
|
|
27
|
+
if (!/^[0-9a-f]{64}$/i.test(seed)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'LearnCard bundle key-private-seed must be exactly 64 hexadecimal characters'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return seed;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const readLearnCardBundleSeedData = async (
|
|
37
|
+
data: Buffer,
|
|
38
|
+
options: ReadLearnCardBundleOptions = {}
|
|
39
|
+
): Promise<string> => getSeedEntry(await readLearnCardBundleData(data, options));
|
|
40
|
+
|
|
41
|
+
export const readLearnCardBundleSeed = async (
|
|
42
|
+
path: string,
|
|
43
|
+
options: ReadLearnCardBundleOptions = {}
|
|
44
|
+
): Promise<string> => getSeedEntry(await readLearnCardBundle(path, options));
|
|
45
|
+
|
|
46
|
+
export const restoreLearnCardFromBundleData = async (
|
|
47
|
+
data: Buffer,
|
|
48
|
+
options: RestoreLearnCardFromBundleOptions
|
|
49
|
+
): Promise<InitLearnCard['returnValue']> => {
|
|
50
|
+
const seed = await readLearnCardBundleSeedData(data, options);
|
|
51
|
+
|
|
52
|
+
return initLearnCard({ ...options.init, seed } as SeededInitConfig);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const restoreLearnCardFromBundle = async (
|
|
56
|
+
path: string,
|
|
57
|
+
options: RestoreLearnCardFromBundleOptions
|
|
58
|
+
): Promise<InitLearnCard['returnValue']> => {
|
|
59
|
+
const seed = await readLearnCardBundleSeed(path, options);
|
|
60
|
+
|
|
61
|
+
return initLearnCard({ ...options.init, seed } as SeededInitConfig);
|
|
62
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { CredentialRecord } from '@learncard/types';
|
|
2
|
+
|
|
3
|
+
export type BundleContentType =
|
|
4
|
+
| 'key-recovery-phrase'
|
|
5
|
+
| 'key-private-seed'
|
|
6
|
+
| 'key-jwks'
|
|
7
|
+
| 'did-document'
|
|
8
|
+
| 'credential'
|
|
9
|
+
| 'presentation'
|
|
10
|
+
| 'consent-record'
|
|
11
|
+
| 'status-cache'
|
|
12
|
+
| 'unknown-json'
|
|
13
|
+
| 'index-record';
|
|
14
|
+
|
|
15
|
+
export type BundleEncryptionMode = 'argon2id-aes-256-gcm' | 'none';
|
|
16
|
+
|
|
17
|
+
export type JsonValue =
|
|
18
|
+
| null
|
|
19
|
+
| boolean
|
|
20
|
+
| number
|
|
21
|
+
| string
|
|
22
|
+
| JsonValue[]
|
|
23
|
+
| { [key: string]: JsonValue };
|
|
24
|
+
|
|
25
|
+
export type LearnCardBundleEntryMetadata = {
|
|
26
|
+
id: string;
|
|
27
|
+
type: BundleContentType;
|
|
28
|
+
path: string;
|
|
29
|
+
mediaType: string;
|
|
30
|
+
sha256: string;
|
|
31
|
+
encrypted: boolean;
|
|
32
|
+
sourceUri?: string;
|
|
33
|
+
credentialId?: string;
|
|
34
|
+
indexRecordRef?: string;
|
|
35
|
+
warnings?: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type LearnCardBundleManifest = {
|
|
39
|
+
specVersion: '1.0.0';
|
|
40
|
+
createdAt: string;
|
|
41
|
+
primaryDid: string;
|
|
42
|
+
walletName: 'LearnCard';
|
|
43
|
+
encryption: {
|
|
44
|
+
mode: BundleEncryptionMode;
|
|
45
|
+
encryptedPayloads: boolean;
|
|
46
|
+
envelope?: 'sss-key-manager-encryptWithPassword-v1';
|
|
47
|
+
kdf?: 'argon2id';
|
|
48
|
+
cipher?: 'AES-256-GCM';
|
|
49
|
+
};
|
|
50
|
+
contents: LearnCardBundleEntryMetadata[];
|
|
51
|
+
warnings: string[];
|
|
52
|
+
payloadSha256: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type LearnCardBundleOptions = {
|
|
56
|
+
password?: string;
|
|
57
|
+
encrypt?: boolean;
|
|
58
|
+
createdAt?: string;
|
|
59
|
+
fetchStatusLists?: boolean;
|
|
60
|
+
statusListFetchTimeoutMs?: number;
|
|
61
|
+
maxStatusListBytes?: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type ExportLearnCardBundleOptions = LearnCardBundleOptions & {
|
|
65
|
+
out: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type ReadLearnCardBundleOptions = {
|
|
69
|
+
password?: string;
|
|
70
|
+
decrypt?: boolean;
|
|
71
|
+
maxBundleBytes?: number;
|
|
72
|
+
maxEntryBytes?: number;
|
|
73
|
+
maxJsonBytes?: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type ImportLearnCardBundleOptions = ReadLearnCardBundleOptions & {
|
|
77
|
+
wallet: LearnCardBundleWallet;
|
|
78
|
+
verifyBeforeImport?: boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type LearnCardBundleResult = {
|
|
82
|
+
data: Buffer;
|
|
83
|
+
manifest: LearnCardBundleManifest;
|
|
84
|
+
warnings: string[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type ReadLearnCardBundleResult = {
|
|
88
|
+
manifest: LearnCardBundleManifest;
|
|
89
|
+
entries: Array<LearnCardBundleEntryMetadata & { content: string }>;
|
|
90
|
+
warnings: string[];
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type ImportLearnCardBundleReport = {
|
|
94
|
+
importedCredentials: number;
|
|
95
|
+
importedPresentations: number;
|
|
96
|
+
skipped: number;
|
|
97
|
+
/** Count of skipped (non-importable) entries broken down by their bundle content type. */
|
|
98
|
+
skippedByType: Partial<Record<BundleContentType, number>>;
|
|
99
|
+
errors: Array<{ path: string; message: string }>;
|
|
100
|
+
warnings: string[];
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type LearnCardBundleWallet = {
|
|
104
|
+
id: {
|
|
105
|
+
did: (method?: string) => string;
|
|
106
|
+
keypair?: (algorithm?: 'ed25519' | 'secp256k1') => JsonValue;
|
|
107
|
+
};
|
|
108
|
+
invoke: {
|
|
109
|
+
getKey?: () => string;
|
|
110
|
+
resolveDid?: (did: string) => Promise<JsonValue>;
|
|
111
|
+
verifyCredential?: (
|
|
112
|
+
credential: JsonValue,
|
|
113
|
+
options?: Record<string, unknown>
|
|
114
|
+
) => Promise<unknown>;
|
|
115
|
+
verifyPresentation?: (
|
|
116
|
+
presentation: JsonValue,
|
|
117
|
+
options?: Record<string, unknown>
|
|
118
|
+
) => Promise<unknown>;
|
|
119
|
+
getHolderExportMetadata?: () => Promise<JsonValue>;
|
|
120
|
+
getConsentedContracts?: () => Promise<JsonValue>;
|
|
121
|
+
};
|
|
122
|
+
index: {
|
|
123
|
+
LearnCloud: {
|
|
124
|
+
get: (query?: Record<string, unknown>) => Promise<CredentialRecord[]>;
|
|
125
|
+
add: (record: CredentialRecord) => Promise<unknown>;
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
read: {
|
|
129
|
+
get: (uri: string) => Promise<JsonValue | undefined>;
|
|
130
|
+
};
|
|
131
|
+
store: {
|
|
132
|
+
LearnCloud: {
|
|
133
|
+
upload?: (content: JsonValue) => Promise<string>;
|
|
134
|
+
uploadEncrypted?: (content: JsonValue) => Promise<string>;
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export type EncryptedPayloadEnvelope = {
|
|
140
|
+
ciphertext: string;
|
|
141
|
+
iv: string;
|
|
142
|
+
salt: string;
|
|
143
|
+
kdfParams: {
|
|
144
|
+
algorithm: 'argon2id';
|
|
145
|
+
timeCost: number;
|
|
146
|
+
memoryCost: number;
|
|
147
|
+
parallelism: number;
|
|
148
|
+
};
|
|
149
|
+
};
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 Learning Economy Foundation <sdk@learningeconomy.io>
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|