@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,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
|
|
74
|
+
if (!bestMatch) return !this.options.defaultDeny;
|
|
73
75
|
|
|
74
76
|
const fn = bestMatch.rule[op];
|
|
75
|
-
if (fn === undefined) return
|
|
77
|
+
if (fn === undefined) return !this.options.defaultDeny;
|
|
76
78
|
|
|
77
79
|
const ctx: RuleContext = {
|
|
78
80
|
auth,
|