@learncard/holder-continuity 0.2.4 → 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
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,42 +1,51 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
2
|
+
"name": "@learncard/holder-continuity",
|
|
3
|
+
"version": "0.2.6",
|
|
4
|
+
"description": "Holder continuity bundle helpers for LearnCard wallets",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/holder-continuity.esm.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"development": "./src/index.ts",
|
|
11
|
+
"import": "./dist/holder-continuity.esm.js",
|
|
12
|
+
"require": "./dist/index.js",
|
|
13
|
+
"default": "./dist/holder-continuity.esm.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"BUNDLE_SPEC.md",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "node ./scripts/build.mjs && shx cp ./scripts/mixedEntrypoint.js ./dist/index.js && tsc --p tsconfig.json",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest"
|
|
26
|
+
},
|
|
27
|
+
"author": "Learning Economy Foundation (www.learningeconomy.io)",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"homepage": "https://github.com/learningeconomy/LearnCard/tree/main/packages/holder-continuity-bundle/BUNDLE_SPEC.md",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/learningeconomy/LearnCard"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/learningeconomy/LearnCard/issues"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@learncard/init": "workspace:*",
|
|
39
|
+
"@learncard/sss-key-manager": "workspace:*",
|
|
40
|
+
"@learncard/types": "workspace:*",
|
|
41
|
+
"jszip": "^3.10.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^18.19.68",
|
|
45
|
+
"esbuild": "^0.27.1",
|
|
46
|
+
"shx": "^0.3.4",
|
|
47
|
+
"typescript": "5.6.2",
|
|
48
|
+
"vitest": "^1.6.0"
|
|
49
|
+
},
|
|
50
|
+
"sideEffects": false
|
|
51
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { webcrypto } from 'node:crypto';
|
|
5
|
+
import JSZip from 'jszip';
|
|
6
|
+
|
|
7
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { createLearnCardBundle } from '../exportBundle';
|
|
10
|
+
import { importLearnCardBundle, readLearnCardBundleData } from '../importBundle';
|
|
11
|
+
import { computePayloadSha256 } from '../manifest';
|
|
12
|
+
import type { JsonValue, LearnCardBundleWallet } from '../types';
|
|
13
|
+
|
|
14
|
+
const vc = {
|
|
15
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
16
|
+
id: 'urn:uuid:credential-1',
|
|
17
|
+
type: ['VerifiableCredential'],
|
|
18
|
+
issuer: 'did:example:issuer',
|
|
19
|
+
issuanceDate: '2024-01-01T00:00:00Z',
|
|
20
|
+
credentialSubject: { id: 'did:example:holder' },
|
|
21
|
+
proof: { type: 'Ed25519Signature2018', jws: 'test' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const vp = {
|
|
25
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
26
|
+
id: 'urn:uuid:presentation-1',
|
|
27
|
+
type: ['VerifiablePresentation'],
|
|
28
|
+
holder: 'did:example:holder',
|
|
29
|
+
verifiableCredential: [vc],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const createWallet = (): LearnCardBundleWallet & {
|
|
33
|
+
stored: JsonValue[];
|
|
34
|
+
added: Array<Record<string, unknown>>;
|
|
35
|
+
} => {
|
|
36
|
+
const stored: JsonValue[] = [];
|
|
37
|
+
const added: Array<Record<string, unknown>> = [];
|
|
38
|
+
const payloads: Record<string, JsonValue | undefined> = {
|
|
39
|
+
'lc:cloud:https%3A%2F%2Fcloud.example:credentials:1': vc,
|
|
40
|
+
'lc:cloud:https%3A%2F%2Fcloud.example:presentations:1': vp,
|
|
41
|
+
'lc:cloud:https%3A%2F%2Fcloud.example:missing:1': undefined,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
stored,
|
|
46
|
+
added,
|
|
47
|
+
id: {
|
|
48
|
+
did: method => (method ? `did:${method}:holder` : 'did:key:holder'),
|
|
49
|
+
keypair: (algorithm = 'ed25519') => ({
|
|
50
|
+
kty: 'OKP',
|
|
51
|
+
crv: algorithm,
|
|
52
|
+
d: `${algorithm}-private`,
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
invoke: {
|
|
56
|
+
getKey: () => '1'.repeat(64),
|
|
57
|
+
resolveDid: async did => ({ id: did, verificationMethod: [] }),
|
|
58
|
+
getHolderExportMetadata: async () => ({
|
|
59
|
+
consentRecords: [
|
|
60
|
+
{
|
|
61
|
+
termsUri: 'lc:consent:terms:1',
|
|
62
|
+
status: 'live',
|
|
63
|
+
contract: { id: 'contract-1' },
|
|
64
|
+
terms: { read: {} },
|
|
65
|
+
transactions: [{ id: 'transaction-1' }],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
}),
|
|
69
|
+
verifyCredential: async credential =>
|
|
70
|
+
typeof credential === 'object' &&
|
|
71
|
+
credential !== null &&
|
|
72
|
+
'id' in credential &&
|
|
73
|
+
credential.id === vc.id
|
|
74
|
+
? { checks: ['proof'], warnings: [], errors: [] }
|
|
75
|
+
: { checks: [], warnings: [], errors: ['invalid'] },
|
|
76
|
+
verifyPresentation: async presentation =>
|
|
77
|
+
typeof presentation === 'object' &&
|
|
78
|
+
presentation !== null &&
|
|
79
|
+
'id' in presentation &&
|
|
80
|
+
presentation.id === vp.id
|
|
81
|
+
? { checks: ['proof'], warnings: [], errors: [] }
|
|
82
|
+
: { checks: [], warnings: [], errors: ['invalid'] },
|
|
83
|
+
},
|
|
84
|
+
index: {
|
|
85
|
+
LearnCloud: {
|
|
86
|
+
get: async () => [
|
|
87
|
+
{
|
|
88
|
+
id: 'credential-record',
|
|
89
|
+
uri: 'lc:cloud:https%3A%2F%2Fcloud.example:credentials:1',
|
|
90
|
+
category: 'Achievement',
|
|
91
|
+
title: 'Credential',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'duplicate-record',
|
|
95
|
+
uri: 'lc:cloud:https%3A%2F%2Fcloud.example:credentials:1',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'presentation-record',
|
|
99
|
+
uri: 'lc:cloud:https%3A%2F%2Fcloud.example:presentations:1',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'missing-record',
|
|
103
|
+
uri: 'lc:cloud:https%3A%2F%2Fcloud.example:missing:1',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
add: async record => {
|
|
107
|
+
added.push(record);
|
|
108
|
+
|
|
109
|
+
return true;
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
read: {
|
|
114
|
+
get: async uri => payloads[uri],
|
|
115
|
+
},
|
|
116
|
+
store: {
|
|
117
|
+
LearnCloud: {
|
|
118
|
+
uploadEncrypted: async content => {
|
|
119
|
+
stored.push(content);
|
|
120
|
+
|
|
121
|
+
return `imported:${stored.length}`;
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
beforeAll(() => {
|
|
129
|
+
Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('LearnCard holder continuity bundles', () => {
|
|
133
|
+
it('computes deterministic manifest payload hashes', () => {
|
|
134
|
+
const contents = [
|
|
135
|
+
{
|
|
136
|
+
id: 'b',
|
|
137
|
+
type: 'credential' as const,
|
|
138
|
+
path: 'credentials/b.json',
|
|
139
|
+
mediaType: 'application/json',
|
|
140
|
+
sha256: 'b',
|
|
141
|
+
encrypted: false,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 'a',
|
|
145
|
+
type: 'credential' as const,
|
|
146
|
+
path: 'credentials/a.json',
|
|
147
|
+
mediaType: 'application/json',
|
|
148
|
+
sha256: 'a',
|
|
149
|
+
encrypted: false,
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
expect(computePayloadSha256(contents)).toBe(computePayloadSha256([...contents].reverse()));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('round-trips encrypted entries through the existing password envelope', async () => {
|
|
157
|
+
const bundle = await createLearnCardBundle(createWallet(), {
|
|
158
|
+
password: 'correct horse battery staple',
|
|
159
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
160
|
+
fetchStatusLists: false,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(bundle.manifest.encryption.mode).toBe('argon2id-aes-256-gcm');
|
|
164
|
+
expect(bundle.manifest.contents.some(entry => entry.encrypted)).toBe(true);
|
|
165
|
+
|
|
166
|
+
const read = await readLearnCardBundleData(bundle.data, {
|
|
167
|
+
password: 'correct horse battery staple',
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(read.entries.some(entry => entry.content.includes('urn:uuid:credential-1'))).toBe(
|
|
171
|
+
true
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('exports fake wallet credentials, presentations, consent records, and missing URI warnings', async () => {
|
|
176
|
+
const bundle = await createLearnCardBundle(createWallet(), {
|
|
177
|
+
encrypt: false,
|
|
178
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
179
|
+
fetchStatusLists: false,
|
|
180
|
+
});
|
|
181
|
+
const zip = await JSZip.loadAsync(bundle.data);
|
|
182
|
+
const manifest = JSON.parse(await zip.file('manifest.json')!.async('string')) as {
|
|
183
|
+
contents: Array<Record<string, unknown>>;
|
|
184
|
+
};
|
|
185
|
+
const credentialManifestEntry = manifest.contents.find(
|
|
186
|
+
entry => entry.type === 'credential'
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(bundle.manifest.contents.filter(entry => entry.type === 'credential')).toHaveLength(
|
|
190
|
+
2
|
|
191
|
+
);
|
|
192
|
+
expect(
|
|
193
|
+
bundle.manifest.contents.filter(entry => entry.type === 'presentation')
|
|
194
|
+
).toHaveLength(1);
|
|
195
|
+
expect(
|
|
196
|
+
bundle.manifest.contents.filter(entry => entry.type === 'consent-record')
|
|
197
|
+
).toHaveLength(1);
|
|
198
|
+
expect(
|
|
199
|
+
bundle.warnings.some(warning =>
|
|
200
|
+
warning.startsWith('Could not resolve wallet index URI ')
|
|
201
|
+
)
|
|
202
|
+
).toBe(true);
|
|
203
|
+
expect(credentialManifestEntry?.indexRecordRef).toBeDefined();
|
|
204
|
+
expect(credentialManifestEntry).not.toHaveProperty('indexRecord');
|
|
205
|
+
expect(credentialManifestEntry).not.toHaveProperty('title');
|
|
206
|
+
expect(credentialManifestEntry).not.toHaveProperty('category');
|
|
207
|
+
expect(JSON.stringify(manifest)).not.toContain('"credential-record"');
|
|
208
|
+
});
|
|
209
|
+
it('imports credentials and presentations into a fresh wallet store and index', async () => {
|
|
210
|
+
const source = createWallet();
|
|
211
|
+
const target = createWallet();
|
|
212
|
+
const bundle = await createLearnCardBundle(source, {
|
|
213
|
+
encrypt: false,
|
|
214
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
215
|
+
fetchStatusLists: false,
|
|
216
|
+
});
|
|
217
|
+
const dir = await mkdtemp(join(tmpdir(), 'learncard-bundle-'));
|
|
218
|
+
const path = join(dir, 'bundle.zip');
|
|
219
|
+
|
|
220
|
+
await writeFile(path, bundle.data);
|
|
221
|
+
|
|
222
|
+
const report = await importLearnCardBundle(path, { wallet: target });
|
|
223
|
+
|
|
224
|
+
expect(report.warnings).toContain(
|
|
225
|
+
'Bundle credential signatures were not verified before import; only import bundles from sources you trust.'
|
|
226
|
+
);
|
|
227
|
+
expect(report.errors).toEqual([]);
|
|
228
|
+
expect(report.importedCredentials).toBe(2);
|
|
229
|
+
expect(report.importedPresentations).toBe(1);
|
|
230
|
+
expect(report.skipped).toBe(
|
|
231
|
+
Object.values(report.skippedByType).reduce((sum, count) => sum + count, 0)
|
|
232
|
+
);
|
|
233
|
+
expect(report.skippedByType['index-record']).toBeGreaterThan(0);
|
|
234
|
+
expect(report.skippedByType['consent-record']).toBe(1);
|
|
235
|
+
expect(target.stored).toHaveLength(3);
|
|
236
|
+
expect(target.added).toHaveLength(3);
|
|
237
|
+
expect(target.added[0]?.sourceExport).toMatchObject({
|
|
238
|
+
manifestCreatedAt: '2024-01-01T00:00:00.000Z',
|
|
239
|
+
credentialId: 'urn:uuid:credential-1',
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('verifies signatures before import when requested', async () => {
|
|
244
|
+
const source = createWallet();
|
|
245
|
+
const target = createWallet();
|
|
246
|
+
const bundle = await createLearnCardBundle(source, {
|
|
247
|
+
encrypt: false,
|
|
248
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
249
|
+
fetchStatusLists: false,
|
|
250
|
+
});
|
|
251
|
+
const dir = await mkdtemp(join(tmpdir(), 'learncard-bundle-'));
|
|
252
|
+
const path = join(dir, 'bundle.zip');
|
|
253
|
+
|
|
254
|
+
await writeFile(path, bundle.data);
|
|
255
|
+
|
|
256
|
+
const report = await importLearnCardBundle(path, {
|
|
257
|
+
wallet: target,
|
|
258
|
+
verifyBeforeImport: true,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(report.warnings).not.toContain(
|
|
262
|
+
'Bundle credential signatures were not verified before import; only import bundles from sources you trust.'
|
|
263
|
+
);
|
|
264
|
+
expect(report.errors).toEqual([]);
|
|
265
|
+
expect(report.importedCredentials).toBe(2);
|
|
266
|
+
expect(report.importedPresentations).toBe(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('rejects status-list URLs that are not public HTTPS URLs', async () => {
|
|
270
|
+
const wallet = createWallet();
|
|
271
|
+
|
|
272
|
+
wallet.read.get = async uri =>
|
|
273
|
+
uri.includes('credentials')
|
|
274
|
+
? {
|
|
275
|
+
...vc,
|
|
276
|
+
credentialStatus: {
|
|
277
|
+
type: 'BitstringStatusListEntry',
|
|
278
|
+
statusListCredential: 'http://127.0.0.1/latest-status?token=secret',
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
: uri.includes('presentations')
|
|
282
|
+
? vp
|
|
283
|
+
: undefined;
|
|
284
|
+
|
|
285
|
+
const bundle = await createLearnCardBundle(wallet, {
|
|
286
|
+
encrypt: false,
|
|
287
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(bundle.warnings).toContain(
|
|
291
|
+
'Could not cache status-list credential http://[redacted-host]/latest-status?[redacted]: status-list URL must use https'
|
|
292
|
+
);
|
|
293
|
+
expect(JSON.stringify(bundle.manifest)).not.toContain('127.0.0.1');
|
|
294
|
+
expect(JSON.stringify(bundle.manifest)).not.toContain('token=secret');
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { webcrypto } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import JSZip from 'jszip';
|
|
4
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { createLearnCardBundle } from '../exportBundle';
|
|
7
|
+
import { sha256Hex } from '../crypto';
|
|
8
|
+
import { computePayloadSha256 } from '../manifest';
|
|
9
|
+
import { readLearnCardBundleSeedData, restoreLearnCardFromBundleData } from '../restoreBundle';
|
|
10
|
+
import type { JsonValue, LearnCardBundleWallet } from '../types';
|
|
11
|
+
|
|
12
|
+
const { initLearnCardMock } = vi.hoisted(() => ({ initLearnCardMock: vi.fn() }));
|
|
13
|
+
|
|
14
|
+
vi.mock('@learncard/init', () => ({
|
|
15
|
+
initLearnCard: initLearnCardMock,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const createWallet = (): LearnCardBundleWallet & {
|
|
19
|
+
stored: JsonValue[];
|
|
20
|
+
added: Array<Record<string, unknown>>;
|
|
21
|
+
} => ({
|
|
22
|
+
stored: [],
|
|
23
|
+
added: [],
|
|
24
|
+
id: {
|
|
25
|
+
did: method => (method ? `did:${method}:holder` : 'did:key:holder'),
|
|
26
|
+
keypair: (algorithm = 'ed25519') => ({
|
|
27
|
+
kty: 'OKP',
|
|
28
|
+
crv: algorithm,
|
|
29
|
+
d: `${algorithm}-private`,
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
invoke: {
|
|
33
|
+
getKey: () => '1'.repeat(64),
|
|
34
|
+
resolveDid: async did => ({ id: did, verificationMethod: [] }),
|
|
35
|
+
},
|
|
36
|
+
index: {
|
|
37
|
+
LearnCloud: {
|
|
38
|
+
get: async () => [],
|
|
39
|
+
add: async record => record,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
read: {
|
|
43
|
+
get: async () => undefined,
|
|
44
|
+
},
|
|
45
|
+
store: {
|
|
46
|
+
LearnCloud: {},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
beforeAll(() => {
|
|
51
|
+
Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
initLearnCardMock.mockReset();
|
|
56
|
+
initLearnCardMock.mockResolvedValue({ restored: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('restore helpers', () => {
|
|
60
|
+
it('reads the exported seed from an encrypted bundle', async () => {
|
|
61
|
+
const bundle = await createLearnCardBundle(createWallet(), {
|
|
62
|
+
password: 'correct horse battery staple',
|
|
63
|
+
fetchStatusLists: false,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await expect(
|
|
67
|
+
readLearnCardBundleSeedData(bundle.data, { password: 'correct horse battery staple' })
|
|
68
|
+
).resolves.toBe('1'.repeat(64));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('restores a LearnCard by passing the exported seed to initLearnCard', async () => {
|
|
72
|
+
const bundle = await createLearnCardBundle(createWallet(), {
|
|
73
|
+
password: 'correct horse battery staple',
|
|
74
|
+
fetchStatusLists: false,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const restored = await restoreLearnCardFromBundleData(bundle.data, {
|
|
78
|
+
password: 'correct horse battery staple',
|
|
79
|
+
init: { network: true, allowRemoteContexts: true, didkit: 'node' },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(initLearnCardMock).toHaveBeenCalledWith({
|
|
83
|
+
seed: '1'.repeat(64),
|
|
84
|
+
network: true,
|
|
85
|
+
allowRemoteContexts: true,
|
|
86
|
+
didkit: 'node',
|
|
87
|
+
});
|
|
88
|
+
expect(restored).toEqual({ restored: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects malformed exported seeds', async () => {
|
|
92
|
+
const bundle = await createLearnCardBundle(createWallet(), {
|
|
93
|
+
password: 'correct horse battery staple',
|
|
94
|
+
fetchStatusLists: false,
|
|
95
|
+
});
|
|
96
|
+
const zip = await JSZip.loadAsync(bundle.data);
|
|
97
|
+
const seedPath = 'keys/private-key-seed.txt.enc';
|
|
98
|
+
const tamperedSeed = `${'f'.repeat(63)}\n`;
|
|
99
|
+
const contents = bundle.manifest.contents.map(entry =>
|
|
100
|
+
entry.path === seedPath
|
|
101
|
+
? { ...entry, encrypted: false, sha256: sha256Hex(tamperedSeed) }
|
|
102
|
+
: entry
|
|
103
|
+
);
|
|
104
|
+
const manifest = {
|
|
105
|
+
...bundle.manifest,
|
|
106
|
+
contents,
|
|
107
|
+
payloadSha256: computePayloadSha256(contents),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
zip.file(seedPath, tamperedSeed, { date: new Date('2024-01-01T00:00:00.000Z') });
|
|
111
|
+
zip.file('manifest.json', `${JSON.stringify(manifest, null, 2)}\n`);
|
|
112
|
+
|
|
113
|
+
const data = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
114
|
+
|
|
115
|
+
await expect(
|
|
116
|
+
readLearnCardBundleSeedData(data, { password: 'correct horse battery staple' })
|
|
117
|
+
).rejects.toThrow(
|
|
118
|
+
'LearnCard bundle key-private-seed must be exactly 64 hexadecimal characters'
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
});
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { decryptWithPassword, encryptWithPassword } from '@learncard/sss-key-manager';
|
|
4
|
+
|
|
5
|
+
import type { EncryptedPayloadEnvelope } from './types';
|
|
6
|
+
|
|
7
|
+
export const sha256Hex = (bytes: string | Buffer): string =>
|
|
8
|
+
createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
|
|
10
|
+
export const stableStringify = (value: unknown): string => {
|
|
11
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
12
|
+
|
|
13
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
14
|
+
|
|
15
|
+
const object = value as Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
return `{${Object.keys(object)
|
|
18
|
+
.sort()
|
|
19
|
+
.map(key => `${JSON.stringify(key)}:${stableStringify(object[key])}`)
|
|
20
|
+
.join(',')}}`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const encodePayload = async (
|
|
24
|
+
plaintext: string,
|
|
25
|
+
options: { encrypt: boolean; password?: string }
|
|
26
|
+
): Promise<{ stored: string; encrypted: boolean }> => {
|
|
27
|
+
if (!options.encrypt) return { stored: plaintext, encrypted: false };
|
|
28
|
+
|
|
29
|
+
if (!options.password)
|
|
30
|
+
throw new Error('A password is required for encrypted LearnCard exports');
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
stored: JSON.stringify(await encryptWithPassword(plaintext, options.password), null, 2),
|
|
34
|
+
encrypted: true,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const decodePayload = async (
|
|
39
|
+
stored: string,
|
|
40
|
+
options: { encrypted: boolean; password?: string }
|
|
41
|
+
): Promise<string> => {
|
|
42
|
+
if (!options.encrypted) return stored;
|
|
43
|
+
|
|
44
|
+
if (!options.password)
|
|
45
|
+
throw new Error('A password is required to decrypt this LearnCard export');
|
|
46
|
+
|
|
47
|
+
const envelope = JSON.parse(stored) as EncryptedPayloadEnvelope;
|
|
48
|
+
|
|
49
|
+
return decryptWithPassword(
|
|
50
|
+
envelope.ciphertext,
|
|
51
|
+
envelope.iv,
|
|
52
|
+
envelope.salt,
|
|
53
|
+
options.password,
|
|
54
|
+
envelope.kdfParams
|
|
55
|
+
);
|
|
56
|
+
};
|