@bod.ee/db 0.9.0 → 0.10.1
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/.claude/settings.local.json +7 -1
- package/.claude/skills/config-file.md +1 -0
- package/.claude/skills/developing-bod-db.md +11 -5
- package/.claude/skills/using-bod-db.md +125 -5
- package/CLAUDE.md +11 -6
- package/README.md +3 -3
- package/admin/admin.ts +57 -0
- package/admin/demo.config.ts +132 -0
- package/admin/rules.ts +4 -1
- package/admin/ui.html +530 -6
- package/bun.lock +33 -0
- package/cli.ts +4 -43
- package/client.ts +5 -3
- package/config.ts +10 -3
- package/index.ts +5 -0
- package/package.json +8 -2
- package/src/client/BodClient.ts +220 -2
- package/src/client/{CachedClient.ts → BodClientCached.ts} +115 -6
- package/src/server/BodDB.ts +24 -8
- package/src/server/KeyAuthEngine.ts +481 -0
- package/src/server/ReplicationEngine.ts +1 -1
- package/src/server/RulesEngine.ts +4 -2
- package/src/server/Transport.ts +213 -0
- package/src/server/VFSEngine.ts +78 -7
- package/src/shared/keyAuth.browser.ts +80 -0
- package/src/shared/keyAuth.ts +177 -0
- package/src/shared/protocol.ts +28 -1
- package/tests/cached-client.test.ts +123 -7
- package/tests/keyauth.test.ts +1010 -0
- package/admin/server.ts +0 -607
|
@@ -0,0 +1,1010 @@
|
|
|
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
|
+
// Second unauthenticated create should be rejected
|
|
800
|
+
const ws2 = await openWs();
|
|
801
|
+
const reject = await wsSend(ws2, { op: 'auth-create-account', password: 'hacker' });
|
|
802
|
+
expect(reject.ok).toBe(false);
|
|
803
|
+
expect(reject.code).toBe('AUTH_REQUIRED');
|
|
804
|
+
ws.close();
|
|
805
|
+
ws2.close();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('auth-register-device via WS', async () => {
|
|
809
|
+
const kp = await browserGenerateKeyPair();
|
|
810
|
+
const ws = await openWs();
|
|
811
|
+
const reg = await wsSend(ws, { op: 'auth-register-device', publicKey: kp.publicKeyBase64, displayName: 'Test Device' });
|
|
812
|
+
expect(reg.ok).toBe(true);
|
|
813
|
+
expect(reg.data.fingerprint).toBe(kp.fingerprint);
|
|
814
|
+
// Now authenticate
|
|
815
|
+
const challenge = await wsSend(ws, { op: 'auth-challenge' });
|
|
816
|
+
const sig = await browserSign(challenge.data.nonce, kp.privateKeyBase64);
|
|
817
|
+
const verify = await wsSend(ws, { op: 'auth-verify', publicKey: kp.publicKeyBase64, signature: sig, nonce: challenge.data.nonce });
|
|
818
|
+
expect(verify.ok).toBe(true);
|
|
819
|
+
expect(verify.data.token).toContain('.');
|
|
820
|
+
ws.close();
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('BodClient auth.registerDevice convenience method', async () => {
|
|
824
|
+
const kp = await browserGenerateKeyPair();
|
|
825
|
+
const client = new BodClient({ url: `ws://localhost:${port}` });
|
|
826
|
+
await client.connect();
|
|
827
|
+
const reg = await client.auth.registerDevice(kp.publicKeyBase64, 'My Device');
|
|
828
|
+
expect(reg.fingerprint).toBe(kp.fingerprint);
|
|
829
|
+
// Authenticate with convenience method
|
|
830
|
+
const result = await client.auth.authenticate(kp.publicKeyBase64, (nonce) => browserSign(nonce, kp.privateKeyBase64));
|
|
831
|
+
expect(result.token).toContain('.');
|
|
832
|
+
client.disconnect();
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it('BodClient autoAuth — full device identity flow', async () => {
|
|
836
|
+
// Use in-memory storage to avoid localStorage dependency
|
|
837
|
+
const storage = new Map<string, string>();
|
|
838
|
+
const memStorage = {
|
|
839
|
+
get: (k: string) => storage.get(k) ?? null,
|
|
840
|
+
set: (k: string, v: string) => storage.set(k, v),
|
|
841
|
+
remove: (k: string) => storage.delete(k),
|
|
842
|
+
};
|
|
843
|
+
const client = new BodClient({
|
|
844
|
+
url: `ws://localhost:${port}`,
|
|
845
|
+
autoAuth: { displayName: 'Auto Device', storage: memStorage },
|
|
846
|
+
});
|
|
847
|
+
await client.connect();
|
|
848
|
+
// Should be authenticated and have a fingerprint
|
|
849
|
+
expect(client.deviceFingerprint).toBeTruthy();
|
|
850
|
+
expect(client.deviceFingerprint).toHaveLength(64);
|
|
851
|
+
// Should be able to do CRUD
|
|
852
|
+
await client.set('auto/test', 'works');
|
|
853
|
+
expect(await client.get('auto/test')).toBe('works');
|
|
854
|
+
// Keys should be in storage
|
|
855
|
+
expect(storage.has('boddb-device-pubkey')).toBe(true);
|
|
856
|
+
expect(storage.has('boddb-device-privkey')).toBe(true);
|
|
857
|
+
client.disconnect();
|
|
858
|
+
|
|
859
|
+
// Reconnect with same storage — should reuse keys
|
|
860
|
+
const client2 = new BodClient({
|
|
861
|
+
url: `ws://localhost:${port}`,
|
|
862
|
+
autoAuth: { storage: memStorage },
|
|
863
|
+
});
|
|
864
|
+
await client2.connect();
|
|
865
|
+
expect(client2.deviceFingerprint).toBe(client.deviceFingerprint);
|
|
866
|
+
expect(await client2.get('auto/test')).toBe('works');
|
|
867
|
+
client2.disconnect();
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('auth-create-role via WS (root only)', async () => {
|
|
871
|
+
// Set up root
|
|
872
|
+
const rootKp = generateEd25519KeyPair();
|
|
873
|
+
authDb.keyAuth!.initRoot(rootKp.publicKeyBase64);
|
|
874
|
+
|
|
875
|
+
const ws = await openWs();
|
|
876
|
+
// Unauthenticated — should fail
|
|
877
|
+
const fail = await wsSend(ws, { op: 'auth-create-role', role: { id: 'test', name: 'Test', permissions: [] } });
|
|
878
|
+
expect(fail.ok).toBe(false);
|
|
879
|
+
|
|
880
|
+
// Authenticate as root
|
|
881
|
+
await authenticateWs(ws, rootKp.privateKey, rootKp.publicKeyBase64);
|
|
882
|
+
|
|
883
|
+
// Now create role
|
|
884
|
+
const ok = await wsSend(ws, { op: 'auth-create-role', role: { id: 'editor', name: 'Editor', permissions: [{ path: 'posts/$id', read: true, write: true }] } });
|
|
885
|
+
expect(ok.ok).toBe(true);
|
|
886
|
+
|
|
887
|
+
// Verify role exists
|
|
888
|
+
const roles = await wsSend(ws, { op: 'auth-list-roles' });
|
|
889
|
+
expect(roles.ok).toBe(true);
|
|
890
|
+
expect(roles.data).toHaveLength(1);
|
|
891
|
+
expect(roles.data[0].id).toBe('editor');
|
|
892
|
+
|
|
893
|
+
// Delete role
|
|
894
|
+
const del = await wsSend(ws, { op: 'auth-delete-role', roleId: 'editor' });
|
|
895
|
+
expect(del.ok).toBe(true);
|
|
896
|
+
|
|
897
|
+
const rolesAfter = await wsSend(ws, { op: 'auth-list-roles' });
|
|
898
|
+
expect(rolesAfter.data).toHaveLength(0);
|
|
899
|
+
|
|
900
|
+
ws.close();
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('auth-list-accounts via WS', async () => {
|
|
904
|
+
const acct = authDb.keyAuth!.createAccount('pw', [], 'Test');
|
|
905
|
+
const account = authDb.get(`_auth/accounts/${acct.fingerprint}`) as any;
|
|
906
|
+
const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
|
|
907
|
+
const ws = await openWs();
|
|
908
|
+
await authenticateWs(ws, privKey, acct.publicKey);
|
|
909
|
+
const result = await wsSend(ws, { op: 'auth-list-accounts' });
|
|
910
|
+
expect(result.ok).toBe(true);
|
|
911
|
+
expect(result.data.length).toBeGreaterThanOrEqual(1);
|
|
912
|
+
ws.close();
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it('auth-list-devices via WS', async () => {
|
|
916
|
+
const acct = authDb.keyAuth!.createAccount('pw');
|
|
917
|
+
const account = authDb.get(`_auth/accounts/${acct.fingerprint}`) as any;
|
|
918
|
+
const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
|
|
919
|
+
const deviceKp = generateEd25519KeyPair();
|
|
920
|
+
authDb.keyAuth!.linkDevice(acct.fingerprint, 'pw', deviceKp.publicKeyBase64, 'Test Phone');
|
|
921
|
+
const ws = await openWs();
|
|
922
|
+
await authenticateWs(ws, privKey, acct.publicKey);
|
|
923
|
+
const result = await wsSend(ws, { op: 'auth-list-devices', accountFingerprint: acct.fingerprint });
|
|
924
|
+
expect(result.ok).toBe(true);
|
|
925
|
+
expect(result.data).toHaveLength(1);
|
|
926
|
+
expect(result.data[0].name).toBe('Test Phone');
|
|
927
|
+
ws.close();
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it('auth-list-sessions via WS', async () => {
|
|
931
|
+
const acct = authDb.keyAuth!.createAccount('pw');
|
|
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-sessions' });
|
|
937
|
+
expect(result.ok).toBe(true);
|
|
938
|
+
expect(result.data.length).toBeGreaterThanOrEqual(1);
|
|
939
|
+
// Filter by account
|
|
940
|
+
const filtered = await wsSend(ws, { op: 'auth-list-sessions', accountFingerprint: acct.fingerprint });
|
|
941
|
+
expect(filtered.ok).toBe(true);
|
|
942
|
+
expect(filtered.data.length).toBeGreaterThanOrEqual(1);
|
|
943
|
+
ws.close();
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it('QR approval flow via WS', async () => {
|
|
947
|
+
// Device A: authenticate
|
|
948
|
+
const acct = authDb.keyAuth!.createAccount('pw', ['admin']);
|
|
949
|
+
const account = authDb.get(`_auth/accounts/${acct.fingerprint}`) as any;
|
|
950
|
+
const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
|
|
951
|
+
const wsA = await openWs();
|
|
952
|
+
await authenticateWs(wsA, privKey, acct.publicKey);
|
|
953
|
+
|
|
954
|
+
// Device B: request approval
|
|
955
|
+
const deviceBKp = generateEd25519KeyPair();
|
|
956
|
+
const wsB = await openWs();
|
|
957
|
+
const reqResult = await wsSend(wsB, { op: 'auth-request-approval', publicKey: deviceBKp.publicKeyBase64 });
|
|
958
|
+
expect(reqResult.ok).toBe(true);
|
|
959
|
+
const { requestId } = reqResult.data;
|
|
960
|
+
|
|
961
|
+
// Device B: poll → pending
|
|
962
|
+
const poll1 = await wsSend(wsB, { op: 'auth-poll-approval', requestId });
|
|
963
|
+
expect(poll1.ok).toBe(true);
|
|
964
|
+
expect(poll1.data.status).toBe('pending');
|
|
965
|
+
|
|
966
|
+
// Device A: approve
|
|
967
|
+
const approve = await wsSend(wsA, { op: 'auth-approve-device', requestId });
|
|
968
|
+
expect(approve.ok).toBe(true);
|
|
969
|
+
expect(approve.data.token).toContain('.');
|
|
970
|
+
|
|
971
|
+
// Device B: poll → approved
|
|
972
|
+
const poll2 = await wsSend(wsB, { op: 'auth-poll-approval', requestId });
|
|
973
|
+
expect(poll2.ok).toBe(true);
|
|
974
|
+
expect(poll2.data.status).toBe('approved');
|
|
975
|
+
expect(poll2.data.token).toBeTruthy();
|
|
976
|
+
|
|
977
|
+
wsA.close();
|
|
978
|
+
wsB.close();
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it('auth-approve-device requires auth', async () => {
|
|
982
|
+
const ws = await openWs();
|
|
983
|
+
const result = await wsSend(ws, { op: 'auth-approve-device', requestId: 'fake' });
|
|
984
|
+
expect(result.ok).toBe(false);
|
|
985
|
+
expect(result.code).toBe('AUTH_REQUIRED');
|
|
986
|
+
ws.close();
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('BodClient keyAuth auto challenge-response', async () => {
|
|
990
|
+
const acct = authDb.keyAuth!.createAccount('pw');
|
|
991
|
+
const account = authDb.get(`_auth/accounts/${acct.fingerprint}`) as any;
|
|
992
|
+
const privKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, 'pw');
|
|
993
|
+
|
|
994
|
+
const client = new BodClient({
|
|
995
|
+
url: `ws://localhost:${port}`,
|
|
996
|
+
keyAuth: {
|
|
997
|
+
publicKey: acct.publicKey,
|
|
998
|
+
signFn: (nonce: string) => signData(Buffer.from(nonce), privKey).toString('base64'),
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
1001
|
+
await client.connect();
|
|
1002
|
+
|
|
1003
|
+
// Should be able to do CRUD after auto-auth
|
|
1004
|
+
await client.set('test/hello', 'world');
|
|
1005
|
+
expect(await client.get('test/hello')).toBe('world');
|
|
1006
|
+
|
|
1007
|
+
client.disconnect();
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
});
|