@learncard/sss-key-manager 0.1.14 → 0.1.16
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 +17 -17
- package/dist/sss-key-manager.cjs.development.js +126 -36
- package/dist/sss-key-manager.cjs.development.js.map +2 -2
- package/dist/sss-key-manager.cjs.production.min.js +6 -6
- package/dist/sss-key-manager.cjs.production.min.js.map +3 -3
- package/dist/sss-key-manager.esm.js +126 -36
- package/dist/sss-key-manager.esm.js.map +2 -2
- package/package.json +62 -52
- package/src/api-client.ts +257 -0
- package/src/atomic-operations.test.ts +327 -0
- package/src/atomic-operations.ts +275 -0
- package/src/auth-coordinator.test.ts +13 -0
- package/src/auth-coordinator.ts +12 -0
- package/src/critical-paths.test.ts +380 -0
- package/src/crypto.test.ts +214 -0
- package/src/crypto.ts +203 -0
- package/src/index.ts +146 -0
- package/src/key-manager.test.ts +330 -0
- package/src/key-manager.ts +323 -0
- package/src/passkey.test.ts +59 -0
- package/src/passkey.ts +222 -0
- package/src/qr-crypto.test.ts +122 -0
- package/src/qr-crypto.ts +206 -0
- package/src/qr-login-notify.test.ts +95 -0
- package/src/qr-login.test.ts +548 -0
- package/src/qr-login.ts +339 -0
- package/src/recovery-phrase.test.ts +287 -0
- package/src/recovery-phrase.ts +131 -0
- package/src/sss-strategy.test.ts +1956 -0
- package/src/sss-strategy.ts +1119 -0
- package/src/sss.test.ts +242 -0
- package/src/sss.ts +49 -0
- package/src/storage.test.ts +530 -0
- package/src/storage.ts +467 -0
- package/src/types.ts +200 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for QR Login Client
|
|
3
|
+
*
|
|
4
|
+
* Tests the full flow: session creation → approval → polling + decryption.
|
|
5
|
+
* Uses a mock fetch to simulate the relay server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createQrLoginSession,
|
|
12
|
+
pollQrLoginSession,
|
|
13
|
+
getQrLoginSessionInfo,
|
|
14
|
+
approveQrLoginSession,
|
|
15
|
+
pollUntilApproved,
|
|
16
|
+
} from './qr-login';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
generateEphemeralKeypair,
|
|
20
|
+
encryptShareForTransfer,
|
|
21
|
+
} from './qr-crypto';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const SERVER_URL = 'https://api.example.com';
|
|
28
|
+
|
|
29
|
+
const supportsX25519 = async (): Promise<boolean> => {
|
|
30
|
+
try {
|
|
31
|
+
await crypto.subtle.generateKey({ name: 'X25519' }, false, ['deriveBits']);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// In-memory "server" state for mock fetch
|
|
39
|
+
let sessions: Map<string, {
|
|
40
|
+
sessionId: string;
|
|
41
|
+
shortCode: string;
|
|
42
|
+
publicKey: string;
|
|
43
|
+
status: 'pending' | 'approved';
|
|
44
|
+
encryptedPayload?: string;
|
|
45
|
+
approverDid?: string;
|
|
46
|
+
}>;
|
|
47
|
+
|
|
48
|
+
let shortCodeMap: Map<string, string>;
|
|
49
|
+
|
|
50
|
+
const mockFetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
51
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
52
|
+
const method = init?.method ?? 'GET';
|
|
53
|
+
const body = init?.body ? JSON.parse(init.body as string) : undefined;
|
|
54
|
+
|
|
55
|
+
// POST /qr-login/session — create session
|
|
56
|
+
if (urlStr.endsWith('/qr-login/session') && method === 'POST') {
|
|
57
|
+
const sessionId = `session-${Math.random().toString(36).slice(2, 8)}`;
|
|
58
|
+
const shortCode = Math.floor(Math.random() * 100_000_000).toString().padStart(8, '0');
|
|
59
|
+
|
|
60
|
+
sessions.set(sessionId, {
|
|
61
|
+
sessionId,
|
|
62
|
+
shortCode,
|
|
63
|
+
publicKey: body.publicKey,
|
|
64
|
+
status: 'pending',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
shortCodeMap.set(shortCode, sessionId);
|
|
68
|
+
|
|
69
|
+
return new Response(JSON.stringify({
|
|
70
|
+
sessionId,
|
|
71
|
+
shortCode,
|
|
72
|
+
expiresInSeconds: 120,
|
|
73
|
+
}), { status: 200 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// GET /qr-login/session/:lookup — get session
|
|
77
|
+
const getMatch = urlStr.match(/\/qr-login\/session\/([^/]+)$/);
|
|
78
|
+
|
|
79
|
+
if (getMatch && method === 'GET') {
|
|
80
|
+
let sessionId = getMatch[1]!;
|
|
81
|
+
|
|
82
|
+
if (/^\d{8}$/.test(sessionId)) {
|
|
83
|
+
const resolved = shortCodeMap.get(sessionId);
|
|
84
|
+
|
|
85
|
+
if (!resolved) {
|
|
86
|
+
return new Response('Not found', { status: 404 });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
sessionId = resolved;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const session = sessions.get(sessionId);
|
|
93
|
+
|
|
94
|
+
if (!session) {
|
|
95
|
+
return new Response('Not found', { status: 404 });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return new Response(JSON.stringify({
|
|
99
|
+
sessionId: session.sessionId,
|
|
100
|
+
publicKey: session.publicKey,
|
|
101
|
+
status: session.status,
|
|
102
|
+
encryptedPayload: session.encryptedPayload,
|
|
103
|
+
approverDid: session.approverDid,
|
|
104
|
+
}), { status: 200 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// POST /qr-login/session/:id/approve
|
|
108
|
+
const approveMatch = urlStr.match(/\/qr-login\/session\/([^/]+)\/approve$/);
|
|
109
|
+
|
|
110
|
+
if (approveMatch && method === 'POST') {
|
|
111
|
+
const sessionId = approveMatch[1]!;
|
|
112
|
+
const session = sessions.get(sessionId);
|
|
113
|
+
|
|
114
|
+
if (!session) {
|
|
115
|
+
return new Response('Not found', { status: 404 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
session.status = 'approved';
|
|
119
|
+
session.encryptedPayload = body.encryptedPayload;
|
|
120
|
+
session.approverDid = body.approverDid;
|
|
121
|
+
|
|
122
|
+
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return new Response('Not found', { status: 404 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('QR Login Client', async () => {
|
|
129
|
+
const supported = await supportsX25519();
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
sessions = new Map();
|
|
133
|
+
shortCodeMap = new Map();
|
|
134
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
135
|
+
mockFetch.mockClear();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
vi.unstubAllGlobals();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// -----------------------------------------------------------------------
|
|
143
|
+
// Session creation
|
|
144
|
+
// -----------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
it.skipIf(!supported)('createQrLoginSession returns session info and ephemeral keypair', async () => {
|
|
147
|
+
const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
|
|
148
|
+
serverUrl: SERVER_URL,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(session.sessionId).toBeTruthy();
|
|
152
|
+
expect(session.shortCode).toMatch(/^\d{8}$/);
|
|
153
|
+
expect(session.expiresInSeconds).toBe(120);
|
|
154
|
+
|
|
155
|
+
expect(ephemeralKeypair.publicKey).toBeTruthy();
|
|
156
|
+
expect(ephemeralKeypair.privateKey.type).toBe('private');
|
|
157
|
+
|
|
158
|
+
expect(qrPayload.sessionId).toBe(session.sessionId);
|
|
159
|
+
expect(qrPayload.publicKey).toBe(ephemeralKeypair.publicKey);
|
|
160
|
+
expect(qrPayload.serverUrl).toBe(SERVER_URL);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// -----------------------------------------------------------------------
|
|
164
|
+
// Session info retrieval
|
|
165
|
+
// -----------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
it.skipIf(!supported)('getQrLoginSessionInfo retrieves session by ID', async () => {
|
|
168
|
+
const { session } = await createQrLoginSession({ serverUrl: SERVER_URL });
|
|
169
|
+
|
|
170
|
+
const info = await getQrLoginSessionInfo({ serverUrl: SERVER_URL }, session.sessionId);
|
|
171
|
+
|
|
172
|
+
expect(info.sessionId).toBe(session.sessionId);
|
|
173
|
+
expect(info.status).toBe('pending');
|
|
174
|
+
expect(info.publicKey).toBeTruthy();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it.skipIf(!supported)('getQrLoginSessionInfo retrieves session by short code', async () => {
|
|
178
|
+
const { session } = await createQrLoginSession({ serverUrl: SERVER_URL });
|
|
179
|
+
|
|
180
|
+
const info = await getQrLoginSessionInfo({ serverUrl: SERVER_URL }, session.shortCode);
|
|
181
|
+
|
|
182
|
+
expect(info.sessionId).toBe(session.sessionId);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// -----------------------------------------------------------------------
|
|
186
|
+
// Full flow: create → approve → poll → decrypt
|
|
187
|
+
// -----------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
it.skipIf(!supported)('full cross-device flow: Device B creates, Device A approves, Device B decrypts', async () => {
|
|
190
|
+
const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
|
|
191
|
+
const approverDid = 'did:key:zApprover123';
|
|
192
|
+
|
|
193
|
+
// --- Device B: create session ---
|
|
194
|
+
const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
|
|
195
|
+
serverUrl: SERVER_URL,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// --- Device A: scan QR, get session info ---
|
|
199
|
+
const sessionInfo = await getQrLoginSessionInfo(
|
|
200
|
+
{ serverUrl: SERVER_URL },
|
|
201
|
+
qrPayload.sessionId
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(sessionInfo.status).toBe('pending');
|
|
205
|
+
|
|
206
|
+
// --- Device A: approve (encrypt + push) ---
|
|
207
|
+
await approveQrLoginSession(
|
|
208
|
+
{ serverUrl: SERVER_URL },
|
|
209
|
+
session.sessionId,
|
|
210
|
+
deviceShare,
|
|
211
|
+
approverDid,
|
|
212
|
+
sessionInfo.publicKey
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// --- Device B: poll and decrypt ---
|
|
216
|
+
const result = await pollQrLoginSession(
|
|
217
|
+
{ serverUrl: SERVER_URL },
|
|
218
|
+
session.sessionId,
|
|
219
|
+
ephemeralKeypair.privateKey
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect(result.status).toBe('approved');
|
|
223
|
+
|
|
224
|
+
if (result.status === 'approved') {
|
|
225
|
+
expect(result.deviceShare).toBe(deviceShare);
|
|
226
|
+
expect(result.approverDid).toBe(approverDid);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// -----------------------------------------------------------------------
|
|
231
|
+
// Polling
|
|
232
|
+
// -----------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
it.skipIf(!supported)('pollQrLoginSession returns pending when not yet approved', async () => {
|
|
235
|
+
const { session, ephemeralKeypair } = await createQrLoginSession({
|
|
236
|
+
serverUrl: SERVER_URL,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const result = await pollQrLoginSession(
|
|
240
|
+
{ serverUrl: SERVER_URL },
|
|
241
|
+
session.sessionId,
|
|
242
|
+
ephemeralKeypair.privateKey
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(result.status).toBe('pending');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it.skipIf(!supported)('pollUntilApproved resolves when session is approved', async () => {
|
|
249
|
+
const deviceShare = 'share-for-polling-test';
|
|
250
|
+
const approverDid = 'did:key:zPoller';
|
|
251
|
+
|
|
252
|
+
const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
|
|
253
|
+
serverUrl: SERVER_URL,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Simulate Device A approving after a short delay
|
|
257
|
+
setTimeout(async () => {
|
|
258
|
+
const info = await getQrLoginSessionInfo(
|
|
259
|
+
{ serverUrl: SERVER_URL },
|
|
260
|
+
qrPayload.sessionId
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
await approveQrLoginSession(
|
|
264
|
+
{ serverUrl: SERVER_URL },
|
|
265
|
+
session.sessionId,
|
|
266
|
+
deviceShare,
|
|
267
|
+
approverDid,
|
|
268
|
+
info.publicKey
|
|
269
|
+
);
|
|
270
|
+
}, 50);
|
|
271
|
+
|
|
272
|
+
const result = await pollUntilApproved(
|
|
273
|
+
{ serverUrl: SERVER_URL },
|
|
274
|
+
session.sessionId,
|
|
275
|
+
ephemeralKeypair.privateKey,
|
|
276
|
+
{ intervalMs: 30, timeoutMs: 5000 }
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
expect(result.deviceShare).toBe(deviceShare);
|
|
280
|
+
expect(result.approverDid).toBe(approverDid);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it.skipIf(!supported)('pollUntilApproved can be aborted', async () => {
|
|
284
|
+
const { session, ephemeralKeypair } = await createQrLoginSession({
|
|
285
|
+
serverUrl: SERVER_URL,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const controller = new AbortController();
|
|
289
|
+
|
|
290
|
+
// Abort after 100ms
|
|
291
|
+
setTimeout(() => controller.abort(), 100);
|
|
292
|
+
|
|
293
|
+
await expect(
|
|
294
|
+
pollUntilApproved(
|
|
295
|
+
{ serverUrl: SERVER_URL },
|
|
296
|
+
session.sessionId,
|
|
297
|
+
ephemeralKeypair.privateKey,
|
|
298
|
+
{ intervalMs: 30, timeoutMs: 10000, signal: controller.signal }
|
|
299
|
+
)
|
|
300
|
+
).rejects.toThrow('aborted');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// -----------------------------------------------------------------------
|
|
304
|
+
// Account hint in encrypted payload
|
|
305
|
+
// -----------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
it.skipIf(!supported)('full flow with accountHint: hint is encrypted and returned to Device B', async () => {
|
|
308
|
+
const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
|
|
309
|
+
const approverDid = 'did:key:zHintTest';
|
|
310
|
+
const accountHint = 'user@example.com';
|
|
311
|
+
|
|
312
|
+
const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
|
|
313
|
+
serverUrl: SERVER_URL,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const sessionInfo = await getQrLoginSessionInfo(
|
|
317
|
+
{ serverUrl: SERVER_URL },
|
|
318
|
+
qrPayload.sessionId
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// Device A approves WITH accountHint
|
|
322
|
+
await approveQrLoginSession(
|
|
323
|
+
{ serverUrl: SERVER_URL },
|
|
324
|
+
session.sessionId,
|
|
325
|
+
deviceShare,
|
|
326
|
+
approverDid,
|
|
327
|
+
sessionInfo.publicKey,
|
|
328
|
+
accountHint
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const result = await pollQrLoginSession(
|
|
332
|
+
{ serverUrl: SERVER_URL },
|
|
333
|
+
session.sessionId,
|
|
334
|
+
ephemeralKeypair.privateKey
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
expect(result.status).toBe('approved');
|
|
338
|
+
|
|
339
|
+
if (result.status === 'approved') {
|
|
340
|
+
expect(result.deviceShare).toBe(deviceShare);
|
|
341
|
+
expect(result.approverDid).toBe(approverDid);
|
|
342
|
+
expect(result.accountHint).toBe(accountHint);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// -----------------------------------------------------------------------
|
|
347
|
+
// shareVersion in encrypted envelope
|
|
348
|
+
// -----------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
it.skipIf(!supported)('full flow with shareVersion: version is encrypted and returned to Device B', async () => {
|
|
351
|
+
const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
|
|
352
|
+
const approverDid = 'did:key:zVersionTest';
|
|
353
|
+
const shareVersion = 5;
|
|
354
|
+
|
|
355
|
+
const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
|
|
356
|
+
serverUrl: SERVER_URL,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const sessionInfo = await getQrLoginSessionInfo(
|
|
360
|
+
{ serverUrl: SERVER_URL },
|
|
361
|
+
qrPayload.sessionId
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// Device A approves WITH shareVersion
|
|
365
|
+
await approveQrLoginSession(
|
|
366
|
+
{ serverUrl: SERVER_URL },
|
|
367
|
+
session.sessionId,
|
|
368
|
+
deviceShare,
|
|
369
|
+
approverDid,
|
|
370
|
+
sessionInfo.publicKey,
|
|
371
|
+
undefined, // no accountHint
|
|
372
|
+
shareVersion
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const result = await pollQrLoginSession(
|
|
376
|
+
{ serverUrl: SERVER_URL },
|
|
377
|
+
session.sessionId,
|
|
378
|
+
ephemeralKeypair.privateKey
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
expect(result.status).toBe('approved');
|
|
382
|
+
|
|
383
|
+
if (result.status === 'approved') {
|
|
384
|
+
expect(result.deviceShare).toBe(deviceShare);
|
|
385
|
+
expect(result.approverDid).toBe(approverDid);
|
|
386
|
+
expect(result.shareVersion).toBe(shareVersion);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it.skipIf(!supported)('shareVersion is undefined when not provided by Device A', async () => {
|
|
391
|
+
const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
|
|
392
|
+
const approverDid = 'did:key:zNoVersion';
|
|
393
|
+
|
|
394
|
+
const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
|
|
395
|
+
serverUrl: SERVER_URL,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const sessionInfo = await getQrLoginSessionInfo(
|
|
399
|
+
{ serverUrl: SERVER_URL },
|
|
400
|
+
qrPayload.sessionId
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
// Device A approves WITHOUT shareVersion
|
|
404
|
+
await approveQrLoginSession(
|
|
405
|
+
{ serverUrl: SERVER_URL },
|
|
406
|
+
session.sessionId,
|
|
407
|
+
deviceShare,
|
|
408
|
+
approverDid,
|
|
409
|
+
sessionInfo.publicKey
|
|
410
|
+
// no accountHint, no shareVersion
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const result = await pollQrLoginSession(
|
|
414
|
+
{ serverUrl: SERVER_URL },
|
|
415
|
+
session.sessionId,
|
|
416
|
+
ephemeralKeypair.privateKey
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
expect(result.status).toBe('approved');
|
|
420
|
+
|
|
421
|
+
if (result.status === 'approved') {
|
|
422
|
+
expect(result.deviceShare).toBe(deviceShare);
|
|
423
|
+
expect(result.shareVersion).toBeUndefined();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it.skipIf(!supported)('full flow with both accountHint and shareVersion', async () => {
|
|
428
|
+
const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
|
|
429
|
+
const approverDid = 'did:key:zBothFields';
|
|
430
|
+
const accountHint = 'user@example.com';
|
|
431
|
+
const shareVersion = 3;
|
|
432
|
+
|
|
433
|
+
const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
|
|
434
|
+
serverUrl: SERVER_URL,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const sessionInfo = await getQrLoginSessionInfo(
|
|
438
|
+
{ serverUrl: SERVER_URL },
|
|
439
|
+
qrPayload.sessionId
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
await approveQrLoginSession(
|
|
443
|
+
{ serverUrl: SERVER_URL },
|
|
444
|
+
session.sessionId,
|
|
445
|
+
deviceShare,
|
|
446
|
+
approverDid,
|
|
447
|
+
sessionInfo.publicKey,
|
|
448
|
+
accountHint,
|
|
449
|
+
shareVersion
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const result = await pollQrLoginSession(
|
|
453
|
+
{ serverUrl: SERVER_URL },
|
|
454
|
+
session.sessionId,
|
|
455
|
+
ephemeralKeypair.privateKey
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
expect(result.status).toBe('approved');
|
|
459
|
+
|
|
460
|
+
if (result.status === 'approved') {
|
|
461
|
+
expect(result.deviceShare).toBe(deviceShare);
|
|
462
|
+
expect(result.approverDid).toBe(approverDid);
|
|
463
|
+
expect(result.accountHint).toBe(accountHint);
|
|
464
|
+
expect(result.shareVersion).toBe(shareVersion);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it.skipIf(!supported)('pollUntilApproved returns shareVersion', async () => {
|
|
469
|
+
const deviceShare = 'share-for-poll-version';
|
|
470
|
+
const approverDid = 'did:key:zPollVersion';
|
|
471
|
+
const shareVersion = 7;
|
|
472
|
+
|
|
473
|
+
const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
|
|
474
|
+
serverUrl: SERVER_URL,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Simulate Device A approving after a short delay
|
|
478
|
+
setTimeout(async () => {
|
|
479
|
+
const info = await getQrLoginSessionInfo(
|
|
480
|
+
{ serverUrl: SERVER_URL },
|
|
481
|
+
qrPayload.sessionId
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
await approveQrLoginSession(
|
|
485
|
+
{ serverUrl: SERVER_URL },
|
|
486
|
+
session.sessionId,
|
|
487
|
+
deviceShare,
|
|
488
|
+
approverDid,
|
|
489
|
+
info.publicKey,
|
|
490
|
+
undefined,
|
|
491
|
+
shareVersion
|
|
492
|
+
);
|
|
493
|
+
}, 50);
|
|
494
|
+
|
|
495
|
+
const result = await pollUntilApproved(
|
|
496
|
+
{ serverUrl: SERVER_URL },
|
|
497
|
+
session.sessionId,
|
|
498
|
+
ephemeralKeypair.privateKey,
|
|
499
|
+
{ intervalMs: 30, timeoutMs: 5000 }
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
expect(result.deviceShare).toBe(deviceShare);
|
|
503
|
+
expect(result.approverDid).toBe(approverDid);
|
|
504
|
+
expect(result.shareVersion).toBe(shareVersion);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// -----------------------------------------------------------------------
|
|
508
|
+
// Short code flow (typed code instead of QR scan)
|
|
509
|
+
// -----------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
it.skipIf(!supported)('full flow via short code (phone → desktop)', async () => {
|
|
512
|
+
const deviceShare = 'share-via-short-code';
|
|
513
|
+
const approverDid = 'did:key:zDesktopUser';
|
|
514
|
+
|
|
515
|
+
// Device B (phone) creates session
|
|
516
|
+
const { session, ephemeralKeypair } = await createQrLoginSession({
|
|
517
|
+
serverUrl: SERVER_URL,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Device A (desktop) types in the 6-digit code
|
|
521
|
+
const info = await getQrLoginSessionInfo(
|
|
522
|
+
{ serverUrl: SERVER_URL },
|
|
523
|
+
session.shortCode
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
// Device A approves
|
|
527
|
+
await approveQrLoginSession(
|
|
528
|
+
{ serverUrl: SERVER_URL },
|
|
529
|
+
info.sessionId,
|
|
530
|
+
deviceShare,
|
|
531
|
+
approverDid,
|
|
532
|
+
info.publicKey
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
// Device B polls and decrypts
|
|
536
|
+
const result = await pollQrLoginSession(
|
|
537
|
+
{ serverUrl: SERVER_URL },
|
|
538
|
+
session.sessionId,
|
|
539
|
+
ephemeralKeypair.privateKey
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
expect(result.status).toBe('approved');
|
|
543
|
+
|
|
544
|
+
if (result.status === 'approved') {
|
|
545
|
+
expect(result.deviceShare).toBe(deviceShare);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
});
|