@bod.ee/db 0.9.1 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +225 -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 +223 -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 +1037 -0
- package/admin/server.ts +0 -607
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash, randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync,
|
|
3
|
+
sign as cryptoSign, verify as cryptoVerify, generateKeyPairSync,
|
|
4
|
+
} from 'crypto';
|
|
5
|
+
|
|
6
|
+
// --- Types ---
|
|
7
|
+
|
|
8
|
+
export interface KeyAuthAccount {
|
|
9
|
+
publicKey: string; // base64
|
|
10
|
+
fingerprint: string; // hex SHA-256 of publicKey
|
|
11
|
+
encryptedPrivateKey: string; // base64 (AES-256-GCM encrypted)
|
|
12
|
+
salt: string; // base64 (PBKDF2 salt)
|
|
13
|
+
iv: string; // base64 (AES-GCM IV)
|
|
14
|
+
authTag: string; // base64 (AES-GCM auth tag)
|
|
15
|
+
displayName?: string;
|
|
16
|
+
roles: string[];
|
|
17
|
+
createdAt: number;
|
|
18
|
+
lastAuth: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface KeyAuthDevice {
|
|
22
|
+
publicKey: string;
|
|
23
|
+
fingerprint: string;
|
|
24
|
+
accountFingerprint: string;
|
|
25
|
+
certificate: string; // base64 Ed25519 signature of (devicePubKey + accountFp + linkedAt)
|
|
26
|
+
name?: string;
|
|
27
|
+
linkedAt: number;
|
|
28
|
+
lastAuth: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface KeyAuthRole {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
permissions: Array<{ path: string; read?: boolean; write?: boolean }>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface KeyAuthSession {
|
|
38
|
+
fingerprint: string; // device or root fingerprint
|
|
39
|
+
accountFingerprint: string;
|
|
40
|
+
roles: string[];
|
|
41
|
+
isRoot: boolean;
|
|
42
|
+
createdAt: number;
|
|
43
|
+
expiresAt: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface KeyAuthContext {
|
|
47
|
+
sid: string;
|
|
48
|
+
fingerprint: string;
|
|
49
|
+
accountFingerprint: string;
|
|
50
|
+
roles: string[];
|
|
51
|
+
isRoot: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface KeyAuthTokenPayload {
|
|
55
|
+
sid: string;
|
|
56
|
+
fp: string;
|
|
57
|
+
accountFp: string;
|
|
58
|
+
roles: string[];
|
|
59
|
+
root: boolean;
|
|
60
|
+
exp: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Utils ---
|
|
64
|
+
|
|
65
|
+
export function fingerprint(publicKeyBase64: string): string {
|
|
66
|
+
return createHash('sha256').update(Buffer.from(publicKeyBase64, 'base64')).digest('hex');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function generateNonce(): string {
|
|
70
|
+
return randomBytes(32).toString('base64url');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function generateSessionId(): string {
|
|
74
|
+
return randomBytes(16).toString('base64url');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Token encoding (self-signed, no JWT library) ---
|
|
78
|
+
|
|
79
|
+
function base64url(buf: Buffer): string {
|
|
80
|
+
return buf.toString('base64url');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fromBase64url(str: string): Buffer {
|
|
84
|
+
return Buffer.from(str, 'base64url');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function encodeToken(payload: KeyAuthTokenPayload, serverPrivateKey: Uint8Array): string {
|
|
88
|
+
const payloadBytes = Buffer.from(JSON.stringify(payload));
|
|
89
|
+
const payloadB64 = base64url(payloadBytes);
|
|
90
|
+
const sig = cryptoSign(null, Buffer.from(payloadB64), {
|
|
91
|
+
key: Buffer.from(serverPrivateKey),
|
|
92
|
+
format: 'der',
|
|
93
|
+
type: 'pkcs8',
|
|
94
|
+
});
|
|
95
|
+
return payloadB64 + '.' + base64url(sig);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function decodeToken(token: string, serverPublicKey: Uint8Array): KeyAuthTokenPayload | null {
|
|
99
|
+
const dotIdx = token.indexOf('.');
|
|
100
|
+
if (dotIdx < 0) return null;
|
|
101
|
+
const payloadB64 = token.slice(0, dotIdx);
|
|
102
|
+
const sigB64 = token.slice(dotIdx + 1);
|
|
103
|
+
try {
|
|
104
|
+
const valid = cryptoVerify(null, Buffer.from(payloadB64), {
|
|
105
|
+
key: Buffer.from(serverPublicKey),
|
|
106
|
+
format: 'der',
|
|
107
|
+
type: 'spki',
|
|
108
|
+
}, fromBase64url(sigB64));
|
|
109
|
+
if (!valid) return null;
|
|
110
|
+
return JSON.parse(fromBase64url(payloadB64).toString());
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Crypto: AES-256-GCM + PBKDF2 ---
|
|
117
|
+
|
|
118
|
+
const PBKDF2_ITERATIONS = 100_000;
|
|
119
|
+
const PBKDF2_KEYLEN = 32; // 256 bits
|
|
120
|
+
const PBKDF2_DIGEST = 'sha256';
|
|
121
|
+
|
|
122
|
+
export function encryptPrivateKey(privateKeyDer: Uint8Array, password: string): { encrypted: string; salt: string; iv: string; authTag: string } {
|
|
123
|
+
const salt = randomBytes(16);
|
|
124
|
+
const iv = randomBytes(12); // 96-bit for GCM
|
|
125
|
+
const derivedKey = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
126
|
+
const cipher = createCipheriv('aes-256-gcm', derivedKey, iv);
|
|
127
|
+
const encrypted = Buffer.concat([cipher.update(privateKeyDer), cipher.final()]);
|
|
128
|
+
const authTag = cipher.getAuthTag();
|
|
129
|
+
return {
|
|
130
|
+
encrypted: encrypted.toString('base64'),
|
|
131
|
+
salt: salt.toString('base64'),
|
|
132
|
+
iv: iv.toString('base64'),
|
|
133
|
+
authTag: authTag.toString('base64'),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function decryptPrivateKey(encrypted: string, salt: string, iv: string, authTag: string, password: string): Uint8Array {
|
|
138
|
+
const derivedKey = pbkdf2Sync(password, Buffer.from(salt, 'base64'), PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
139
|
+
const decipher = createDecipheriv('aes-256-gcm', derivedKey, Buffer.from(iv, 'base64'));
|
|
140
|
+
decipher.setAuthTag(Buffer.from(authTag, 'base64'));
|
|
141
|
+
const decrypted = Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64')), decipher.final()]);
|
|
142
|
+
return new Uint8Array(decrypted);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- Ed25519 key generation ---
|
|
146
|
+
|
|
147
|
+
export function generateEd25519KeyPair(): { publicKey: Uint8Array; privateKey: Uint8Array; publicKeyBase64: string } {
|
|
148
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
|
|
149
|
+
publicKeyEncoding: { type: 'spki', format: 'der' },
|
|
150
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
|
151
|
+
});
|
|
152
|
+
return {
|
|
153
|
+
publicKey: new Uint8Array(publicKey),
|
|
154
|
+
privateKey: new Uint8Array(privateKey),
|
|
155
|
+
publicKeyBase64: Buffer.from(publicKey).toString('base64'),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function signData(data: Buffer, privateKeyDer: Uint8Array): Buffer {
|
|
160
|
+
return cryptoSign(null, data, {
|
|
161
|
+
key: Buffer.from(privateKeyDer),
|
|
162
|
+
format: 'der',
|
|
163
|
+
type: 'pkcs8',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function verifySignature(data: Buffer, signature: Buffer, publicKeyDer: Uint8Array): boolean {
|
|
168
|
+
try {
|
|
169
|
+
return cryptoVerify(null, data, {
|
|
170
|
+
key: Buffer.from(publicKeyDer),
|
|
171
|
+
format: 'der',
|
|
172
|
+
type: 'spki',
|
|
173
|
+
}, signature);
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/shared/protocol.ts
CHANGED
|
@@ -38,7 +38,34 @@ export type ClientMessage =
|
|
|
38
38
|
| { id: string; op: 'vfs-list'; path: string }
|
|
39
39
|
| { id: string; op: 'vfs-delete'; path: string }
|
|
40
40
|
| { id: string; op: 'vfs-mkdir'; path: string }
|
|
41
|
-
| { id: string; op: 'vfs-move'; path: string; dst: string }
|
|
41
|
+
| { id: string; op: 'vfs-move'; path: string; dst: string }
|
|
42
|
+
// KeyAuth ops
|
|
43
|
+
| { id: string; op: 'auth-challenge' }
|
|
44
|
+
| { id: string; op: 'auth-verify'; publicKey: string; signature: string; nonce: string }
|
|
45
|
+
| { id: string; op: 'auth-link-device'; accountFingerprint: string; password: string; devicePublicKey: string; deviceName?: string }
|
|
46
|
+
| { id: string; op: 'auth-change-password'; fingerprint: string; oldPassword: string; newPassword: string }
|
|
47
|
+
| { id: string; op: 'auth-create-account'; password: string; roles?: string[]; displayName?: string }
|
|
48
|
+
| { id: string; op: 'auth-register-device'; publicKey: string; displayName?: string }
|
|
49
|
+
| { id: string; op: 'auth-revoke-session'; sid: string }
|
|
50
|
+
| { id: string; op: 'auth-revoke-device'; accountFingerprint: string; deviceFingerprint: string }
|
|
51
|
+
| { id: string; op: 'auth-create-role'; role: { id: string; name: string; permissions: Array<{ path: string; read?: boolean; write?: boolean }> } }
|
|
52
|
+
| { id: string; op: 'auth-delete-role'; roleId: string }
|
|
53
|
+
| { id: string; op: 'auth-update-roles'; accountFingerprint: string; roles: string[] }
|
|
54
|
+
| { id: string; op: 'auth-list-accounts' }
|
|
55
|
+
| { id: string; op: 'auth-list-roles' }
|
|
56
|
+
| { id: string; op: 'auth-list-devices'; accountFingerprint: string }
|
|
57
|
+
| { id: string; op: 'auth-list-sessions'; accountFingerprint?: string }
|
|
58
|
+
| { id: string; op: 'auth-request-approval'; publicKey: string }
|
|
59
|
+
| { id: string; op: 'auth-approve-device'; requestId: string }
|
|
60
|
+
| { id: string; op: 'auth-poll-approval'; requestId: string }
|
|
61
|
+
| { id: string; op: 'auth-has-accounts' }
|
|
62
|
+
| { id: string; op: 'auth-account-info'; accountFingerprint?: string }
|
|
63
|
+
| { id: string; op: 'auth-list-account-fingerprints' }
|
|
64
|
+
// Admin ops
|
|
65
|
+
| { id: string; op: 'transform'; path: string; type: string; value?: unknown }
|
|
66
|
+
| { id: string; op: 'set-ttl'; path: string; value: unknown; ttl: number }
|
|
67
|
+
| { id: string; op: 'sweep' }
|
|
68
|
+
| { id: string; op: 'get-rules' };
|
|
42
69
|
|
|
43
70
|
// Server → Client messages
|
|
44
71
|
export type ServerMessage =
|
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
2
|
import { BodDB } from '../src/server/BodDB.ts';
|
|
3
3
|
import { BodClient, ValueSnapshot } from '../src/client/BodClient.ts';
|
|
4
|
-
import {
|
|
4
|
+
import { BodClientCached } from '../src/client/BodClientCached.ts';
|
|
5
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
6
|
+
import { tmpdir } from 'os';
|
|
7
|
+
import { join } from 'path';
|
|
5
8
|
|
|
6
9
|
let db: BodDB;
|
|
7
10
|
let client: BodClient;
|
|
8
|
-
let cached:
|
|
11
|
+
let cached: BodClientCached;
|
|
12
|
+
const vfsRoot = mkdtempSync(join(tmpdir(), 'boddb-vfs-test-'));
|
|
9
13
|
|
|
10
14
|
beforeAll(async () => {
|
|
11
|
-
db = new BodDB({ port: 0 });
|
|
15
|
+
db = new BodDB({ port: 0, vfs: { storageRoot: vfsRoot } });
|
|
12
16
|
const server = db.serve();
|
|
13
17
|
client = new BodClient({ url: `ws://localhost:${server.port}`, reconnect: false });
|
|
14
18
|
await client.connect();
|
|
15
|
-
cached = new
|
|
19
|
+
cached = new BodClientCached(client, { enabled: true });
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
afterAll(() => {
|
|
19
23
|
client.disconnect();
|
|
20
24
|
db.close();
|
|
25
|
+
try { rmSync(vfsRoot, { recursive: true }); } catch {}
|
|
21
26
|
});
|
|
22
27
|
|
|
23
|
-
describe('
|
|
28
|
+
describe('BodClientCached', () => {
|
|
24
29
|
test('get() fetches from network on first call', async () => {
|
|
25
30
|
db.set('cache/test1', { name: 'hello' });
|
|
26
31
|
const val = await cached.get('cache/test1');
|
|
@@ -89,7 +94,7 @@ describe('CachedClient', () => {
|
|
|
89
94
|
});
|
|
90
95
|
|
|
91
96
|
test('disabled mode is pure passthrough', async () => {
|
|
92
|
-
const passthrough = new
|
|
97
|
+
const passthrough = new BodClientCached(client, { enabled: false });
|
|
93
98
|
db.set('cache/pt', 'val');
|
|
94
99
|
const v1 = await passthrough.get('cache/pt');
|
|
95
100
|
expect(v1).toBe('val');
|
|
@@ -99,7 +104,7 @@ describe('CachedClient', () => {
|
|
|
99
104
|
});
|
|
100
105
|
|
|
101
106
|
test('memory eviction respects maxMemoryEntries', async () => {
|
|
102
|
-
const small = new
|
|
107
|
+
const small = new BodClientCached(client, { maxMemoryEntries: 3 });
|
|
103
108
|
for (let i = 0; i < 5; i++) {
|
|
104
109
|
db.set(`cache/evict/${i}`, i);
|
|
105
110
|
await small.get(`cache/evict/${i}`);
|
|
@@ -141,3 +146,114 @@ describe('CachedClient', () => {
|
|
|
141
146
|
off();
|
|
142
147
|
});
|
|
143
148
|
});
|
|
149
|
+
|
|
150
|
+
describe('CachedVFSClient', () => {
|
|
151
|
+
test('list() caches and returns from memory on second call', async () => {
|
|
152
|
+
const vfs = cached.vfs();
|
|
153
|
+
// Upload a file to have something to list
|
|
154
|
+
await vfs.upload('vfs-test/a.txt', new TextEncoder().encode('hello'), 'text/plain');
|
|
155
|
+
|
|
156
|
+
const list1 = await vfs.list('vfs-test');
|
|
157
|
+
expect(list1.length).toBeGreaterThan(0);
|
|
158
|
+
|
|
159
|
+
// Second call returns from cache (stale-while-revalidate)
|
|
160
|
+
const list2 = await vfs.list('vfs-test');
|
|
161
|
+
expect(list2).toEqual(list1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('stat() caches and returns from memory on second call', async () => {
|
|
165
|
+
const vfs = cached.vfs();
|
|
166
|
+
const stat1 = await vfs.stat('vfs-test/a.txt');
|
|
167
|
+
expect(stat1).not.toBeNull();
|
|
168
|
+
expect(stat1!.name).toBe('a.txt');
|
|
169
|
+
|
|
170
|
+
const stat2 = await vfs.stat('vfs-test/a.txt');
|
|
171
|
+
expect(stat2).toEqual(stat1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('delete() invalidates stat + parent list cache', async () => {
|
|
175
|
+
const vfs = cached.vfs();
|
|
176
|
+
await vfs.upload('vfs-test/del.txt', new TextEncoder().encode('bye'), 'text/plain');
|
|
177
|
+
|
|
178
|
+
// Prime caches
|
|
179
|
+
await vfs.stat('vfs-test/del.txt');
|
|
180
|
+
await vfs.list('vfs-test');
|
|
181
|
+
|
|
182
|
+
await vfs.delete('vfs-test/del.txt');
|
|
183
|
+
|
|
184
|
+
// stat cache should be invalidated — fresh fetch returns null
|
|
185
|
+
const stat = await vfs.stat('vfs-test/del.txt');
|
|
186
|
+
expect(stat).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('move() invalidates both src and dst dirs', async () => {
|
|
190
|
+
const vfs = cached.vfs();
|
|
191
|
+
await vfs.upload('vfs-test/mv-src.txt', new TextEncoder().encode('data'), 'text/plain');
|
|
192
|
+
await vfs.mkdir('vfs-test/subdir');
|
|
193
|
+
|
|
194
|
+
// Prime list caches
|
|
195
|
+
await vfs.list('vfs-test');
|
|
196
|
+
await vfs.list('vfs-test/subdir');
|
|
197
|
+
|
|
198
|
+
await vfs.move('vfs-test/mv-src.txt', 'vfs-test/subdir/mv-dst.txt');
|
|
199
|
+
|
|
200
|
+
// Fresh list should reflect the move
|
|
201
|
+
const rootList = await vfs.list('vfs-test');
|
|
202
|
+
const names = rootList.map(s => s.name);
|
|
203
|
+
expect(names).not.toContain('mv-src.txt');
|
|
204
|
+
|
|
205
|
+
const subList = await vfs.list('vfs-test/subdir');
|
|
206
|
+
const subNames = subList.map(s => s.name);
|
|
207
|
+
expect(subNames).toContain('mv-dst.txt');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('mkdir() invalidates parent list and own list cache', async () => {
|
|
211
|
+
const vfs = cached.vfs();
|
|
212
|
+
// Prime parent list cache
|
|
213
|
+
await vfs.list('vfs-test');
|
|
214
|
+
|
|
215
|
+
await vfs.mkdir('vfs-test/newdir');
|
|
216
|
+
|
|
217
|
+
// Parent list should be invalidated — fresh fetch includes newdir
|
|
218
|
+
const parentList = await vfs.list('vfs-test');
|
|
219
|
+
expect(parentList.map(s => s.name)).toContain('newdir');
|
|
220
|
+
|
|
221
|
+
// The dir's own list cache should also be cleared (was stale empty)
|
|
222
|
+
const dirList = await vfs.list('vfs-test/newdir');
|
|
223
|
+
expect(dirList).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('stats tracks hits, misses and invalidations', async () => {
|
|
227
|
+
const fresh = new BodClientCached(client, { enabled: true });
|
|
228
|
+
const vfs = fresh.vfs();
|
|
229
|
+
|
|
230
|
+
await vfs.upload('vfs-stats/f.txt', new TextEncoder().encode('x'), 'text/plain');
|
|
231
|
+
await vfs.list('vfs-stats'); // miss
|
|
232
|
+
await vfs.list('vfs-stats'); // hit
|
|
233
|
+
|
|
234
|
+
const s = vfs.stats;
|
|
235
|
+
expect(s.misses).toBeGreaterThanOrEqual(1);
|
|
236
|
+
expect(s.hits).toBeGreaterThanOrEqual(1);
|
|
237
|
+
expect(s.hitRate).not.toBe('n/a');
|
|
238
|
+
expect(s.invalidations).toBeGreaterThanOrEqual(1); // from upload
|
|
239
|
+
fresh.close();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('server-side _vfs event invalidates list cache', async () => {
|
|
243
|
+
const vfs = cached.vfs();
|
|
244
|
+
|
|
245
|
+
// Prime list cache
|
|
246
|
+
await vfs.list('vfs-test');
|
|
247
|
+
|
|
248
|
+
// Upload via raw client (simulates another device)
|
|
249
|
+
await client.vfs().upload('vfs-test/remote.txt', new TextEncoder().encode('remote'), 'text/plain');
|
|
250
|
+
|
|
251
|
+
// Wait for _vfs event to propagate
|
|
252
|
+
await new Promise(r => setTimeout(r, 200));
|
|
253
|
+
|
|
254
|
+
// List should now show the new file (cache was invalidated by push event)
|
|
255
|
+
const list = await vfs.list('vfs-test');
|
|
256
|
+
const names = list.map(s => s.name);
|
|
257
|
+
expect(names).toContain('remote.txt');
|
|
258
|
+
});
|
|
259
|
+
});
|