@bod.ee/db 0.9.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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 { CachedClient } from '../src/client/CachedClient.ts';
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: CachedClient;
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 CachedClient(client, { enabled: true });
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('CachedClient', () => {
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 CachedClient(client, { enabled: false });
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 CachedClient(client, { maxMemoryEntries: 3 });
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
+ });