@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.
@@ -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
  }
@@ -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
- this.backend = new LocalBackend(this.options.storageRoot);
66
- const { mkdirSync } = require('fs');
67
- try { mkdirSync(this.options.storageRoot, { recursive: true }); } catch {}
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 Record<string, unknown> | null;
143
- if (meta?.fileId) {
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 updated: FileStat = { ...meta, name: newName, path: dstPath, mtime: Date.now() };
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
+ }