@bod.ee/db 0.9.1 → 0.10.2

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,1037 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+ import { KeyAuthEngine } from '../src/server/KeyAuthEngine.ts';
4
+ import { BodClient } from '../src/client/BodClient.ts';
5
+ import {
6
+ fingerprint, generateEd25519KeyPair, signData, verifySignature,
7
+ encryptPrivateKey, decryptPrivateKey, encodeToken, decodeToken,
8
+ generateNonce,
9
+ } from '../src/shared/keyAuth.ts';
10
+ import {
11
+ generateKeyPair as browserGenerateKeyPair, sign as browserSign,
12
+ browserFingerprint, wrapPublicKey,
13
+ } from '../src/shared/keyAuth.browser.ts';
14
+
15
+ describe('KeyAuth', () => {
16
+ let db: BodDB;
17
+
18
+ beforeEach(() => {
19
+ db = new BodDB({ path: ':memory:', sweepInterval: 0 });
20
+ });
21
+
22
+ afterEach(() => {
23
+ db.close();
24
+ });
25
+
26
+ // --- Shared crypto utils ---
27
+
28
+ describe('crypto utils', () => {
29
+ it('fingerprint is stable', () => {
30
+ const kp = generateEd25519KeyPair();
31
+ const fp1 = fingerprint(kp.publicKeyBase64);
32
+ const fp2 = fingerprint(kp.publicKeyBase64);
33
+ expect(fp1).toBe(fp2);
34
+ expect(fp1).toHaveLength(64); // SHA-256 hex
35
+ });
36
+
37
+ it('encrypt/decrypt private key round-trip', () => {
38
+ const kp = generateEd25519KeyPair();
39
+ const enc = encryptPrivateKey(kp.privateKey, 'mypassword');
40
+ const dec = decryptPrivateKey(enc.encrypted, enc.salt, enc.iv, enc.authTag, 'mypassword');
41
+ expect(Buffer.from(dec)).toEqual(Buffer.from(kp.privateKey));
42
+ });
43
+
44
+ it('decrypt with wrong password fails', () => {
45
+ const kp = generateEd25519KeyPair();
46
+ const enc = encryptPrivateKey(kp.privateKey, 'correct');
47
+ expect(() => decryptPrivateKey(enc.encrypted, enc.salt, enc.iv, enc.authTag, 'wrong')).toThrow();
48
+ });
49
+
50
+ it('sign/verify round-trip', () => {
51
+ const kp = generateEd25519KeyPair();
52
+ const data = Buffer.from('hello');
53
+ const sig = signData(data, kp.privateKey);
54
+ expect(verifySignature(data, sig, kp.publicKey)).toBe(true);
55
+ expect(verifySignature(Buffer.from('other'), sig, kp.publicKey)).toBe(false);
56
+ });
57
+ });
58
+
59
+ // --- KeyAuthEngine ---
60
+
61
+ describe('KeyAuthEngine', () => {
62
+ let engine: KeyAuthEngine;
63
+
64
+ beforeEach(() => {
65
+ engine = new KeyAuthEngine(db);
66
+ });
67
+
68
+ it('stores server public key in DB', () => {
69
+ const pubKey = db.get('_auth/server/publicKey');
70
+ expect(pubKey).toBeTruthy();
71
+ expect(typeof pubKey).toBe('string');
72
+ });
73
+
74
+ it('server key persists (in-memory regenerates each time but is consistent within session)', () => {
75
+ expect(engine.fp).toHaveLength(64);
76
+ expect(engine.publicKey).toBeTruthy();
77
+ });
78
+
79
+ describe('account creation', () => {
80
+ it('creates account with encrypted key', () => {
81
+ const result = engine.createAccount('password123', ['admin'], 'Alice');
82
+ expect(result.publicKey).toBeTruthy();
83
+ expect(result.fingerprint).toHaveLength(64);
84
+
85
+ const account = db.get(`_auth/accounts/${result.fingerprint}`) as any;
86
+ expect(account.publicKey).toBe(result.publicKey);
87
+ expect(account.encryptedPrivateKey).toBeTruthy();
88
+ expect(account.roles).toEqual(['admin']);
89
+ expect(account.displayName).toBe('Alice');
90
+ });
91
+
92
+ it('first account auto-becomes root', () => {
93
+ const first = engine.createAccount('pw1');
94
+ expect(first.isRoot).toBe(true);
95
+ const root = db.get('_auth/root') as any;
96
+ expect(root.fingerprint).toBe(first.fingerprint);
97
+
98
+ const second = engine.createAccount('pw2');
99
+ expect(second.isRoot).toBe(false);
100
+ });
101
+
102
+ it('auto-links device when devicePublicKey provided', () => {
103
+ const deviceKp = generateEd25519KeyPair();
104
+ const result = engine.createAccount('pw', [], 'Test', deviceKp.publicKeyBase64);
105
+ expect(result.deviceFingerprint).toBeTruthy();
106
+
107
+ // Device can authenticate via challenge-response
108
+ const { nonce } = engine.challenge();
109
+ const sig = signData(Buffer.from(nonce), deviceKp.privateKey);
110
+ const auth = engine.verify(deviceKp.publicKeyBase64, sig.toString('base64'), nonce);
111
+ expect(auth).toBeTruthy();
112
+ const ctx = engine.validateToken(auth!.token);
113
+ expect(ctx!.accountFingerprint).toBe(result.fingerprint);
114
+ });
115
+ });
116
+
117
+ describe('challenge-response auth', () => {
118
+ it('happy path: account authenticates', () => {
119
+ const acct = engine.createAccount('pw');
120
+ const account = db.get(`_auth/accounts/${acct.fingerprint}`) as any;
121
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
122
+
123
+ const { nonce } = engine.challenge();
124
+ const sig = signData(Buffer.from(nonce), privKey);
125
+ const result = engine.verify(acct.publicKey, sig.toString('base64'), nonce);
126
+
127
+ expect(result).toBeTruthy();
128
+ expect(result!.token).toContain('.');
129
+ expect(result!.expiresAt).toBeGreaterThan(Date.now());
130
+ });
131
+
132
+ it('replay nonce fails', () => {
133
+ const acct = engine.createAccount('pw');
134
+ const account = db.get(`_auth/accounts/${acct.fingerprint}`) as any;
135
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
136
+
137
+ const { nonce } = engine.challenge();
138
+ const sig = signData(Buffer.from(nonce), privKey);
139
+
140
+ const r1 = engine.verify(acct.publicKey, sig.toString('base64'), nonce);
141
+ expect(r1).toBeTruthy();
142
+
143
+ const r2 = engine.verify(acct.publicKey, sig.toString('base64'), nonce);
144
+ expect(r2).toBeNull();
145
+ });
146
+
147
+ it('invalid signature fails', () => {
148
+ engine.createAccount('pw');
149
+ const other = generateEd25519KeyPair();
150
+ const { nonce } = engine.challenge();
151
+ const sig = signData(Buffer.from(nonce), other.privateKey);
152
+ const result = engine.verify(other.publicKeyBase64, sig.toString('base64'), nonce);
153
+ expect(result).toBeNull();
154
+ });
155
+
156
+ it('unknown public key fails', () => {
157
+ const kp = generateEd25519KeyPair();
158
+ const { nonce } = engine.challenge();
159
+ const sig = signData(Buffer.from(nonce), kp.privateKey);
160
+ const result = engine.verify(kp.publicKeyBase64, sig.toString('base64'), nonce);
161
+ expect(result).toBeNull();
162
+ });
163
+ });
164
+
165
+ describe('token validation', () => {
166
+ it('valid token returns context', () => {
167
+ // Create a dummy account first so the test account isn't auto-root
168
+ engine.createAccount('dummy');
169
+ const acct = engine.createAccount('pw', ['editor']);
170
+ const account = db.get(`_auth/accounts/${acct.fingerprint}`) as any;
171
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
172
+
173
+ const { nonce } = engine.challenge();
174
+ const sig = signData(Buffer.from(nonce), privKey);
175
+ const { token } = engine.verify(acct.publicKey, sig.toString('base64'), nonce)!;
176
+
177
+ const ctx = engine.validateToken(token);
178
+ expect(ctx).toBeTruthy();
179
+ expect(ctx!.accountFingerprint).toBe(acct.fingerprint);
180
+ expect(ctx!.roles).toEqual(['editor']);
181
+ expect(ctx!.isRoot).toBe(false);
182
+ });
183
+
184
+ it('tampered token fails', () => {
185
+ const acct = engine.createAccount('pw');
186
+ const account = db.get(`_auth/accounts/${acct.fingerprint}`) as any;
187
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
188
+
189
+ const { nonce } = engine.challenge();
190
+ const sig = signData(Buffer.from(nonce), privKey);
191
+ const { token } = engine.verify(acct.publicKey, sig.toString('base64'), nonce)!;
192
+
193
+ const tampered = token.slice(0, -4) + 'XXXX';
194
+ expect(engine.validateToken(tampered)).toBeNull();
195
+ });
196
+
197
+ it('expired token with zero clock skew fails', () => {
198
+ // Create engine with 0 sessionTtl and 0 clockSkew
199
+ const strictEngine = new KeyAuthEngine(db, { sessionTtl: 0, clockSkew: 0 });
200
+ const acct = strictEngine.createAccount('pw');
201
+ const account = db.get(`_auth/accounts/${acct.fingerprint}`) as any;
202
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
203
+
204
+ const { nonce } = strictEngine.challenge();
205
+ const sig = signData(Buffer.from(nonce), privKey);
206
+ const result = strictEngine.verify(acct.publicKey, sig.toString('base64'), nonce);
207
+ expect(result).toBeTruthy();
208
+ // exp = Date.now() + 0 ms, with 0 clockSkew → already expired
209
+ // Small chance of same-ms execution, so use a tiny delay
210
+ const before = Date.now();
211
+ // Spin until at least 1ms has passed
212
+ while (Date.now() === before) {}
213
+ expect(strictEngine.validateToken(result!.token)).toBeNull();
214
+ });
215
+
216
+ it('revoked session returns null', () => {
217
+ const acct = engine.createAccount('pw');
218
+ const account = db.get(`_auth/accounts/${acct.fingerprint}`) as any;
219
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
220
+
221
+ const { nonce } = engine.challenge();
222
+ const sig = signData(Buffer.from(nonce), privKey);
223
+ const { token } = engine.verify(acct.publicKey, sig.toString('base64'), nonce)!;
224
+
225
+ const ctx = engine.validateToken(token);
226
+ expect(ctx).toBeTruthy();
227
+
228
+ engine.revokeSession(ctx!.sid);
229
+ expect(engine.validateToken(token)).toBeNull();
230
+ });
231
+ });
232
+
233
+ describe('root auth', () => {
234
+ it('root key authenticates with isRoot=true', () => {
235
+ const rootKp = generateEd25519KeyPair();
236
+ engine.initRoot(rootKp.publicKeyBase64);
237
+
238
+ const { nonce } = engine.challenge();
239
+ const sig = signData(Buffer.from(nonce), rootKp.privateKey);
240
+ const result = engine.verify(rootKp.publicKeyBase64, sig.toString('base64'), nonce);
241
+
242
+ expect(result).toBeTruthy();
243
+ const ctx = engine.validateToken(result!.token);
244
+ expect(ctx!.isRoot).toBe(true);
245
+ });
246
+ });
247
+
248
+ describe('device linking', () => {
249
+ it('links device and authenticates via device key', () => {
250
+ const acct = engine.createAccount('pw', ['viewer']);
251
+ const deviceKp = generateEd25519KeyPair();
252
+
253
+ const linked = engine.linkDevice(acct.fingerprint, 'pw', deviceKp.publicKeyBase64, 'My Phone');
254
+ expect(linked).toBeTruthy();
255
+ expect(linked!.fingerprint).toHaveLength(64);
256
+
257
+ // Verify reverse index was created
258
+ const reverseIdx = db.get(`_auth/deviceIndex/${linked!.fingerprint}`);
259
+ expect(reverseIdx).toBe(acct.fingerprint);
260
+
261
+ // Now authenticate with device key
262
+ const { nonce } = engine.challenge();
263
+ const sig = signData(Buffer.from(nonce), deviceKp.privateKey);
264
+ const result = engine.verify(deviceKp.publicKeyBase64, sig.toString('base64'), nonce);
265
+
266
+ expect(result).toBeTruthy();
267
+ const ctx = engine.validateToken(result!.token);
268
+ expect(ctx!.accountFingerprint).toBe(acct.fingerprint);
269
+ expect(ctx!.roles).toEqual(['viewer']);
270
+ });
271
+
272
+ it('link with wrong password fails', () => {
273
+ const acct = engine.createAccount('pw');
274
+ const deviceKp = generateEd25519KeyPair();
275
+ const result = engine.linkDevice(acct.fingerprint, 'wrong', deviceKp.publicKeyBase64);
276
+ expect(result).toBeNull();
277
+ });
278
+
279
+ it('device revocation invalidates sessions and removes reverse index', () => {
280
+ const acct = engine.createAccount('pw');
281
+ const deviceKp = generateEd25519KeyPair();
282
+ engine.linkDevice(acct.fingerprint, 'pw', deviceKp.publicKeyBase64);
283
+
284
+ const { nonce } = engine.challenge();
285
+ const sig = signData(Buffer.from(nonce), deviceKp.privateKey);
286
+ const { token } = engine.verify(deviceKp.publicKeyBase64, sig.toString('base64'), nonce)!;
287
+ expect(engine.validateToken(token)).toBeTruthy();
288
+
289
+ const deviceFp = fingerprint(deviceKp.publicKeyBase64);
290
+ engine.revokeDevice(acct.fingerprint, deviceFp);
291
+
292
+ // Session invalidated
293
+ expect(engine.validateToken(token)).toBeNull();
294
+ // Reverse index cleaned up
295
+ expect(db.get(`_auth/deviceIndex/${deviceFp}`)).toBeNull();
296
+ // Can't auth anymore
297
+ const { nonce: n2 } = engine.challenge();
298
+ const sig2 = signData(Buffer.from(n2), deviceKp.privateKey);
299
+ expect(engine.verify(deviceKp.publicKeyBase64, sig2.toString('base64'), n2)).toBeNull();
300
+ });
301
+ });
302
+
303
+ describe('password change', () => {
304
+ it('changes password atomically, fingerprint stays stable', () => {
305
+ const acct = engine.createAccount('old-pw');
306
+ const fpBefore = acct.fingerprint;
307
+
308
+ const changed = engine.changePassword(acct.fingerprint, 'old-pw', 'new-pw');
309
+ expect(changed).toBe(true);
310
+
311
+ const account = db.get(`_auth/accounts/${acct.fingerprint}`) as any;
312
+ expect(account.fingerprint).toBe(fpBefore);
313
+
314
+ // Old password can't decrypt
315
+ expect(() => decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'old-pw')).toThrow();
316
+
317
+ // New password works
318
+ const deviceKp = generateEd25519KeyPair();
319
+ const linked = engine.linkDevice(acct.fingerprint, 'new-pw', deviceKp.publicKeyBase64);
320
+ expect(linked).toBeTruthy();
321
+ });
322
+
323
+ it('wrong old password fails', () => {
324
+ const acct = engine.createAccount('pw');
325
+ expect(engine.changePassword(acct.fingerprint, 'wrong', 'new')).toBe(false);
326
+ });
327
+ });
328
+
329
+ describe('IAM', () => {
330
+ it('creates and lists roles', () => {
331
+ engine.createRole({ id: 'admin', name: 'Admin', permissions: [{ path: '', read: true, write: true }] });
332
+ engine.createRole({ id: 'viewer', name: 'Viewer', permissions: [{ path: '', read: true }] });
333
+
334
+ const roles = engine.listRoles();
335
+ expect(roles).toHaveLength(2);
336
+ expect(roles.map(r => r.id).sort()).toEqual(['admin', 'viewer']);
337
+ });
338
+
339
+ it('updates account roles', () => {
340
+ const acct = engine.createAccount('pw', ['viewer']);
341
+ engine.updateAccountRoles(acct.fingerprint, ['admin', 'editor']);
342
+ const account = db.get(`_auth/accounts/${acct.fingerprint}`) as any;
343
+ expect(account.roles).toEqual(['admin', 'editor']);
344
+ });
345
+
346
+ it('deletes role', () => {
347
+ engine.createRole({ id: 'temp', name: 'Temp', permissions: [] });
348
+ expect(engine.getRole('temp')).toBeTruthy();
349
+ engine.deleteRole('temp');
350
+ expect(engine.getRole('temp')).toBeNull();
351
+ });
352
+
353
+ it('lists accounts', () => {
354
+ engine.createAccount('pw1', [], 'Alice');
355
+ engine.createAccount('pw2', [], 'Bob');
356
+ expect(engine.listAccounts()).toHaveLength(2);
357
+ });
358
+ });
359
+
360
+ describe('listDevices', () => {
361
+ it('returns linked devices', () => {
362
+ const acct = engine.createAccount('pw', ['viewer']);
363
+ const d1 = generateEd25519KeyPair();
364
+ const d2 = generateEd25519KeyPair();
365
+ engine.linkDevice(acct.fingerprint, 'pw', d1.publicKeyBase64, 'Phone');
366
+ engine.linkDevice(acct.fingerprint, 'pw', d2.publicKeyBase64, 'Laptop');
367
+ const devices = engine.listDevices(acct.fingerprint);
368
+ expect(devices).toHaveLength(2);
369
+ expect(devices.map(d => d.name).sort()).toEqual(['Laptop', 'Phone']);
370
+ });
371
+
372
+ it('returns empty for account with no devices', () => {
373
+ const acct = engine.createAccount('pw');
374
+ expect(engine.listDevices(acct.fingerprint)).toHaveLength(0);
375
+ });
376
+ });
377
+
378
+ describe('listSessions', () => {
379
+ it('returns all sessions', () => {
380
+ const acct = engine.createAccount('pw');
381
+ const account = db.get(`_auth/accounts/${acct.fingerprint}`) as any;
382
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
383
+ // Create 2 sessions
384
+ const { nonce: n1 } = engine.challenge();
385
+ engine.verify(acct.publicKey, signData(Buffer.from(n1), privKey).toString('base64'), n1);
386
+ const { nonce: n2 } = engine.challenge();
387
+ engine.verify(acct.publicKey, signData(Buffer.from(n2), privKey).toString('base64'), n2);
388
+ const sessions = engine.listSessions();
389
+ expect(sessions.length).toBeGreaterThanOrEqual(2);
390
+ });
391
+
392
+ it('filters by account fingerprint', () => {
393
+ const a1 = engine.createAccount('pw1');
394
+ const a2 = engine.createAccount('pw2');
395
+ const acc1 = db.get(`_auth/accounts/${a1.fingerprint}`) as any;
396
+ const pk1 = decryptPrivateKey(acc1.encryptedPrivateKey, acc1.salt, acc1.iv, acc1.authTag, 'pw1');
397
+ const acc2 = db.get(`_auth/accounts/${a2.fingerprint}`) as any;
398
+ const pk2 = decryptPrivateKey(acc2.encryptedPrivateKey, acc2.salt, acc2.iv, acc2.authTag, 'pw2');
399
+ const { nonce: n1 } = engine.challenge();
400
+ engine.verify(a1.publicKey, signData(Buffer.from(n1), pk1).toString('base64'), n1);
401
+ const { nonce: n2 } = engine.challenge();
402
+ engine.verify(a2.publicKey, signData(Buffer.from(n2), pk2).toString('base64'), n2);
403
+ const s1 = engine.listSessions(a1.fingerprint);
404
+ expect(s1).toHaveLength(1);
405
+ expect(s1[0].accountFingerprint).toBe(a1.fingerprint);
406
+ });
407
+ });
408
+
409
+ describe('QR cross-device approval', () => {
410
+ it('requestApproval → pollApproval returns pending', () => {
411
+ const kp = generateEd25519KeyPair();
412
+ const { requestId } = engine.requestApproval(kp.publicKeyBase64);
413
+ const poll = engine.pollApproval(requestId);
414
+ expect(poll).toBeTruthy();
415
+ expect(poll!.status).toBe('pending');
416
+ expect(poll!.token).toBeUndefined();
417
+ });
418
+
419
+ it('approveDevice → pollApproval returns approved with token', () => {
420
+ const acct = engine.createAccount('pw', ['admin']);
421
+ const deviceKp = generateEd25519KeyPair();
422
+ const { requestId } = engine.requestApproval(deviceKp.publicKeyBase64);
423
+ const result = engine.approveDevice(requestId, acct.fingerprint);
424
+ expect(result).toBeTruthy();
425
+ expect(result!.fingerprint).toHaveLength(64);
426
+ expect(result!.token).toContain('.');
427
+ // Poll should show approved
428
+ const poll = engine.pollApproval(requestId);
429
+ expect(poll!.status).toBe('approved');
430
+ expect(poll!.token).toBeTruthy();
431
+ // Token should be valid
432
+ const ctx = engine.validateToken(result!.token);
433
+ expect(ctx).toBeTruthy();
434
+ expect(ctx!.accountFingerprint).toBe(acct.fingerprint);
435
+ });
436
+
437
+ it('expired approval returns expired status', () => {
438
+ const poll = engine.pollApproval('nonexistent-id');
439
+ expect(poll).toBeTruthy();
440
+ expect(poll!.status).toBe('expired');
441
+ });
442
+
443
+ it('approveDevice on nonexistent request returns null', () => {
444
+ const acct = engine.createAccount('pw');
445
+ expect(engine.approveDevice('nonexistent', acct.fingerprint)).toBeNull();
446
+ });
447
+
448
+ it('double-approve returns null', () => {
449
+ const acct = engine.createAccount('pw');
450
+ const kp = generateEd25519KeyPair();
451
+ const { requestId } = engine.requestApproval(kp.publicKeyBase64);
452
+ const r1 = engine.approveDevice(requestId, acct.fingerprint);
453
+ expect(r1).toBeTruthy();
454
+ const r2 = engine.approveDevice(requestId, acct.fingerprint);
455
+ expect(r2).toBeNull();
456
+ });
457
+ });
458
+
459
+ describe('authCallback', () => {
460
+ it('returns function compatible with TransportOptions.auth', () => {
461
+ const cb = engine.authCallback();
462
+ expect(typeof cb).toBe('function');
463
+ expect(cb('bogus.token')).toBeNull();
464
+ });
465
+ });
466
+ });
467
+
468
+ describe('registerDevice', () => {
469
+ let engine: KeyAuthEngine;
470
+
471
+ beforeEach(() => {
472
+ engine = new KeyAuthEngine(db);
473
+ });
474
+
475
+ it('registers device from client-generated public key', () => {
476
+ const kp = generateEd25519KeyPair();
477
+ const result = engine.registerDevice(kp.publicKeyBase64, 'My Laptop');
478
+ expect(result.fingerprint).toHaveLength(64);
479
+ const account = db.get(`_auth/accounts/${result.fingerprint}`) as any;
480
+ expect(account.publicKey).toBe(kp.publicKeyBase64);
481
+ expect(account.displayName).toBe('My Laptop');
482
+ expect(account.encryptedPrivateKey).toBe(''); // no server-side key
483
+ });
484
+
485
+ it('is idempotent', () => {
486
+ const kp = generateEd25519KeyPair();
487
+ const r1 = engine.registerDevice(kp.publicKeyBase64);
488
+ const r2 = engine.registerDevice(kp.publicKeyBase64);
489
+ expect(r1.fingerprint).toBe(r2.fingerprint);
490
+ });
491
+
492
+ it('registered device can authenticate via challenge-response', () => {
493
+ const kp = generateEd25519KeyPair();
494
+ engine.registerDevice(kp.publicKeyBase64);
495
+ const { nonce } = engine.challenge();
496
+ const sig = signData(Buffer.from(nonce), kp.privateKey);
497
+ const result = engine.verify(kp.publicKeyBase64, sig.toString('base64'), nonce);
498
+ expect(result).toBeTruthy();
499
+ expect(result!.token).toContain('.');
500
+ });
501
+ });
502
+
503
+ describe('browser crypto ↔ server compat', () => {
504
+ let engine: KeyAuthEngine;
505
+
506
+ beforeEach(() => {
507
+ engine = new KeyAuthEngine(db);
508
+ });
509
+
510
+ it('browser-generated key registers and authenticates on server', async () => {
511
+ const kp = await browserGenerateKeyPair();
512
+ // Register
513
+ const reg = engine.registerDevice(kp.publicKeyBase64);
514
+ expect(reg.fingerprint).toBe(kp.fingerprint);
515
+ // Challenge-response
516
+ const { nonce } = engine.challenge();
517
+ const sigBase64 = await browserSign(nonce, kp.privateKeyBase64);
518
+ const result = engine.verify(kp.publicKeyBase64, sigBase64, nonce);
519
+ expect(result).toBeTruthy();
520
+ const ctx = engine.validateToken(result!.token);
521
+ expect(ctx).toBeTruthy();
522
+ expect(ctx!.accountFingerprint).toBe(kp.fingerprint);
523
+ });
524
+
525
+ it('fingerprint matches between browser and server', async () => {
526
+ const kp = await browserGenerateKeyPair();
527
+ const serverFp = fingerprint(kp.publicKeyBase64);
528
+ const browserFp = await browserFingerprint(kp.publicKeyBase64);
529
+ expect(browserFp).toBe(serverFp);
530
+ });
531
+ });
532
+
533
+ describe('defaultDeny', () => {
534
+ it('blocks unmatched paths when enabled', () => {
535
+ const denyDb = new BodDB({
536
+ path: ':memory:', sweepInterval: 0, defaultDeny: true,
537
+ rules: { 'public/$any': { read: true, write: true } },
538
+ });
539
+ expect(denyDb.rules.check('read', 'public/hello', null)).toBe(true);
540
+ expect(denyDb.rules.check('read', 'secret/data', null)).toBe(false);
541
+ expect(denyDb.rules.check('write', 'secret/data', null)).toBe(false);
542
+ denyDb.close();
543
+ });
544
+
545
+ it('allows unmatched paths when disabled (default)', () => {
546
+ const openDb = new BodDB({
547
+ path: ':memory:', sweepInterval: 0,
548
+ rules: { 'restricted/$any': { write: false } },
549
+ });
550
+ expect(openDb.rules.check('read', 'anything', null)).toBe(true);
551
+ expect(openDb.rules.check('write', 'anything', null)).toBe(true);
552
+ openDb.close();
553
+ });
554
+
555
+ it('denies when rule matches but op is undefined', () => {
556
+ const denyDb = new BodDB({
557
+ path: ':memory:', sweepInterval: 0, defaultDeny: true,
558
+ rules: { 'posts/$id': { read: true } }, // write not defined
559
+ });
560
+ expect(denyDb.rules.check('read', 'posts/1', null)).toBe(true);
561
+ expect(denyDb.rules.check('write', 'posts/1', null)).toBe(false);
562
+ denyDb.close();
563
+ });
564
+ });
565
+
566
+ describe('device account guards', () => {
567
+ let engine: KeyAuthEngine;
568
+
569
+ beforeEach(() => {
570
+ engine = new KeyAuthEngine(db);
571
+ });
572
+
573
+ it('changePassword returns false for device-registered account', () => {
574
+ const kp = generateEd25519KeyPair();
575
+ const reg = engine.registerDevice(kp.publicKeyBase64);
576
+ expect(engine.changePassword(reg.fingerprint, 'any', 'new')).toBe(false);
577
+ });
578
+
579
+ it('linkDevice returns null for device-registered account', () => {
580
+ const kp = generateEd25519KeyPair();
581
+ const reg = engine.registerDevice(kp.publicKeyBase64);
582
+ const deviceKp = generateEd25519KeyPair();
583
+ expect(engine.linkDevice(reg.fingerprint, 'any', deviceKp.publicKeyBase64)).toBeNull();
584
+ });
585
+ });
586
+
587
+ describe('closed registration', () => {
588
+ let authDb: BodDB;
589
+ let port: number;
590
+
591
+ beforeEach(() => {
592
+ port = 15400 + Math.floor(Math.random() * 1000);
593
+ authDb = new BodDB({ path: ':memory:', sweepInterval: 0, keyAuth: { allowOpenRegistration: false } });
594
+ authDb.serve({ port });
595
+ });
596
+
597
+ afterEach(() => {
598
+ authDb.close();
599
+ });
600
+
601
+ it('rejects unauthenticated device registration', async () => {
602
+ const ws = new WebSocket(`ws://localhost:${port}`);
603
+ await new Promise<void>((res) => { ws.onopen = () => res(); });
604
+ const id = '1';
605
+ const kp = generateEd25519KeyPair();
606
+ const result = await new Promise<any>((resolve) => {
607
+ ws.addEventListener('message', (e: MessageEvent) => {
608
+ const data = JSON.parse(e.data);
609
+ if (data.id === id) resolve(data);
610
+ });
611
+ ws.send(JSON.stringify({ id, op: 'auth-register-device', publicKey: kp.publicKeyBase64 }));
612
+ });
613
+ expect(result.ok).toBe(false);
614
+ expect(result.code).toBe('AUTH_REQUIRED');
615
+ ws.close();
616
+ });
617
+ });
618
+
619
+ describe('BodDB integration', () => {
620
+ it('backward compat: no keyAuth config = unchanged', () => {
621
+ const plainDb = new BodDB({ path: ':memory:', sweepInterval: 0 });
622
+ expect(plainDb.keyAuth).toBeNull();
623
+ plainDb.close();
624
+ });
625
+
626
+ it('keyAuth config initializes engine', () => {
627
+ const authDb = new BodDB({ path: ':memory:', sweepInterval: 0, keyAuth: {} });
628
+ expect(authDb.keyAuth).toBeTruthy();
629
+ expect(authDb.keyAuth!.fp).toHaveLength(64);
630
+ authDb.close();
631
+ });
632
+
633
+ it('keyAuth with rootPublicKey sets root', () => {
634
+ const rootKp = generateEd25519KeyPair();
635
+ const authDb = new BodDB({
636
+ path: ':memory:', sweepInterval: 0,
637
+ keyAuth: { rootPublicKey: rootKp.publicKeyBase64 },
638
+ });
639
+ const root = authDb.get('_auth/root') as any;
640
+ expect(root.publicKey).toBe(rootKp.publicKeyBase64);
641
+ authDb.close();
642
+ });
643
+ });
644
+
645
+ describe('Transport integration', () => {
646
+ let authDb: BodDB;
647
+ let port: number;
648
+
649
+ beforeEach(() => {
650
+ port = 14400 + Math.floor(Math.random() * 1000);
651
+ authDb = new BodDB({ path: ':memory:', sweepInterval: 0, keyAuth: {} });
652
+ authDb.serve({ port });
653
+ });
654
+
655
+ afterEach(() => {
656
+ authDb.close();
657
+ });
658
+
659
+ function wsSend(ws: WebSocket, msg: any): Promise<any> {
660
+ return new Promise((resolve) => {
661
+ const id = String(Math.random());
662
+ const handler = (e: MessageEvent) => {
663
+ const data = JSON.parse(e.data);
664
+ if (data.id === id) { ws.removeEventListener('message', handler); resolve(data); }
665
+ };
666
+ ws.addEventListener('message', handler);
667
+ ws.send(JSON.stringify({ id, ...msg }));
668
+ });
669
+ }
670
+
671
+ async function openWs(): Promise<WebSocket> {
672
+ const ws = new WebSocket(`ws://localhost:${port}`);
673
+ await new Promise<void>((res) => { ws.onopen = () => res(); });
674
+ return ws;
675
+ }
676
+
677
+ async function authenticateWs(ws: WebSocket, privKey: Uint8Array, pubKey: string): Promise<any> {
678
+ const challenge = await wsSend(ws, { op: 'auth-challenge' });
679
+ const sig = signData(Buffer.from(challenge.data.nonce), privKey);
680
+ return wsSend(ws, {
681
+ op: 'auth-verify', publicKey: pubKey,
682
+ signature: sig.toString('base64'), nonce: challenge.data.nonce,
683
+ });
684
+ }
685
+
686
+ it('challenge-response via WS', async () => {
687
+ const acct = authDb.keyAuth!.createAccount('pw');
688
+ const account = authDb.get(`_auth/accounts/${acct.fingerprint}`) as any;
689
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
690
+
691
+ const ws = await openWs();
692
+ const challenge = await wsSend(ws, { op: 'auth-challenge' });
693
+ expect(challenge.ok).toBe(true);
694
+ expect(challenge.data.nonce).toBeTruthy();
695
+
696
+ const sig = signData(Buffer.from(challenge.data.nonce), privKey);
697
+ const verify = await wsSend(ws, {
698
+ op: 'auth-verify', publicKey: acct.publicKey,
699
+ signature: sig.toString('base64'), nonce: challenge.data.nonce,
700
+ });
701
+ expect(verify.ok).toBe(true);
702
+ expect(verify.data.token).toContain('.');
703
+
704
+ ws.close();
705
+ });
706
+
707
+ it('_auth/ write blocked via WS set', async () => {
708
+ const ws = await openWs();
709
+ const result = await wsSend(ws, { op: 'set', path: '_auth/evil', value: 'hack' });
710
+ expect(result.ok).toBe(false);
711
+ expect(result.code).toBe('PERMISSION_DENIED');
712
+ ws.close();
713
+ });
714
+
715
+ it('_auth/ write blocked via WS delete', async () => {
716
+ const ws = await openWs();
717
+ const result = await wsSend(ws, { op: 'delete', path: '_auth/sessions/abc' });
718
+ expect(result.ok).toBe(false);
719
+ expect(result.code).toBe('PERMISSION_DENIED');
720
+ ws.close();
721
+ });
722
+
723
+ it('_auth/ write blocked via REST PUT', async () => {
724
+ const res = await fetch(`http://localhost:${port}/db/_auth/evil`, {
725
+ method: 'PUT', body: JSON.stringify('hack'), headers: { 'Content-Type': 'application/json' },
726
+ });
727
+ const json = await res.json() as any;
728
+ expect(json.ok).toBe(false);
729
+ expect(json.code).toBe('PERMISSION_DENIED');
730
+ });
731
+
732
+ it('_auth/ write blocked via REST DELETE', async () => {
733
+ const res = await fetch(`http://localhost:${port}/db/_auth/sessions/abc`, { method: 'DELETE' });
734
+ const json = await res.json() as any;
735
+ expect(json.ok).toBe(false);
736
+ });
737
+
738
+ it('auth-change-password works without session (password is the auth)', async () => {
739
+ const acct = authDb.keyAuth!.createAccount('pw');
740
+ const ws = await openWs();
741
+ const result = await wsSend(ws, {
742
+ op: 'auth-change-password', fingerprint: acct.fingerprint,
743
+ oldPassword: 'pw', newPassword: 'new',
744
+ });
745
+ expect(result.ok).toBe(true);
746
+ // Verify old password no longer works
747
+ const fail = await wsSend(ws, {
748
+ op: 'auth-change-password', fingerprint: acct.fingerprint,
749
+ oldPassword: 'pw', newPassword: 'other',
750
+ });
751
+ expect(fail.ok).toBe(false);
752
+ ws.close();
753
+ });
754
+
755
+ it('auth-link-device works without session (password is the auth)', async () => {
756
+ const acct = authDb.keyAuth!.createAccount('pw');
757
+ const deviceKp = generateEd25519KeyPair();
758
+ const ws = await openWs();
759
+ const result = await wsSend(ws, {
760
+ op: 'auth-link-device', accountFingerprint: acct.fingerprint,
761
+ password: 'pw', devicePublicKey: deviceKp.publicKeyBase64,
762
+ });
763
+ expect(result.ok).toBe(true);
764
+ expect(result.data.fingerprint).toBeTruthy();
765
+ ws.close();
766
+ });
767
+
768
+ it('auth-change-password non-root cannot change other account password', async () => {
769
+ authDb.keyAuth!.createAccount('root-dummy'); // first account becomes root
770
+ const acct1 = authDb.keyAuth!.createAccount('pw1');
771
+ const acct2 = authDb.keyAuth!.createAccount('pw2');
772
+ const account1 = authDb.get(`_auth/accounts/${acct1.fingerprint}`) as any;
773
+ const privKey1 = decryptPrivateKey(account1.encryptedPrivateKey, account1.salt, account1.iv, account1.authTag, 'pw1');
774
+
775
+ const ws = await openWs();
776
+ await authenticateWs(ws, privKey1, acct1.publicKey);
777
+
778
+ // Try to change acct2's password — should fail
779
+ const result = await wsSend(ws, {
780
+ op: 'auth-change-password', fingerprint: acct2.fingerprint,
781
+ oldPassword: 'pw2', newPassword: 'hacked',
782
+ });
783
+ expect(result.ok).toBe(false);
784
+ expect(result.code).toBe('PERMISSION_DENIED');
785
+ ws.close();
786
+ });
787
+
788
+ it('auth-create-account bootstrap: first account via WS (no auth needed)', async () => {
789
+ const deviceKp = await browserGenerateKeyPair();
790
+ const ws = await openWs();
791
+ const result = await wsSend(ws, {
792
+ op: 'auth-create-account', password: 'root-pw', displayName: 'Root',
793
+ devicePublicKey: deviceKp.publicKeyBase64,
794
+ });
795
+ expect(result.ok).toBe(true);
796
+ expect(result.data.isRoot).toBe(true);
797
+ expect(result.data.deviceFingerprint).toBeTruthy();
798
+
799
+ // With open registration (default), second create is allowed
800
+ const ws2 = await openWs();
801
+ const second = await wsSend(ws2, { op: 'auth-create-account', password: 'user2' });
802
+ expect(second.ok).toBe(true);
803
+ expect(second.data.isRoot).toBe(false);
804
+ ws.close();
805
+ ws2.close();
806
+ });
807
+
808
+ it('auth-create-account closed registration: second account rejected', async () => {
809
+ // Use a separate DB with closed registration
810
+ const closedPort = 14400 + Math.floor(Math.random() * 1000);
811
+ const closedDb = new BodDB({ path: ':memory:', sweepInterval: 0, keyAuth: { allowOpenRegistration: false } });
812
+ closedDb.serve({ port: closedPort });
813
+ const openClosedWs = () => new Promise<WebSocket>((res, rej) => {
814
+ const w = new WebSocket(`ws://localhost:${closedPort}`);
815
+ w.addEventListener('open', () => res(w));
816
+ w.addEventListener('error', rej);
817
+ });
818
+ const deviceKp = await browserGenerateKeyPair();
819
+ const ws = await openClosedWs();
820
+ const result = await wsSend(ws, {
821
+ op: 'auth-create-account', password: 'root-pw', displayName: 'Root',
822
+ devicePublicKey: deviceKp.publicKeyBase64,
823
+ });
824
+ expect(result.ok).toBe(true);
825
+
826
+ const ws2 = await openClosedWs();
827
+ const reject = await wsSend(ws2, { op: 'auth-create-account', password: 'hacker' });
828
+ expect(reject.ok).toBe(false);
829
+ expect(reject.code).toBe('AUTH_REQUIRED');
830
+ ws.close();
831
+ ws2.close();
832
+ closedDb.close();
833
+ });
834
+
835
+ it('auth-register-device via WS', async () => {
836
+ const kp = await browserGenerateKeyPair();
837
+ const ws = await openWs();
838
+ const reg = await wsSend(ws, { op: 'auth-register-device', publicKey: kp.publicKeyBase64, displayName: 'Test Device' });
839
+ expect(reg.ok).toBe(true);
840
+ expect(reg.data.fingerprint).toBe(kp.fingerprint);
841
+ // Now authenticate
842
+ const challenge = await wsSend(ws, { op: 'auth-challenge' });
843
+ const sig = await browserSign(challenge.data.nonce, kp.privateKeyBase64);
844
+ const verify = await wsSend(ws, { op: 'auth-verify', publicKey: kp.publicKeyBase64, signature: sig, nonce: challenge.data.nonce });
845
+ expect(verify.ok).toBe(true);
846
+ expect(verify.data.token).toContain('.');
847
+ ws.close();
848
+ });
849
+
850
+ it('BodClient auth.registerDevice convenience method', async () => {
851
+ const kp = await browserGenerateKeyPair();
852
+ const client = new BodClient({ url: `ws://localhost:${port}` });
853
+ await client.connect();
854
+ const reg = await client.auth.registerDevice(kp.publicKeyBase64, 'My Device');
855
+ expect(reg.fingerprint).toBe(kp.fingerprint);
856
+ // Authenticate with convenience method
857
+ const result = await client.auth.authenticate(kp.publicKeyBase64, (nonce) => browserSign(nonce, kp.privateKeyBase64));
858
+ expect(result.token).toContain('.');
859
+ client.disconnect();
860
+ });
861
+
862
+ it('BodClient autoAuth — full device identity flow', async () => {
863
+ // Use in-memory storage to avoid localStorage dependency
864
+ const storage = new Map<string, string>();
865
+ const memStorage = {
866
+ get: (k: string) => storage.get(k) ?? null,
867
+ set: (k: string, v: string) => storage.set(k, v),
868
+ remove: (k: string) => storage.delete(k),
869
+ };
870
+ const client = new BodClient({
871
+ url: `ws://localhost:${port}`,
872
+ autoAuth: { displayName: 'Auto Device', storage: memStorage },
873
+ });
874
+ await client.connect();
875
+ // Should be authenticated and have a fingerprint
876
+ expect(client.deviceFingerprint).toBeTruthy();
877
+ expect(client.deviceFingerprint).toHaveLength(64);
878
+ // Should be able to do CRUD
879
+ await client.set('auto/test', 'works');
880
+ expect(await client.get('auto/test')).toBe('works');
881
+ // Keys should be in storage
882
+ expect(storage.has('boddb-device-pubkey')).toBe(true);
883
+ expect(storage.has('boddb-device-privkey')).toBe(true);
884
+ client.disconnect();
885
+
886
+ // Reconnect with same storage — should reuse keys
887
+ const client2 = new BodClient({
888
+ url: `ws://localhost:${port}`,
889
+ autoAuth: { storage: memStorage },
890
+ });
891
+ await client2.connect();
892
+ expect(client2.deviceFingerprint).toBe(client.deviceFingerprint);
893
+ expect(await client2.get('auto/test')).toBe('works');
894
+ client2.disconnect();
895
+ });
896
+
897
+ it('auth-create-role via WS (root only)', async () => {
898
+ // Set up root
899
+ const rootKp = generateEd25519KeyPair();
900
+ authDb.keyAuth!.initRoot(rootKp.publicKeyBase64);
901
+
902
+ const ws = await openWs();
903
+ // Unauthenticated — should fail
904
+ const fail = await wsSend(ws, { op: 'auth-create-role', role: { id: 'test', name: 'Test', permissions: [] } });
905
+ expect(fail.ok).toBe(false);
906
+
907
+ // Authenticate as root
908
+ await authenticateWs(ws, rootKp.privateKey, rootKp.publicKeyBase64);
909
+
910
+ // Now create role
911
+ const ok = await wsSend(ws, { op: 'auth-create-role', role: { id: 'editor', name: 'Editor', permissions: [{ path: 'posts/$id', read: true, write: true }] } });
912
+ expect(ok.ok).toBe(true);
913
+
914
+ // Verify role exists
915
+ const roles = await wsSend(ws, { op: 'auth-list-roles' });
916
+ expect(roles.ok).toBe(true);
917
+ expect(roles.data).toHaveLength(1);
918
+ expect(roles.data[0].id).toBe('editor');
919
+
920
+ // Delete role
921
+ const del = await wsSend(ws, { op: 'auth-delete-role', roleId: 'editor' });
922
+ expect(del.ok).toBe(true);
923
+
924
+ const rolesAfter = await wsSend(ws, { op: 'auth-list-roles' });
925
+ expect(rolesAfter.data).toHaveLength(0);
926
+
927
+ ws.close();
928
+ });
929
+
930
+ it('auth-list-accounts via WS', async () => {
931
+ const acct = authDb.keyAuth!.createAccount('pw', [], 'Test');
932
+ const account = authDb.get(`_auth/accounts/${acct.fingerprint}`) as any;
933
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
934
+ const ws = await openWs();
935
+ await authenticateWs(ws, privKey, acct.publicKey);
936
+ const result = await wsSend(ws, { op: 'auth-list-accounts' });
937
+ expect(result.ok).toBe(true);
938
+ expect(result.data.length).toBeGreaterThanOrEqual(1);
939
+ ws.close();
940
+ });
941
+
942
+ it('auth-list-devices via WS', async () => {
943
+ const acct = authDb.keyAuth!.createAccount('pw');
944
+ const account = authDb.get(`_auth/accounts/${acct.fingerprint}`) as any;
945
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
946
+ const deviceKp = generateEd25519KeyPair();
947
+ authDb.keyAuth!.linkDevice(acct.fingerprint, 'pw', deviceKp.publicKeyBase64, 'Test Phone');
948
+ const ws = await openWs();
949
+ await authenticateWs(ws, privKey, acct.publicKey);
950
+ const result = await wsSend(ws, { op: 'auth-list-devices', accountFingerprint: acct.fingerprint });
951
+ expect(result.ok).toBe(true);
952
+ expect(result.data).toHaveLength(1);
953
+ expect(result.data[0].name).toBe('Test Phone');
954
+ ws.close();
955
+ });
956
+
957
+ it('auth-list-sessions via WS', async () => {
958
+ const acct = authDb.keyAuth!.createAccount('pw');
959
+ const account = authDb.get(`_auth/accounts/${acct.fingerprint}`) as any;
960
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
961
+ const ws = await openWs();
962
+ await authenticateWs(ws, privKey, acct.publicKey);
963
+ const result = await wsSend(ws, { op: 'auth-list-sessions' });
964
+ expect(result.ok).toBe(true);
965
+ expect(result.data.length).toBeGreaterThanOrEqual(1);
966
+ // Filter by account
967
+ const filtered = await wsSend(ws, { op: 'auth-list-sessions', accountFingerprint: acct.fingerprint });
968
+ expect(filtered.ok).toBe(true);
969
+ expect(filtered.data.length).toBeGreaterThanOrEqual(1);
970
+ ws.close();
971
+ });
972
+
973
+ it('QR approval flow via WS', async () => {
974
+ // Device A: authenticate
975
+ const acct = authDb.keyAuth!.createAccount('pw', ['admin']);
976
+ const account = authDb.get(`_auth/accounts/${acct.fingerprint}`) as any;
977
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
978
+ const wsA = await openWs();
979
+ await authenticateWs(wsA, privKey, acct.publicKey);
980
+
981
+ // Device B: request approval
982
+ const deviceBKp = generateEd25519KeyPair();
983
+ const wsB = await openWs();
984
+ const reqResult = await wsSend(wsB, { op: 'auth-request-approval', publicKey: deviceBKp.publicKeyBase64 });
985
+ expect(reqResult.ok).toBe(true);
986
+ const { requestId } = reqResult.data;
987
+
988
+ // Device B: poll → pending
989
+ const poll1 = await wsSend(wsB, { op: 'auth-poll-approval', requestId });
990
+ expect(poll1.ok).toBe(true);
991
+ expect(poll1.data.status).toBe('pending');
992
+
993
+ // Device A: approve
994
+ const approve = await wsSend(wsA, { op: 'auth-approve-device', requestId });
995
+ expect(approve.ok).toBe(true);
996
+ expect(approve.data.token).toContain('.');
997
+
998
+ // Device B: poll → approved
999
+ const poll2 = await wsSend(wsB, { op: 'auth-poll-approval', requestId });
1000
+ expect(poll2.ok).toBe(true);
1001
+ expect(poll2.data.status).toBe('approved');
1002
+ expect(poll2.data.token).toBeTruthy();
1003
+
1004
+ wsA.close();
1005
+ wsB.close();
1006
+ });
1007
+
1008
+ it('auth-approve-device requires auth', async () => {
1009
+ const ws = await openWs();
1010
+ const result = await wsSend(ws, { op: 'auth-approve-device', requestId: 'fake' });
1011
+ expect(result.ok).toBe(false);
1012
+ expect(result.code).toBe('AUTH_REQUIRED');
1013
+ ws.close();
1014
+ });
1015
+
1016
+ it('BodClient keyAuth auto challenge-response', async () => {
1017
+ const acct = authDb.keyAuth!.createAccount('pw');
1018
+ const account = authDb.get(`_auth/accounts/${acct.fingerprint}`) as any;
1019
+ const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
1020
+
1021
+ const client = new BodClient({
1022
+ url: `ws://localhost:${port}`,
1023
+ keyAuth: {
1024
+ publicKey: acct.publicKey,
1025
+ signFn: (nonce: string) => signData(Buffer.from(nonce), privKey).toString('base64'),
1026
+ },
1027
+ });
1028
+ await client.connect();
1029
+
1030
+ // Should be able to do CRUD after auto-auth
1031
+ await client.set('test/hello', 'world');
1032
+ expect(await client.get('test/hello')).toBe('world');
1033
+
1034
+ client.disconnect();
1035
+ });
1036
+ });
1037
+ });