@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
package/src/server/Transport.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from 'bun';
|
|
2
2
|
import type { BodDB } from './BodDB.ts';
|
|
3
3
|
import type { RulesEngine } from './RulesEngine.ts';
|
|
4
|
+
import type { KeyAuthEngine } from './KeyAuthEngine.ts';
|
|
5
|
+
import { AUTH_PREFIX } from './KeyAuthEngine.ts';
|
|
4
6
|
import type { ClientMessage } from '../shared/protocol.ts';
|
|
5
7
|
import { Errors } from '../shared/errors.ts';
|
|
6
8
|
import { normalizePath, pathKey } from '../shared/pathUtils.ts';
|
|
9
|
+
import { increment, serverTimestamp, arrayUnion, arrayRemove } from '../shared/transforms.ts';
|
|
7
10
|
|
|
8
11
|
interface VfsTransfer {
|
|
9
12
|
path: string;
|
|
@@ -22,8 +25,12 @@ export interface WsData {
|
|
|
22
25
|
export class TransportOptions {
|
|
23
26
|
port: number = 4400;
|
|
24
27
|
auth?: (token: string) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>;
|
|
28
|
+
/** KeyAuth engine (auto-wired from BodDB) */
|
|
29
|
+
keyAuth?: KeyAuthEngine;
|
|
25
30
|
/** Map URL paths to local file paths, e.g. { '/admin': './admin/ui.html' } */
|
|
26
31
|
staticRoutes?: Record<string, string>;
|
|
32
|
+
/** Custom REST routes checked before 404. Return null to fall through. */
|
|
33
|
+
extraRoutes?: Record<string, (req: Request, url: URL) => Response | Promise<Response> | null>;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export class Transport {
|
|
@@ -85,6 +92,9 @@ export class Transport {
|
|
|
85
92
|
if (req.method === 'PUT' && url.pathname.startsWith('/db/')) {
|
|
86
93
|
return (async () => {
|
|
87
94
|
const path = normalizePath(url.pathname.slice(4));
|
|
95
|
+
if (this.options.keyAuth && path.startsWith(AUTH_PREFIX)) {
|
|
96
|
+
return Response.json({ ok: false, error: `Write to ${AUTH_PREFIX} is reserved`, code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
97
|
+
}
|
|
88
98
|
if (this.db.replication?.isReplica) {
|
|
89
99
|
try {
|
|
90
100
|
const body = await req.json();
|
|
@@ -108,6 +118,9 @@ export class Transport {
|
|
|
108
118
|
if (req.method === 'DELETE' && url.pathname.startsWith('/db/')) {
|
|
109
119
|
return (async () => {
|
|
110
120
|
const path = normalizePath(url.pathname.slice(4));
|
|
121
|
+
if (this.options.keyAuth && path.startsWith(AUTH_PREFIX)) {
|
|
122
|
+
return Response.json({ ok: false, error: `Write to ${AUTH_PREFIX} is reserved`, code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
123
|
+
}
|
|
111
124
|
if (this.db.replication?.isReplica) {
|
|
112
125
|
try {
|
|
113
126
|
await this.db.replication.proxyWrite({ op: 'delete', path });
|
|
@@ -252,6 +265,16 @@ export class Transport {
|
|
|
252
265
|
if (filePath) return new Response(Bun.file(filePath));
|
|
253
266
|
}
|
|
254
267
|
|
|
268
|
+
// Extra routes (custom REST handlers)
|
|
269
|
+
if (this.options.extraRoutes) {
|
|
270
|
+
for (const [pattern, handler] of Object.entries(this.options.extraRoutes)) {
|
|
271
|
+
if (url.pathname === pattern || url.pathname.startsWith(pattern + '/')) {
|
|
272
|
+
const result = handler(req, url);
|
|
273
|
+
if (result) return result;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
255
278
|
return new Response('Not Found', { status: 404 });
|
|
256
279
|
}
|
|
257
280
|
|
|
@@ -281,6 +304,14 @@ export class Transport {
|
|
|
281
304
|
const { id } = msg;
|
|
282
305
|
const reply = (data: unknown, extra?: Record<string, unknown>) => ws.send(JSON.stringify({ id, ok: true, data, ...extra }));
|
|
283
306
|
const error = (err: string, code: string) => ws.send(JSON.stringify({ id, ok: false, error: err, code }));
|
|
307
|
+
// Block external writes to _auth/ prefix (only KeyAuthEngine may write)
|
|
308
|
+
const guardAuthPrefix = (path: string) => {
|
|
309
|
+
if (self.options.keyAuth && normalizePath(path).startsWith(AUTH_PREFIX)) {
|
|
310
|
+
error(`Write to ${AUTH_PREFIX} is reserved`, Errors.PERMISSION_DENIED);
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
};
|
|
284
315
|
|
|
285
316
|
try {
|
|
286
317
|
switch (msg.op) {
|
|
@@ -305,6 +336,7 @@ export class Transport {
|
|
|
305
336
|
}
|
|
306
337
|
|
|
307
338
|
case 'set': {
|
|
339
|
+
if (guardAuthPrefix(msg.path)) return;
|
|
308
340
|
if (self.db.replication?.isReplica) {
|
|
309
341
|
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
310
342
|
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
@@ -317,6 +349,9 @@ export class Transport {
|
|
|
317
349
|
}
|
|
318
350
|
|
|
319
351
|
case 'update': {
|
|
352
|
+
for (const path of Object.keys(msg.updates)) {
|
|
353
|
+
if (guardAuthPrefix(path)) return;
|
|
354
|
+
}
|
|
320
355
|
if (self.db.replication?.isReplica) {
|
|
321
356
|
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
322
357
|
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
@@ -331,6 +366,7 @@ export class Transport {
|
|
|
331
366
|
}
|
|
332
367
|
|
|
333
368
|
case 'delete': {
|
|
369
|
+
if (guardAuthPrefix(msg.path)) return;
|
|
334
370
|
if (self.db.replication?.isReplica) {
|
|
335
371
|
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
336
372
|
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
@@ -433,6 +469,7 @@ export class Transport {
|
|
|
433
469
|
}
|
|
434
470
|
|
|
435
471
|
case 'push': {
|
|
472
|
+
if (guardAuthPrefix(msg.path)) return;
|
|
436
473
|
if (self.db.replication?.isReplica) {
|
|
437
474
|
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
438
475
|
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
@@ -664,6 +701,182 @@ export class Transport {
|
|
|
664
701
|
return reply(moved);
|
|
665
702
|
}
|
|
666
703
|
|
|
704
|
+
case 'auth-challenge': {
|
|
705
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
706
|
+
return reply(self.options.keyAuth.challenge());
|
|
707
|
+
}
|
|
708
|
+
case 'auth-verify': {
|
|
709
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
710
|
+
const result = self.options.keyAuth.verify(msg.publicKey, msg.signature, msg.nonce);
|
|
711
|
+
if (!result) return error('Authentication failed', Errors.AUTH_REQUIRED);
|
|
712
|
+
// Also set ws auth context
|
|
713
|
+
const ctx = self.options.keyAuth.validateToken(result.token);
|
|
714
|
+
if (ctx) ws.data.auth = ctx as unknown as Record<string, unknown>;
|
|
715
|
+
return reply(result);
|
|
716
|
+
}
|
|
717
|
+
case 'auth-link-device': {
|
|
718
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
719
|
+
// Password is the auth — engine.linkDevice verifies it
|
|
720
|
+
const linked = self.options.keyAuth.linkDevice(msg.accountFingerprint, msg.password, msg.devicePublicKey, msg.deviceName);
|
|
721
|
+
if (!linked) return error('Link failed (wrong password or account not found)', Errors.AUTH_REQUIRED);
|
|
722
|
+
return reply(linked);
|
|
723
|
+
}
|
|
724
|
+
case 'auth-change-password': {
|
|
725
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
726
|
+
// If authenticated, non-root can only change own password
|
|
727
|
+
if (ws.data.auth) {
|
|
728
|
+
const authCtx = ws.data.auth as { isRoot?: boolean; accountFingerprint?: string };
|
|
729
|
+
if (!authCtx.isRoot && authCtx.accountFingerprint !== msg.fingerprint) {
|
|
730
|
+
return error('Can only change own password', Errors.PERMISSION_DENIED);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
// Password is the auth — engine.changePassword verifies old password
|
|
734
|
+
const changed = self.options.keyAuth.changePassword(msg.fingerprint, msg.oldPassword, msg.newPassword);
|
|
735
|
+
if (!changed) return error('Password change failed (wrong password or device account)', Errors.AUTH_REQUIRED);
|
|
736
|
+
return reply(null);
|
|
737
|
+
}
|
|
738
|
+
case 'auth-create-account': {
|
|
739
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
740
|
+
// Root-only, or allowed when zero accounts exist (bootstrap)
|
|
741
|
+
if (ws.data.auth) {
|
|
742
|
+
const ctx = ws.data.auth as { isRoot?: boolean };
|
|
743
|
+
if (!ctx.isRoot) return error('Root only', Errors.PERMISSION_DENIED);
|
|
744
|
+
} else {
|
|
745
|
+
// Only allow unauthenticated if no accounts exist yet
|
|
746
|
+
const accounts = self.options.keyAuth.listAccounts();
|
|
747
|
+
if (accounts.length > 0) return error('Authentication required', Errors.AUTH_REQUIRED);
|
|
748
|
+
}
|
|
749
|
+
const result = self.options.keyAuth.createAccount(msg.password, msg.roles, msg.displayName, msg.devicePublicKey);
|
|
750
|
+
return reply(result);
|
|
751
|
+
}
|
|
752
|
+
case 'auth-register-device': {
|
|
753
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
754
|
+
if (!self.options.keyAuth.options.allowOpenRegistration && !ws.data.auth) {
|
|
755
|
+
return error('Authentication required for registration', Errors.AUTH_REQUIRED);
|
|
756
|
+
}
|
|
757
|
+
const result = self.options.keyAuth.registerDevice(msg.publicKey, msg.displayName);
|
|
758
|
+
return reply(result);
|
|
759
|
+
}
|
|
760
|
+
case 'auth-revoke-session': {
|
|
761
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
762
|
+
if (!ws.data.auth) return error('Authentication required', Errors.AUTH_REQUIRED);
|
|
763
|
+
self.options.keyAuth.revokeSession(msg.sid);
|
|
764
|
+
return reply(null);
|
|
765
|
+
}
|
|
766
|
+
case 'auth-revoke-device': {
|
|
767
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
768
|
+
if (!ws.data.auth) return error('Authentication required', Errors.AUTH_REQUIRED);
|
|
769
|
+
self.options.keyAuth.revokeDevice(msg.accountFingerprint, msg.deviceFingerprint);
|
|
770
|
+
return reply(null);
|
|
771
|
+
}
|
|
772
|
+
case 'auth-create-role': {
|
|
773
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
774
|
+
if (!ws.data.auth || !(ws.data.auth as any).isRoot) return error('Root only', Errors.PERMISSION_DENIED);
|
|
775
|
+
self.options.keyAuth.createRole(msg.role);
|
|
776
|
+
return reply(null);
|
|
777
|
+
}
|
|
778
|
+
case 'auth-delete-role': {
|
|
779
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
780
|
+
if (!ws.data.auth || !(ws.data.auth as any).isRoot) return error('Root only', Errors.PERMISSION_DENIED);
|
|
781
|
+
self.options.keyAuth.deleteRole(msg.roleId);
|
|
782
|
+
return reply(null);
|
|
783
|
+
}
|
|
784
|
+
case 'auth-update-roles': {
|
|
785
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
786
|
+
if (!ws.data.auth || !(ws.data.auth as any).isRoot) return error('Root only', Errors.PERMISSION_DENIED);
|
|
787
|
+
self.options.keyAuth.updateAccountRoles(msg.accountFingerprint, msg.roles);
|
|
788
|
+
return reply(null);
|
|
789
|
+
}
|
|
790
|
+
case 'auth-list-accounts': {
|
|
791
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
792
|
+
if (!ws.data.auth) return error('Authentication required', Errors.AUTH_REQUIRED);
|
|
793
|
+
return reply(self.options.keyAuth.listAccounts());
|
|
794
|
+
}
|
|
795
|
+
case 'auth-list-account-fingerprints': {
|
|
796
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
797
|
+
// Safe: returns only fingerprints + displayNames, no secrets
|
|
798
|
+
const accounts = self.options.keyAuth.listAccounts();
|
|
799
|
+
return reply(accounts.map((a: any) => ({ fingerprint: a.fingerprint, displayName: a.displayName })));
|
|
800
|
+
}
|
|
801
|
+
case 'auth-list-roles': {
|
|
802
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
803
|
+
if (!ws.data.auth) return error('Authentication required', Errors.AUTH_REQUIRED);
|
|
804
|
+
return reply(self.options.keyAuth.listRoles());
|
|
805
|
+
}
|
|
806
|
+
case 'auth-list-devices': {
|
|
807
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
808
|
+
if (!ws.data.auth) return error('Authentication required', Errors.AUTH_REQUIRED);
|
|
809
|
+
return reply(self.options.keyAuth.listDevices(msg.accountFingerprint));
|
|
810
|
+
}
|
|
811
|
+
case 'auth-list-sessions': {
|
|
812
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
813
|
+
if (!ws.data.auth) return error('Authentication required', Errors.AUTH_REQUIRED);
|
|
814
|
+
return reply(self.options.keyAuth.listSessions(msg.accountFingerprint));
|
|
815
|
+
}
|
|
816
|
+
case 'auth-request-approval': {
|
|
817
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
818
|
+
return reply(self.options.keyAuth.requestApproval(msg.publicKey));
|
|
819
|
+
}
|
|
820
|
+
case 'auth-approve-device': {
|
|
821
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
822
|
+
if (!ws.data.auth) return error('Authentication required', Errors.AUTH_REQUIRED);
|
|
823
|
+
const approverFp = (ws.data.auth as any).accountFingerprint;
|
|
824
|
+
if (!approverFp) return error('No account context', Errors.AUTH_REQUIRED);
|
|
825
|
+
const result = self.options.keyAuth.approveDevice(msg.requestId, approverFp);
|
|
826
|
+
if (!result) return error('Approval not found or already processed', Errors.NOT_FOUND);
|
|
827
|
+
return reply(result);
|
|
828
|
+
}
|
|
829
|
+
case 'auth-poll-approval': {
|
|
830
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
831
|
+
return reply(self.options.keyAuth.pollApproval(msg.requestId));
|
|
832
|
+
}
|
|
833
|
+
case 'auth-has-accounts': {
|
|
834
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
835
|
+
return reply({ hasAccounts: self.options.keyAuth.hasAccounts() });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
case 'auth-account-info': {
|
|
839
|
+
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
840
|
+
if (!ws.data.auth) return error('Authentication required', Errors.AUTH_REQUIRED);
|
|
841
|
+
const ctx = ws.data.auth as { accountFingerprint?: string };
|
|
842
|
+
const fp = msg.accountFingerprint || ctx.accountFingerprint;
|
|
843
|
+
if (!fp) return error('No account fingerprint', Errors.INVALID_PATH);
|
|
844
|
+
const info = self.options.keyAuth.getAccountInfo(fp);
|
|
845
|
+
if (!info) return error('Account not found', Errors.NOT_FOUND);
|
|
846
|
+
return reply(info);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
case 'transform': {
|
|
850
|
+
const { path: tPath, type, value: tVal } = msg as any;
|
|
851
|
+
let sentinel: unknown;
|
|
852
|
+
switch (type) {
|
|
853
|
+
case 'increment': sentinel = increment(tVal as number); break;
|
|
854
|
+
case 'serverTimestamp': sentinel = serverTimestamp(); break;
|
|
855
|
+
case 'arrayUnion': sentinel = arrayUnion(...(Array.isArray(tVal) ? tVal : [tVal])); break;
|
|
856
|
+
case 'arrayRemove': sentinel = arrayRemove(...(Array.isArray(tVal) ? tVal : [tVal])); break;
|
|
857
|
+
default: return error(`Unknown transform: ${type}`, Errors.INTERNAL);
|
|
858
|
+
}
|
|
859
|
+
self.db.set(tPath, sentinel);
|
|
860
|
+
return reply(self.db.get(tPath));
|
|
861
|
+
}
|
|
862
|
+
case 'set-ttl': {
|
|
863
|
+
const { path: ttlPath, value: ttlVal, ttl } = msg as any;
|
|
864
|
+
self.db.set(ttlPath, ttlVal, { ttl });
|
|
865
|
+
return reply(null);
|
|
866
|
+
}
|
|
867
|
+
case 'sweep': {
|
|
868
|
+
return reply(self.db.sweep());
|
|
869
|
+
}
|
|
870
|
+
case 'get-rules': {
|
|
871
|
+
const ruleEntries = self.rules ? (self.rules as any).options?.rules ?? {} : {};
|
|
872
|
+
const summary = Object.entries(ruleEntries).map(([pattern, rule]: [string, any]) => ({
|
|
873
|
+
pattern,
|
|
874
|
+
read: rule.read === undefined ? null : rule.read === false ? false : rule.read === true ? true : rule.read.toString(),
|
|
875
|
+
write: rule.write === undefined ? null : rule.write === false ? false : rule.write === true ? true : rule.write.toString(),
|
|
876
|
+
}));
|
|
877
|
+
return reply(summary);
|
|
878
|
+
}
|
|
879
|
+
|
|
667
880
|
default:
|
|
668
881
|
return error('Unknown operation', Errors.INTERNAL);
|
|
669
882
|
}
|
package/src/server/VFSEngine.ts
CHANGED
|
@@ -10,6 +10,54 @@ export interface VFSBackend {
|
|
|
10
10
|
write(fileId: string, data: Uint8Array): Promise<void>;
|
|
11
11
|
delete(fileId: string): Promise<void>;
|
|
12
12
|
exists(fileId: string): Promise<boolean>;
|
|
13
|
+
/** Optional: create a directory on the physical filesystem */
|
|
14
|
+
mkdir?(dirPath: string): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// --- DiskBackend --- stores files as real directory structure on disk
|
|
18
|
+
|
|
19
|
+
export class DiskBackend implements VFSBackend {
|
|
20
|
+
constructor(private root: string) {}
|
|
21
|
+
|
|
22
|
+
private resolvePath(filePath: string): string {
|
|
23
|
+
if (filePath.split('/').includes('..')) throw new Error(`Path traversal rejected: ${filePath}`);
|
|
24
|
+
return `${this.root}/${filePath}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async read(filePath: string): Promise<Uint8Array> {
|
|
28
|
+
const f = Bun.file(this.resolvePath(filePath));
|
|
29
|
+
if (!(await f.exists())) throw new Error(`File not found: ${filePath}`);
|
|
30
|
+
return new Uint8Array(await f.arrayBuffer());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async write(filePath: string, data: Uint8Array): Promise<void> {
|
|
34
|
+
const full = this.resolvePath(filePath);
|
|
35
|
+
const { mkdirSync } = require('fs');
|
|
36
|
+
const dir = full.substring(0, full.lastIndexOf('/'));
|
|
37
|
+
if (dir) try { mkdirSync(dir, { recursive: true }); } catch {}
|
|
38
|
+
await Bun.write(full, data);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async delete(filePath: string): Promise<void> {
|
|
42
|
+
const full = this.resolvePath(filePath);
|
|
43
|
+
const { unlink, rmdir } = require('fs/promises');
|
|
44
|
+
try { await unlink(full); } catch { return; }
|
|
45
|
+
// Cleanup empty parent dirs up to root
|
|
46
|
+
let dir = full.substring(0, full.lastIndexOf('/'));
|
|
47
|
+
while (dir.length > this.root.length) {
|
|
48
|
+
try { await rmdir(dir); } catch { break; }
|
|
49
|
+
dir = dir.substring(0, dir.lastIndexOf('/'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async exists(filePath: string): Promise<boolean> {
|
|
54
|
+
return Bun.file(this.resolvePath(filePath)).exists();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
mkdir(dirPath: string): void {
|
|
58
|
+
const { mkdirSync } = require('fs');
|
|
59
|
+
mkdirSync(this.resolvePath(dirPath), { recursive: true });
|
|
60
|
+
}
|
|
13
61
|
}
|
|
14
62
|
|
|
15
63
|
// --- LocalBackend ---
|
|
@@ -52,6 +100,8 @@ const META_KEY = '__meta';
|
|
|
52
100
|
export class VFSEngineOptions {
|
|
53
101
|
storageRoot: string = '.data/vfs';
|
|
54
102
|
metaPrefix: string = '_vfs';
|
|
103
|
+
backend?: VFSBackend;
|
|
104
|
+
pathAsFileId?: boolean;
|
|
55
105
|
}
|
|
56
106
|
|
|
57
107
|
export class VFSEngine {
|
|
@@ -62,9 +112,13 @@ export class VFSEngine {
|
|
|
62
112
|
constructor(db: BodDB, options?: Partial<VFSEngineOptions>) {
|
|
63
113
|
this.options = { ...new VFSEngineOptions(), ...options };
|
|
64
114
|
this.db = db;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
115
|
+
if (this.options.backend) {
|
|
116
|
+
this.backend = this.options.backend;
|
|
117
|
+
} else {
|
|
118
|
+
this.backend = new LocalBackend(this.options.storageRoot);
|
|
119
|
+
const { mkdirSync } = require('fs');
|
|
120
|
+
try { mkdirSync(this.options.storageRoot, { recursive: true }); } catch {}
|
|
121
|
+
}
|
|
68
122
|
}
|
|
69
123
|
|
|
70
124
|
private metaPath(virtualPath: string): string {
|
|
@@ -80,7 +134,7 @@ export class VFSEngine {
|
|
|
80
134
|
async write(virtualPath: string, data: Uint8Array, mime?: string): Promise<FileStat> {
|
|
81
135
|
const vp = normalizePath(virtualPath);
|
|
82
136
|
const existing = this.db.get(this.metaPath(vp)) as Record<string, unknown> | null;
|
|
83
|
-
const fileId = (existing?.fileId as string) || generatePushId();
|
|
137
|
+
const fileId = this.options.pathAsFileId ? vp : ((existing?.fileId as string) || generatePushId());
|
|
84
138
|
|
|
85
139
|
await this.backend.write(fileId, data);
|
|
86
140
|
|
|
@@ -134,15 +188,24 @@ export class VFSEngine {
|
|
|
134
188
|
const name = vp.split('/').pop()!;
|
|
135
189
|
const stat: FileStat = { name, path: vp, size: 0, mime: '', mtime: Date.now(), isDir: true };
|
|
136
190
|
this.db.set(this.metaPath(vp), stat);
|
|
191
|
+
if (this.backend.mkdir) try { this.backend.mkdir(vp); } catch {}
|
|
137
192
|
return stat;
|
|
138
193
|
}
|
|
139
194
|
|
|
140
195
|
async remove(virtualPath: string): Promise<void> {
|
|
141
196
|
const vp = normalizePath(virtualPath);
|
|
142
|
-
const meta = this.db.get(this.metaPath(vp)) as
|
|
143
|
-
|
|
197
|
+
const meta = this.db.get(this.metaPath(vp)) as FileStat | null;
|
|
198
|
+
|
|
199
|
+
if (meta?.isDir) {
|
|
200
|
+
// Recursively delete children from backend before wiping metadata
|
|
201
|
+
const children = this.list(vp);
|
|
202
|
+
for (const child of children) {
|
|
203
|
+
await this.remove(child.path);
|
|
204
|
+
}
|
|
205
|
+
} else if (meta?.fileId) {
|
|
144
206
|
await this.backend.delete(meta.fileId as string);
|
|
145
207
|
}
|
|
208
|
+
|
|
146
209
|
this.db.delete(this.containerPath(vp));
|
|
147
210
|
}
|
|
148
211
|
|
|
@@ -152,8 +215,16 @@ export class VFSEngine {
|
|
|
152
215
|
const meta = this.db.get(this.metaPath(srcPath)) as FileStat | null;
|
|
153
216
|
if (!meta) throw new Error(`File not found: ${srcPath}`);
|
|
154
217
|
|
|
218
|
+
// For DiskBackend (pathAsFileId): physically move the file
|
|
219
|
+
if (this.options.pathAsFileId && meta.fileId && !meta.isDir) {
|
|
220
|
+
const data = await this.backend.read(meta.fileId);
|
|
221
|
+
await this.backend.write(dstPath, data);
|
|
222
|
+
await this.backend.delete(meta.fileId);
|
|
223
|
+
}
|
|
224
|
+
|
|
155
225
|
const newName = dstPath.split('/').pop()!;
|
|
156
|
-
const
|
|
226
|
+
const fileId = this.options.pathAsFileId ? dstPath : meta.fileId;
|
|
227
|
+
const updated: FileStat = { ...meta, name: newName, path: dstPath, fileId, mtime: Date.now() };
|
|
157
228
|
this.db.set(this.metaPath(dstPath), updated);
|
|
158
229
|
this.db.delete(this.containerPath(srcPath));
|
|
159
230
|
return updated;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-compatible Ed25519 crypto for BodDB KeyAuth.
|
|
3
|
+
* Uses @noble/ed25519 (5KB, zero deps) with DER wrapping for server compatibility.
|
|
4
|
+
*/
|
|
5
|
+
import * as ed from '@noble/ed25519';
|
|
6
|
+
|
|
7
|
+
// --- DER encoding prefixes (fixed ASN.1 headers for Ed25519) ---
|
|
8
|
+
const SPKI_PREFIX = new Uint8Array([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]); // 12 bytes
|
|
9
|
+
const PKCS8_PREFIX = new Uint8Array([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]); // 16 bytes
|
|
10
|
+
|
|
11
|
+
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
12
|
+
const out = new Uint8Array(a.length + b.length);
|
|
13
|
+
out.set(a);
|
|
14
|
+
out.set(b, a.length);
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toBase64(buf: Uint8Array): string {
|
|
19
|
+
let binary = '';
|
|
20
|
+
for (let i = 0; i < buf.length; i++) binary += String.fromCharCode(buf[i]);
|
|
21
|
+
return btoa(binary);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fromBase64(str: string): Uint8Array {
|
|
25
|
+
const binary = atob(str);
|
|
26
|
+
const bytes = new Uint8Array(binary.length);
|
|
27
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
28
|
+
return bytes;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toHex(buf: Uint8Array): string {
|
|
32
|
+
return Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Wrap raw 32-byte public key in SPKI DER format */
|
|
36
|
+
export function wrapPublicKey(raw: Uint8Array): Uint8Array {
|
|
37
|
+
return concat(SPKI_PREFIX, raw);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Extract raw 32-byte key from DER-encoded key */
|
|
41
|
+
export function unwrapPublicKey(der: Uint8Array): Uint8Array {
|
|
42
|
+
return der.slice(SPKI_PREFIX.length);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BrowserKeyPair {
|
|
46
|
+
/** DER-encoded public key (SPKI), base64 — compatible with server */
|
|
47
|
+
publicKeyBase64: string;
|
|
48
|
+
/** Raw 32-byte private key, base64 — for client-side storage only */
|
|
49
|
+
privateKeyBase64: string;
|
|
50
|
+
/** SHA-256 hex fingerprint of DER public key */
|
|
51
|
+
fingerprint: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Generate Ed25519 keypair. Returns DER-encoded public key (server-compatible) + raw private key. */
|
|
55
|
+
export async function generateKeyPair(): Promise<BrowserKeyPair> {
|
|
56
|
+
const rawPriv = ed.utils.randomSecretKey();
|
|
57
|
+
const rawPub = await ed.getPublicKeyAsync(rawPriv);
|
|
58
|
+
const derPub = wrapPublicKey(rawPub);
|
|
59
|
+
const fp = await browserFingerprint(toBase64(derPub));
|
|
60
|
+
return {
|
|
61
|
+
publicKeyBase64: toBase64(derPub),
|
|
62
|
+
privateKeyBase64: toBase64(rawPriv),
|
|
63
|
+
fingerprint: fp,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Sign a nonce string with raw private key. Returns base64 DER-compatible signature. */
|
|
68
|
+
export async function sign(nonce: string, privateKeyBase64: string): Promise<string> {
|
|
69
|
+
const rawPriv = fromBase64(privateKeyBase64);
|
|
70
|
+
const data = new TextEncoder().encode(nonce);
|
|
71
|
+
const sig = await ed.signAsync(data, rawPriv);
|
|
72
|
+
return toBase64(sig);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** SHA-256 fingerprint of a base64 DER-encoded public key (hex string) */
|
|
76
|
+
export async function browserFingerprint(publicKeyBase64: string): Promise<string> {
|
|
77
|
+
const derBytes = fromBase64(publicKeyBase64);
|
|
78
|
+
const hash = await crypto.subtle.digest('SHA-256', derBytes);
|
|
79
|
+
return toHex(new Uint8Array(hash));
|
|
80
|
+
}
|
|
@@ -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
|
+
}
|