@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.
@@ -0,0 +1,481 @@
1
+ import type { BodDB } from './BodDB.ts';
2
+ import {
3
+ fingerprint, generateNonce, generateSessionId,
4
+ encryptPrivateKey, decryptPrivateKey,
5
+ generateEd25519KeyPair, signData, verifySignature,
6
+ encodeToken, decodeToken,
7
+ type KeyAuthAccount, type KeyAuthDevice, type KeyAuthRole,
8
+ type KeyAuthSession, type KeyAuthContext, type KeyAuthTokenPayload,
9
+ } from '../shared/keyAuth.ts';
10
+
11
+ export class KeyAuthEngineOptions {
12
+ /** Session token TTL in seconds (default 24h) */
13
+ sessionTtl: number = 86400;
14
+ /** Nonce TTL in seconds (default 60s) */
15
+ nonceTtl: number = 60;
16
+ /** Path to server key file (default: <dbPath>.keyauth.json) */
17
+ keyPath?: string;
18
+ /** Clock skew tolerance in seconds for token expiry */
19
+ clockSkew: number = 30;
20
+ /** Allow unauthenticated device registration (default true) */
21
+ allowOpenRegistration: boolean = true;
22
+ }
23
+
24
+ /** Reserved prefix — _auth/ writes blocked for external clients */
25
+ export const AUTH_PREFIX = '_auth/';
26
+
27
+ export class KeyAuthEngine {
28
+ readonly options: KeyAuthEngineOptions;
29
+ private db: BodDB;
30
+ private serverPublicKey!: Uint8Array;
31
+ private serverPrivateKey!: Uint8Array;
32
+ private serverFingerprint!: string;
33
+
34
+ constructor(db: BodDB, options?: Partial<KeyAuthEngineOptions>) {
35
+ this.options = { ...new KeyAuthEngineOptions(), ...options };
36
+ this.db = db;
37
+ this._initServerKey();
38
+ }
39
+
40
+ /** Load or generate server Ed25519 key pair (private on filesystem only) */
41
+ private _initServerKey(): void {
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+ const dbPath = this.db.options.path;
45
+ const keyPath = this.options.keyPath ?? (dbPath === ':memory:' ? null : dbPath + '.keyauth.json');
46
+
47
+ if (keyPath && fs.existsSync(keyPath)) {
48
+ const raw = fs.readFileSync(keyPath);
49
+ const parsed = JSON.parse(raw.toString());
50
+ this.serverPrivateKey = new Uint8Array(Buffer.from(parsed.privateKey, 'base64'));
51
+ this.serverPublicKey = new Uint8Array(Buffer.from(parsed.publicKey, 'base64'));
52
+ } else {
53
+ const kp = generateEd25519KeyPair();
54
+ this.serverPublicKey = kp.publicKey;
55
+ this.serverPrivateKey = kp.privateKey;
56
+
57
+ if (keyPath) {
58
+ const dir = path.dirname(keyPath);
59
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
60
+ fs.writeFileSync(keyPath, JSON.stringify({
61
+ publicKey: Buffer.from(kp.publicKey).toString('base64'),
62
+ privateKey: Buffer.from(kp.privateKey).toString('base64'),
63
+ }), { mode: 0o600 });
64
+ }
65
+ }
66
+
67
+ this.serverFingerprint = fingerprint(Buffer.from(this.serverPublicKey).toString('base64'));
68
+ this.db.set('_auth/server/publicKey', Buffer.from(this.serverPublicKey).toString('base64'));
69
+ this.db.set('_auth/server/fingerprint', this.serverFingerprint);
70
+ }
71
+
72
+ /** Initialize root account from a public key */
73
+ initRoot(publicKeyBase64: string): void {
74
+ const fp = fingerprint(publicKeyBase64);
75
+ this.db.set('_auth/root', { publicKey: publicKeyBase64, fingerprint: fp });
76
+ }
77
+
78
+ /** Create a new account with password-encrypted private key.
79
+ * If devicePublicKey is provided, auto-links that device to the new account.
80
+ * If this is the first account ever created, it is auto-elevated to root. */
81
+ createAccount(password: string, roles: string[] = [], displayName?: string, devicePublicKey?: string): { publicKey: string; fingerprint: string; isRoot: boolean; deviceFingerprint?: string } {
82
+ const kp = generateEd25519KeyPair();
83
+ const enc = encryptPrivateKey(kp.privateKey, password);
84
+ const fp = fingerprint(kp.publicKeyBase64);
85
+
86
+ // First account becomes root automatically
87
+ const isFirstAccount = this.db.getShallow('_auth/accounts').length === 0;
88
+
89
+ const account: KeyAuthAccount = {
90
+ publicKey: kp.publicKeyBase64,
91
+ fingerprint: fp,
92
+ encryptedPrivateKey: enc.encrypted,
93
+ salt: enc.salt,
94
+ iv: enc.iv,
95
+ authTag: enc.authTag,
96
+ displayName,
97
+ roles,
98
+ createdAt: Date.now(),
99
+ lastAuth: 0,
100
+ };
101
+ this.db.set(`_auth/accounts/${fp}`, account);
102
+
103
+ if (isFirstAccount) {
104
+ this.initRoot(kp.publicKeyBase64);
105
+ }
106
+
107
+ // Auto-link calling device if provided
108
+ let deviceFingerprint: string | undefined;
109
+ if (devicePublicKey) {
110
+ const linked = this.linkDevice(fp, password, devicePublicKey);
111
+ if (linked) deviceFingerprint = linked.fingerprint;
112
+ }
113
+
114
+ return { publicKey: kp.publicKeyBase64, fingerprint: fp, isRoot: isFirstAccount, deviceFingerprint };
115
+ }
116
+
117
+ /** Register a device (client-generated keypair). No password — client holds the only copy of the private key. */
118
+ registerDevice(publicKeyBase64: string, displayName?: string, roles: string[] = []): { fingerprint: string } {
119
+ const fp = fingerprint(publicKeyBase64);
120
+ // If account already exists, just return its fingerprint (idempotent)
121
+ const existing = this.db.get(`_auth/accounts/${fp}`);
122
+ if (existing) return { fingerprint: fp };
123
+ const account: KeyAuthAccount = {
124
+ publicKey: publicKeyBase64,
125
+ fingerprint: fp,
126
+ encryptedPrivateKey: '', // no server-side private key
127
+ salt: '',
128
+ iv: '',
129
+ authTag: '',
130
+ displayName,
131
+ roles,
132
+ createdAt: Date.now(),
133
+ lastAuth: 0,
134
+ };
135
+ this.db.set(`_auth/accounts/${fp}`, account);
136
+ return { fingerprint: fp };
137
+ }
138
+
139
+ /** Challenge step: returns a nonce for the client to sign */
140
+ challenge(): { nonce: string; serverId: string } {
141
+ const nonce = generateNonce();
142
+ this.db.set(`_auth/nonces/${nonce}`, { createdAt: Date.now() }, { ttl: this.options.nonceTtl });
143
+ return { nonce, serverId: this.serverFingerprint };
144
+ }
145
+
146
+ /** Verify a signed nonce. Returns token + expiry or null. */
147
+ verify(publicKeyBase64: string, signatureBase64: string, nonce: string): { token: string; expiresAt: number } | null {
148
+ // Check nonce exists (one-time use)
149
+ const nonceData = this.db.get(`_auth/nonces/${nonce}`);
150
+ if (!nonceData) return null;
151
+ // Delete nonce immediately (prevent replay)
152
+ this.db.delete(`_auth/nonces/${nonce}`);
153
+
154
+ // Verify signature
155
+ const pubKeyDer = Buffer.from(publicKeyBase64, 'base64');
156
+ const sig = Buffer.from(signatureBase64, 'base64');
157
+ if (!verifySignature(Buffer.from(nonce), sig, new Uint8Array(pubKeyDer))) return null;
158
+
159
+ const fp = fingerprint(publicKeyBase64);
160
+
161
+ // Check if root
162
+ const root = this.db.get('_auth/root') as { fingerprint: string } | null;
163
+ const isRoot = root?.fingerprint === fp;
164
+
165
+ // Resolve account — direct account or device→account
166
+ let accountFp = '';
167
+ let roles: string[] = [];
168
+
169
+ const account = this.db.get(`_auth/accounts/${fp}`) as KeyAuthAccount | null;
170
+ if (account) {
171
+ accountFp = fp;
172
+ roles = account.roles;
173
+ this.db.set(`_auth/accounts/${fp}/lastAuth`, Date.now());
174
+ } else {
175
+ // O(1) device lookup via reverse index
176
+ const resolved = this._resolveDevice(fp);
177
+ if (resolved) {
178
+ accountFp = resolved.accountFp;
179
+ roles = resolved.roles;
180
+ this.db.set(`_auth/accounts/${accountFp}/devices/${fp}/lastAuth`, Date.now());
181
+ } else if (!isRoot) {
182
+ return null; // Unknown key
183
+ }
184
+ }
185
+
186
+ // Create session
187
+ const sid = generateSessionId();
188
+ const expiresAt = Date.now() + this.options.sessionTtl * 1000;
189
+ const session: KeyAuthSession = {
190
+ fingerprint: fp,
191
+ accountFingerprint: accountFp,
192
+ roles,
193
+ isRoot,
194
+ createdAt: Date.now(),
195
+ expiresAt,
196
+ };
197
+ this.db.set(`_auth/sessions/${sid}`, session, { ttl: this.options.sessionTtl });
198
+
199
+ const payload: KeyAuthTokenPayload = {
200
+ sid, fp, accountFp, roles, root: isRoot, exp: expiresAt,
201
+ };
202
+ const token = encodeToken(payload, this.serverPrivateKey);
203
+ return { token, expiresAt };
204
+ }
205
+
206
+ /** Link a device to an account (requires password to decrypt account key) */
207
+ linkDevice(accountFp: string, password: string, devicePublicKeyBase64: string, deviceName?: string): { fingerprint: string } | null {
208
+ const account = this.db.get(`_auth/accounts/${accountFp}`) as KeyAuthAccount | null;
209
+ if (!account) return null;
210
+ // Device-registered accounts have no encrypted private key
211
+ if (!account.encryptedPrivateKey) return null;
212
+
213
+ let accountPrivateKey: Uint8Array;
214
+ try {
215
+ accountPrivateKey = decryptPrivateKey(
216
+ account.encryptedPrivateKey, account.salt, account.iv, account.authTag, password,
217
+ );
218
+ } catch {
219
+ return null; // Wrong password
220
+ }
221
+
222
+ const deviceFp = fingerprint(devicePublicKeyBase64);
223
+ const linkedAt = Date.now();
224
+ const certData = Buffer.from(`${devicePublicKeyBase64}:${accountFp}:${linkedAt}`);
225
+ const certificate = signData(certData, accountPrivateKey);
226
+
227
+ // Wipe account private key from memory
228
+ accountPrivateKey.fill(0);
229
+
230
+ const device: KeyAuthDevice = {
231
+ publicKey: devicePublicKeyBase64,
232
+ fingerprint: deviceFp,
233
+ accountFingerprint: accountFp,
234
+ certificate: certificate.toString('base64'),
235
+ name: deviceName,
236
+ linkedAt,
237
+ lastAuth: 0,
238
+ };
239
+ this.db.set(`_auth/accounts/${accountFp}/devices/${deviceFp}`, device);
240
+ // Reverse index for O(1) device→account lookup
241
+ this.db.set(`_auth/deviceIndex/${deviceFp}`, accountFp);
242
+ return { fingerprint: deviceFp };
243
+ }
244
+
245
+ /** Validate a token, return context or null */
246
+ validateToken(token: string): KeyAuthContext | null {
247
+ const payload = decodeToken(token, this.serverPublicKey);
248
+ if (!payload) return null;
249
+ // Check expiry (with clock skew tolerance)
250
+ if (payload.exp + this.options.clockSkew * 1000 < Date.now()) return null;
251
+ // Check session still exists
252
+ const session = this.db.get(`_auth/sessions/${payload.sid}`);
253
+ if (!session) return null;
254
+ return {
255
+ sid: payload.sid,
256
+ fingerprint: payload.fp,
257
+ accountFingerprint: payload.accountFp,
258
+ roles: payload.roles,
259
+ isRoot: payload.root,
260
+ };
261
+ }
262
+
263
+ /** Change account password (re-encrypt same key pair, fingerprint stable) — atomic via update */
264
+ changePassword(accountFp: string, oldPassword: string, newPassword: string): boolean {
265
+ const account = this.db.get(`_auth/accounts/${accountFp}`) as KeyAuthAccount | null;
266
+ if (!account) return false;
267
+ // Device-registered accounts have no encrypted private key
268
+ if (!account.encryptedPrivateKey) return false;
269
+
270
+ let privateKey: Uint8Array;
271
+ try {
272
+ privateKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, oldPassword);
273
+ } catch {
274
+ return false;
275
+ }
276
+
277
+ const enc = encryptPrivateKey(privateKey, newPassword);
278
+ privateKey.fill(0); // Wipe
279
+
280
+ // Atomic: single update call writes all fields together
281
+ this.db.update({
282
+ [`_auth/accounts/${accountFp}/encryptedPrivateKey`]: enc.encrypted,
283
+ [`_auth/accounts/${accountFp}/salt`]: enc.salt,
284
+ [`_auth/accounts/${accountFp}/iv`]: enc.iv,
285
+ [`_auth/accounts/${accountFp}/authTag`]: enc.authTag,
286
+ });
287
+ return true;
288
+ }
289
+
290
+ /** Revoke a device */
291
+ revokeDevice(accountFp: string, deviceFp: string): void {
292
+ this.db.delete(`_auth/accounts/${accountFp}/devices/${deviceFp}`);
293
+ this.db.delete(`_auth/deviceIndex/${deviceFp}`);
294
+ this._invalidateDeviceSessions(deviceFp);
295
+ }
296
+
297
+ /** Check if any accounts exist (safe to call unauthenticated — no sensitive data) */
298
+ hasAccounts(): boolean {
299
+ return this.db.getShallow('_auth/accounts').length > 0;
300
+ }
301
+
302
+ /** Get public account info (no secrets). Returns null if not found. */
303
+ getAccountInfo(accountFp: string): { fingerprint: string; displayName?: string; roles: string[]; isRoot: boolean; createdAt: number } | null {
304
+ const account = this.db.get(`_auth/accounts/${accountFp}`) as KeyAuthAccount | null;
305
+ if (!account) return null;
306
+ const root = this.db.get('_auth/root') as { fingerprint: string } | null;
307
+ return {
308
+ fingerprint: account.fingerprint,
309
+ displayName: account.displayName,
310
+ roles: account.roles,
311
+ isRoot: root?.fingerprint === account.fingerprint,
312
+ createdAt: account.createdAt,
313
+ };
314
+ }
315
+
316
+ /** Revoke a session */
317
+ revokeSession(sid: string): void {
318
+ this.db.delete(`_auth/sessions/${sid}`);
319
+ }
320
+
321
+ // --- IAM ---
322
+
323
+ createRole(role: KeyAuthRole): void {
324
+ this.db.set(`_auth/roles/${role.id}`, role);
325
+ }
326
+
327
+ deleteRole(roleId: string): void {
328
+ this.db.delete(`_auth/roles/${roleId}`);
329
+ }
330
+
331
+ updateAccountRoles(accountFp: string, roles: string[]): void {
332
+ this.db.set(`_auth/accounts/${accountFp}/roles`, roles);
333
+ }
334
+
335
+ listAccounts(): KeyAuthAccount[] {
336
+ const shallow = this.db.getShallow('_auth/accounts');
337
+ return shallow.map(e => this.db.get(`_auth/accounts/${e.key}`) as KeyAuthAccount).filter(Boolean);
338
+ }
339
+
340
+ listRoles(): KeyAuthRole[] {
341
+ const shallow = this.db.getShallow('_auth/roles');
342
+ return shallow.map(e => this.db.get(`_auth/roles/${e.key}`) as KeyAuthRole).filter(Boolean);
343
+ }
344
+
345
+ getRole(roleId: string): KeyAuthRole | null {
346
+ return this.db.get(`_auth/roles/${roleId}`) as KeyAuthRole | null;
347
+ }
348
+
349
+ /** List devices linked to an account */
350
+ listDevices(accountFp: string): KeyAuthDevice[] {
351
+ const shallow = this.db.getShallow(`_auth/accounts/${accountFp}/devices`);
352
+ return shallow.map(e => this.db.get(`_auth/accounts/${accountFp}/devices/${e.key}`) as KeyAuthDevice).filter(Boolean);
353
+ }
354
+
355
+ /** List sessions, optionally filtered by account fingerprint */
356
+ listSessions(accountFp?: string): (KeyAuthSession & { sid: string })[] {
357
+ const shallow = this.db.getShallow('_auth/sessions');
358
+ const sessions = shallow.map(e => {
359
+ const s = this.db.get(`_auth/sessions/${e.key}`) as KeyAuthSession | null;
360
+ return s ? { ...s, sid: e.key } : null;
361
+ }).filter(Boolean) as (KeyAuthSession & { sid: string })[];
362
+ if (accountFp) return sessions.filter(s => s.accountFingerprint === accountFp);
363
+ return sessions;
364
+ }
365
+
366
+ /** Request QR-based cross-device approval (Device B calls this) */
367
+ requestApproval(publicKeyBase64: string): { requestId: string } {
368
+ const requestId = generateNonce();
369
+ this.db.set(`_auth/approvals/${requestId}`, {
370
+ publicKey: publicKeyBase64,
371
+ status: 'pending',
372
+ createdAt: Date.now(),
373
+ }, { ttl: 300 });
374
+ return { requestId };
375
+ }
376
+
377
+ /** Approve a pending device request (Device A, authenticated, calls this) */
378
+ approveDevice(requestId: string, approverAccountFp: string): { fingerprint: string; token: string; expiresAt: number } | null {
379
+ const approval = this.db.get(`_auth/approvals/${requestId}`) as { publicKey: string; status: string } | null;
380
+ if (!approval || approval.status !== 'pending') return null;
381
+
382
+ // Register the device under approver's account
383
+ const deviceFp = fingerprint(approval.publicKey);
384
+ const linkedAt = Date.now();
385
+ const device: KeyAuthDevice = {
386
+ publicKey: approval.publicKey,
387
+ fingerprint: deviceFp,
388
+ accountFingerprint: approverAccountFp,
389
+ certificate: '',
390
+ name: `QR-approved-${deviceFp.slice(0, 8)}`,
391
+ linkedAt,
392
+ lastAuth: 0,
393
+ };
394
+ this.db.set(`_auth/accounts/${approverAccountFp}/devices/${deviceFp}`, device);
395
+ this.db.set(`_auth/deviceIndex/${deviceFp}`, approverAccountFp);
396
+
397
+ // Create session for Device B
398
+ const account = this.db.get(`_auth/accounts/${approverAccountFp}`) as KeyAuthAccount | null;
399
+ const roles = account?.roles ?? [];
400
+ const sid = generateSessionId();
401
+ const expiresAt = Date.now() + this.options.sessionTtl * 1000;
402
+ const session: KeyAuthSession = {
403
+ fingerprint: deviceFp,
404
+ accountFingerprint: approverAccountFp,
405
+ roles,
406
+ isRoot: false,
407
+ createdAt: Date.now(),
408
+ expiresAt,
409
+ };
410
+ this.db.set(`_auth/sessions/${sid}`, session, { ttl: this.options.sessionTtl });
411
+
412
+ const payload: KeyAuthTokenPayload = {
413
+ sid, fp: deviceFp, accountFp: approverAccountFp, roles, root: false, exp: expiresAt,
414
+ };
415
+ const token = encodeToken(payload, this.serverPrivateKey);
416
+
417
+ // Mark approval as approved with token
418
+ this.db.set(`_auth/approvals/${requestId}`, {
419
+ ...approval,
420
+ status: 'approved',
421
+ accountFp: approverAccountFp,
422
+ token,
423
+ expiresAt,
424
+ }, { ttl: 300 });
425
+
426
+ return { fingerprint: deviceFp, token, expiresAt };
427
+ }
428
+
429
+ /** Poll approval status (Device B polls this) */
430
+ pollApproval(requestId: string): { status: 'pending' | 'approved' | 'expired'; token?: string; expiresAt?: number } | null {
431
+ const approval = this.db.get(`_auth/approvals/${requestId}`) as { status: string; token?: string; expiresAt?: number } | null;
432
+ if (!approval) return { status: 'expired' };
433
+ return {
434
+ status: approval.status as 'pending' | 'approved',
435
+ token: approval.token,
436
+ expiresAt: approval.expiresAt,
437
+ };
438
+ }
439
+
440
+ /** Auth callback compatible with TransportOptions.auth */
441
+ authCallback(): (token: string) => Record<string, unknown> | null {
442
+ return (token: string) => {
443
+ const ctx = this.validateToken(token);
444
+ if (!ctx) return null;
445
+ return ctx as unknown as Record<string, unknown>;
446
+ };
447
+ }
448
+
449
+ // --- Internal ---
450
+
451
+ /** Resolve a device fingerprint to its account via reverse index (O(1)) */
452
+ private _resolveDevice(deviceFp: string): { accountFp: string; roles: string[] } | null {
453
+ const accountFp = this.db.get(`_auth/deviceIndex/${deviceFp}`) as string | null;
454
+ if (!accountFp) return null;
455
+ const account = this.db.get(`_auth/accounts/${accountFp}`) as KeyAuthAccount | null;
456
+ if (!account) return null;
457
+ // Verify device still exists under account
458
+ const device = this.db.get(`_auth/accounts/${accountFp}/devices/${deviceFp}`);
459
+ if (!device) return null;
460
+ return { accountFp, roles: account.roles ?? [] };
461
+ }
462
+
463
+ /** Invalidate all sessions for a given device fingerprint */
464
+ private _invalidateDeviceSessions(deviceFp: string): void {
465
+ const sessions = this.db.getShallow('_auth/sessions');
466
+ for (const entry of sessions) {
467
+ const session = this.db.get(`_auth/sessions/${entry.key}`) as KeyAuthSession | null;
468
+ if (session?.fingerprint === deviceFp) {
469
+ this.db.delete(`_auth/sessions/${entry.key}`);
470
+ }
471
+ }
472
+ }
473
+
474
+ get publicKey(): string {
475
+ return Buffer.from(this.serverPublicKey).toString('base64');
476
+ }
477
+
478
+ get fp(): string {
479
+ return this.serverFingerprint;
480
+ }
481
+ }
@@ -34,7 +34,7 @@ export class ReplicationOptions {
34
34
  primaryUrl?: string;
35
35
  primaryAuth?: () => string | Promise<string>;
36
36
  replicaId?: string;
37
- excludePrefixes: string[] = ['_repl', '_streams', '_mq', '_admin'];
37
+ excludePrefixes: string[] = ['_repl', '_streams', '_mq', '_admin', '_auth'];
38
38
  /** Bootstrap replica from primary's full state before applying _repl stream */
39
39
  fullBootstrap: boolean = true;
40
40
  compact?: CompactOptions;
@@ -17,6 +17,8 @@ export interface PathRule {
17
17
 
18
18
  export class RulesEngineOptions {
19
19
  rules: Record<string, PathRule> = {};
20
+ /** When true, deny operations that don't match any rule (default: open) */
21
+ defaultDeny: boolean = false;
20
22
  }
21
23
 
22
24
  interface CompiledPathRule {
@@ -69,10 +71,10 @@ export class RulesEngine {
69
71
  }
70
72
  }
71
73
 
72
- if (!bestMatch) return true;
74
+ if (!bestMatch) return !this.options.defaultDeny;
73
75
 
74
76
  const fn = bestMatch.rule[op];
75
- if (fn === undefined) return true;
77
+ if (fn === undefined) return !this.options.defaultDeny;
76
78
 
77
79
  const ctx: RuleContext = {
78
80
  auth,