@johnhenry/wsh 0.0.0

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/src/index.mjs ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * wsh — Web Shell client library
3
+ *
4
+ * Browser-native remote command execution over WebTransport/WebSocket
5
+ * with Ed25519 authentication.
6
+ */
7
+
8
+ // CBOR codec + framing
9
+ export { cborEncode, cborDecode, frameEncode, FrameDecoder } from './cbor.mjs';
10
+
11
+ // Protocol messages
12
+ export {
13
+ MSG, MSG_NAMES, CHANNEL_KIND, AUTH_METHOD, PROTOCOL_VERSION,
14
+ hello, serverHello, challenge, authMethods, auth, authOk, authFail,
15
+ open, openOk, openFail, resize, signal, exit, close, sessionData, error, ping, pong,
16
+ attach, resume, rename, idleWarning, shutdown, snapshot,
17
+ presence, controlChanged, metrics,
18
+ mcpDiscover, mcpTools, mcpCall, mcpResult,
19
+ reverseRegister, reverseList, reversePeers, reverseConnect, reverseAccept, reverseReject,
20
+ openTcp, openUdp, resolveDns, gatewayOk, gatewayFail, gatewayClose,
21
+ inboundOpen, inboundAccept, inboundReject, dnsResult,
22
+ listenRequest, listenOk, listenFail, listenClose, gatewayData,
23
+ clipboard, recordingExport, commandJournal, metricsRequest,
24
+ suspendSession, restartPty, sessionListRequest, sessionList,
25
+ detach, detachOk, detachFail,
26
+ guestInvite, guestJoin, guestRevoke,
27
+ shareSession, shareRevoke,
28
+ compressBegin, compressAck,
29
+ rateControl, rateWarning,
30
+ sessionLink, sessionUnlink,
31
+ copilotAttach, copilotSuggest, copilotDetach,
32
+ keyExchange, encryptedFrame,
33
+ echoAck, echoState, termSync, termDiff,
34
+ nodeAnnounce, nodeRedirect,
35
+ sessionGrant, sessionRevoke,
36
+ fileOp, fileResult, fileChunk,
37
+ policyEval, policyResult, policyUpdate,
38
+ terminalConfig,
39
+ msgName, isValidMessage,
40
+ } from './messages.mjs';
41
+
42
+ // Auth + crypto
43
+ export {
44
+ generateKeyPair, exportPublicKeyRaw, exportPublicKeySSH,
45
+ importPublicKeyRaw, exportPrivateKeyPKCS8, importPrivateKeyPKCS8,
46
+ sign, verify, buildTranscript, signChallenge, verifyChallenge,
47
+ fingerprint, shortFingerprint, generateNonce,
48
+ parseSSHPublicKey, extractRawFromSSHWire, base64Decode,
49
+ } from './auth.mjs';
50
+
51
+ // Transport
52
+ export { WshTransport, WebTransportTransport } from './transport.mjs';
53
+ export { WebSocketTransport } from './transport-ws.mjs';
54
+
55
+ // Session + Client
56
+ export { WshSession } from './session.mjs';
57
+ export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
58
+ export { WshClient } from './client.mjs';
59
+
60
+ // Key storage
61
+ export { WshKeyStore } from './keystore.mjs';
62
+
63
+ // File transfer
64
+ export { WshFileTransfer } from './file-transfer.mjs';
65
+
66
+ // Session recording
67
+ export { SessionRecorder, SessionPlayer } from './recording.mjs';
68
+
69
+ // MCP bridge
70
+ export { WshMcpBridge } from './mcp-bridge.mjs';
@@ -0,0 +1,473 @@
1
+ /**
2
+ * WshKeyStore — Ed25519 key management via IndexedDB with OPFS encrypted backup.
3
+ *
4
+ * Keys are stored as non-extractable CryptoKey objects in IndexedDB for
5
+ * day-to-day use. An optional passphrase-encrypted backup can be written
6
+ * to OPFS (requires extractable keys at generation time).
7
+ */
8
+
9
+ import {
10
+ generateKeyPair,
11
+ exportPublicKeySSH,
12
+ exportPublicKeyRaw,
13
+ fingerprint,
14
+ exportPrivateKeyPKCS8,
15
+ importPrivateKeyPKCS8,
16
+ importPublicKeyRaw,
17
+ } from './auth.mjs';
18
+
19
+ const DB_NAME = 'wsh-keystore';
20
+ const DB_VERSION = 1;
21
+ const STORE_NAME = 'keys';
22
+ const BACKUP_FILENAME = 'wsh-keys.backup';
23
+ const BACKUP_DIR = 'wsh-keystore';
24
+
25
+ // PBKDF2 parameters for passphrase-derived key
26
+ const PBKDF2_ITERATIONS = 310_000;
27
+ const SALT_BYTES = 16;
28
+ const IV_BYTES = 12;
29
+
30
+ export class WshKeyStore {
31
+ /** @type {IDBDatabase | null} */
32
+ _db = null;
33
+
34
+ // ── Database lifecycle ──────────────────────────────────────────────
35
+
36
+ /**
37
+ * Open or create the IndexedDB database.
38
+ * @returns {Promise<void>}
39
+ */
40
+ async open() {
41
+ await this._ensureDb();
42
+ }
43
+
44
+ /**
45
+ * Close the database connection.
46
+ */
47
+ close() {
48
+ if (this._db) {
49
+ this._db.close();
50
+ this._db = null;
51
+ }
52
+ }
53
+
54
+ // ── Key management ──────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Generate a new Ed25519 key pair and store it in IndexedDB.
58
+ *
59
+ * @param {string} [name='default'] - Key name / identifier
60
+ * @param {object} [opts]
61
+ * @param {boolean} [opts.extractable=false] - Whether the private key can be exported
62
+ * @returns {Promise<{ name: string, fingerprint: string, publicKeySSH: string }>}
63
+ */
64
+ async generateKey(name = 'default', { extractable = false } = {}) {
65
+ await this._ensureDb();
66
+
67
+ const existing = await this.getKey(name);
68
+ if (existing) {
69
+ throw new Error(`Key "${name}" already exists. Delete it first or choose a different name.`);
70
+ }
71
+
72
+ const { publicKey, privateKey } = await generateKeyPair(extractable);
73
+ const fp = await this._fingerprint(publicKey);
74
+ const publicKeySSH = await exportPublicKeySSH(publicKey);
75
+ const createdAt = Date.now();
76
+
77
+ const entry = {
78
+ name,
79
+ publicKey,
80
+ privateKey,
81
+ createdAt,
82
+ fingerprint: fp,
83
+ };
84
+
85
+ await this._put(entry);
86
+
87
+ return { name, fingerprint: fp, publicKeySSH };
88
+ }
89
+
90
+ /**
91
+ * Get a stored key entry by name.
92
+ *
93
+ * @param {string} name
94
+ * @returns {Promise<{ name: string, publicKey: CryptoKey, privateKey: CryptoKey, createdAt: number, fingerprint: string } | null>}
95
+ */
96
+ async getKey(name) {
97
+ await this._ensureDb();
98
+ return new Promise((resolve, reject) => {
99
+ const tx = this._db.transaction(STORE_NAME, 'readonly');
100
+ const store = tx.objectStore(STORE_NAME);
101
+ const req = store.get(name);
102
+ req.onsuccess = () => resolve(req.result || null);
103
+ req.onerror = () => reject(new Error(`Failed to get key "${name}": ${req.error?.message}`));
104
+ });
105
+ }
106
+
107
+ /**
108
+ * List all stored key names and fingerprints.
109
+ *
110
+ * @returns {Promise<Array<{ name: string, fingerprint: string, createdAt: number }>>}
111
+ */
112
+ async listKeys() {
113
+ await this._ensureDb();
114
+ return new Promise((resolve, reject) => {
115
+ const tx = this._db.transaction(STORE_NAME, 'readonly');
116
+ const store = tx.objectStore(STORE_NAME);
117
+ const req = store.getAll();
118
+ req.onsuccess = () => {
119
+ const keys = (req.result || []).map(({ name, fingerprint: fp, createdAt }) => ({
120
+ name,
121
+ fingerprint: fp,
122
+ createdAt,
123
+ }));
124
+ resolve(keys);
125
+ };
126
+ req.onerror = () => reject(new Error(`Failed to list keys: ${req.error?.message}`));
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Delete a key by name.
132
+ *
133
+ * @param {string} name
134
+ * @returns {Promise<boolean>} true if the key existed and was deleted
135
+ */
136
+ async deleteKey(name) {
137
+ await this._ensureDb();
138
+ const existing = await this.getKey(name);
139
+ if (!existing) return false;
140
+
141
+ return new Promise((resolve, reject) => {
142
+ const tx = this._db.transaction(STORE_NAME, 'readwrite');
143
+ const store = tx.objectStore(STORE_NAME);
144
+ const req = store.delete(name);
145
+ req.onsuccess = () => resolve(true);
146
+ req.onerror = () => reject(new Error(`Failed to delete key "${name}": ${req.error?.message}`));
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Export a public key as an SSH-formatted string.
152
+ *
153
+ * @param {string} name
154
+ * @returns {Promise<string>} ssh-ed25519 AAAA... format
155
+ */
156
+ async exportPublicKey(name) {
157
+ const entry = await this.getKey(name);
158
+ if (!entry) throw new Error(`Key "${name}" not found`);
159
+ return exportPublicKeySSH(entry.publicKey);
160
+ }
161
+
162
+ /**
163
+ * Get the CryptoKey pair for a named key.
164
+ *
165
+ * @param {string} name
166
+ * @returns {Promise<{ publicKey: CryptoKey, privateKey: CryptoKey }>}
167
+ */
168
+ async getKeyPair(name) {
169
+ const entry = await this.getKey(name);
170
+ if (!entry) throw new Error(`Key "${name}" not found`);
171
+ return { publicKey: entry.publicKey, privateKey: entry.privateKey };
172
+ }
173
+
174
+ // ── Encrypted OPFS backup ──────────────────────────────────────────
175
+
176
+ /**
177
+ * Export all extractable keys, encrypt with a passphrase, and store in OPFS.
178
+ *
179
+ * Uses PBKDF2 to derive an AES-256-GCM key from the passphrase.
180
+ * Only keys whose private key was created with extractable=true can be backed up;
181
+ * non-extractable keys are silently skipped.
182
+ *
183
+ * @param {string} passphrase
184
+ * @returns {Promise<{ backedUp: number, skipped: number }>}
185
+ */
186
+ async backup(passphrase) {
187
+ if (!passphrase || typeof passphrase !== 'string') {
188
+ throw new Error('Passphrase is required for backup');
189
+ }
190
+
191
+ await this._ensureDb();
192
+ const allEntries = await this._getAll();
193
+ const exportable = [];
194
+ let skipped = 0;
195
+
196
+ for (const entry of allEntries) {
197
+ try {
198
+ const privBytes = await exportPrivateKeyPKCS8(entry.privateKey);
199
+ const pubBytes = await exportPublicKeyRaw(entry.publicKey);
200
+ exportable.push({
201
+ name: entry.name,
202
+ createdAt: entry.createdAt,
203
+ fingerprint: entry.fingerprint,
204
+ privateKey: Array.from(privBytes),
205
+ publicKey: Array.from(pubBytes),
206
+ });
207
+ } catch {
208
+ // Non-extractable key — skip silently
209
+ skipped++;
210
+ }
211
+ }
212
+
213
+ if (exportable.length === 0 && allEntries.length > 0) {
214
+ throw new Error(
215
+ 'No extractable keys found. Keys must be generated with extractable=true to support backup.'
216
+ );
217
+ }
218
+
219
+ const plaintext = new TextEncoder().encode(JSON.stringify(exportable));
220
+ const ciphertext = await this._encrypt(plaintext, passphrase);
221
+ await this._writeOPFS(ciphertext);
222
+
223
+ return { backedUp: exportable.length, skipped };
224
+ }
225
+
226
+ /**
227
+ * Restore keys from an OPFS encrypted backup.
228
+ *
229
+ * Decrypts the backup with the provided passphrase and imports keys into IndexedDB.
230
+ * Existing keys with the same name are NOT overwritten; they are skipped.
231
+ *
232
+ * @param {string} passphrase
233
+ * @returns {Promise<{ restored: number, skipped: number }>}
234
+ */
235
+ async restore(passphrase) {
236
+ if (!passphrase || typeof passphrase !== 'string') {
237
+ throw new Error('Passphrase is required for restore');
238
+ }
239
+
240
+ await this._ensureDb();
241
+ const ciphertext = await this._readOPFS();
242
+ if (!ciphertext) {
243
+ throw new Error('No backup found in OPFS');
244
+ }
245
+
246
+ let plaintext;
247
+ try {
248
+ plaintext = await this._decrypt(ciphertext, passphrase);
249
+ } catch {
250
+ throw new Error('Decryption failed — wrong passphrase or corrupted backup');
251
+ }
252
+
253
+ const entries = JSON.parse(new TextDecoder().decode(plaintext));
254
+ let restored = 0;
255
+ let skipped = 0;
256
+
257
+ for (const entry of entries) {
258
+ const existing = await this.getKey(entry.name);
259
+ if (existing) {
260
+ skipped++;
261
+ continue;
262
+ }
263
+
264
+ const privateKey = await importPrivateKeyPKCS8(
265
+ new Uint8Array(entry.privateKey),
266
+ false // imported keys are non-extractable by default for security
267
+ );
268
+ const publicKey = await importPublicKeyRaw(new Uint8Array(entry.publicKey));
269
+
270
+ await this._put({
271
+ name: entry.name,
272
+ publicKey,
273
+ privateKey,
274
+ createdAt: entry.createdAt,
275
+ fingerprint: entry.fingerprint,
276
+ });
277
+ restored++;
278
+ }
279
+
280
+ return { restored, skipped };
281
+ }
282
+
283
+ // ── Internal: IndexedDB helpers ────────────────────────────────────
284
+
285
+ /**
286
+ * Ensure the database is open, opening it if necessary.
287
+ * @returns {Promise<void>}
288
+ */
289
+ async _ensureDb() {
290
+ if (this._db) return;
291
+
292
+ return new Promise((resolve, reject) => {
293
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
294
+
295
+ request.onupgradeneeded = (event) => {
296
+ const db = event.target.result;
297
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
298
+ db.createObjectStore(STORE_NAME, { keyPath: 'name' });
299
+ }
300
+ };
301
+
302
+ request.onsuccess = (event) => {
303
+ this._db = event.target.result;
304
+
305
+ // Handle unexpected close (e.g. browser deletes DB)
306
+ this._db.onclose = () => {
307
+ this._db = null;
308
+ };
309
+
310
+ resolve();
311
+ };
312
+
313
+ request.onerror = () => {
314
+ reject(new Error(`Failed to open IndexedDB "${DB_NAME}": ${request.error?.message}`));
315
+ };
316
+
317
+ request.onblocked = () => {
318
+ reject(new Error(`IndexedDB "${DB_NAME}" is blocked by another connection`));
319
+ };
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Put a key entry into the object store.
325
+ * @param {{ name: string, publicKey: CryptoKey, privateKey: CryptoKey, createdAt: number, fingerprint: string }} entry
326
+ * @returns {Promise<void>}
327
+ */
328
+ async _put(entry) {
329
+ return new Promise((resolve, reject) => {
330
+ const tx = this._db.transaction(STORE_NAME, 'readwrite');
331
+ const store = tx.objectStore(STORE_NAME);
332
+ const req = store.put(entry);
333
+ req.onsuccess = () => resolve();
334
+ req.onerror = () => reject(new Error(`Failed to store key "${entry.name}": ${req.error?.message}`));
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Get all entries from the object store.
340
+ * @returns {Promise<Array>}
341
+ */
342
+ async _getAll() {
343
+ return new Promise((resolve, reject) => {
344
+ const tx = this._db.transaction(STORE_NAME, 'readonly');
345
+ const store = tx.objectStore(STORE_NAME);
346
+ const req = store.getAll();
347
+ req.onsuccess = () => resolve(req.result || []);
348
+ req.onerror = () => reject(new Error(`Failed to read keys: ${req.error?.message}`));
349
+ });
350
+ }
351
+
352
+ /**
353
+ * Compute a SHA-256 fingerprint of a CryptoKey's raw bytes.
354
+ * @param {CryptoKey} publicKey
355
+ * @returns {Promise<string>} hex-encoded fingerprint
356
+ */
357
+ async _fingerprint(publicKey) {
358
+ const raw = await exportPublicKeyRaw(publicKey);
359
+ return fingerprint(raw);
360
+ }
361
+
362
+ // ── Internal: AES-GCM encryption with PBKDF2 ──────────────────────
363
+
364
+ /**
365
+ * Derive an AES-256-GCM key from a passphrase using PBKDF2.
366
+ * @param {string} passphrase
367
+ * @param {Uint8Array} salt
368
+ * @returns {Promise<CryptoKey>}
369
+ */
370
+ async _deriveKey(passphrase, salt) {
371
+ const keyMaterial = await crypto.subtle.importKey(
372
+ 'raw',
373
+ new TextEncoder().encode(passphrase),
374
+ 'PBKDF2',
375
+ false,
376
+ ['deriveKey']
377
+ );
378
+ return crypto.subtle.deriveKey(
379
+ { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
380
+ keyMaterial,
381
+ { name: 'AES-GCM', length: 256 },
382
+ false,
383
+ ['encrypt', 'decrypt']
384
+ );
385
+ }
386
+
387
+ /**
388
+ * Encrypt plaintext with AES-256-GCM using a passphrase-derived key.
389
+ * Output: [16-byte salt][12-byte IV][ciphertext+tag]
390
+ *
391
+ * @param {Uint8Array} plaintext
392
+ * @param {string} passphrase
393
+ * @returns {Promise<Uint8Array>}
394
+ */
395
+ async _encrypt(plaintext, passphrase) {
396
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
397
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
398
+ const key = await this._deriveKey(passphrase, salt);
399
+
400
+ const ciphertext = await crypto.subtle.encrypt(
401
+ { name: 'AES-GCM', iv },
402
+ key,
403
+ plaintext
404
+ );
405
+
406
+ const result = new Uint8Array(SALT_BYTES + IV_BYTES + ciphertext.byteLength);
407
+ result.set(salt, 0);
408
+ result.set(iv, SALT_BYTES);
409
+ result.set(new Uint8Array(ciphertext), SALT_BYTES + IV_BYTES);
410
+ return result;
411
+ }
412
+
413
+ /**
414
+ * Decrypt ciphertext produced by _encrypt.
415
+ *
416
+ * @param {Uint8Array} data - [salt][iv][ciphertext+tag]
417
+ * @param {string} passphrase
418
+ * @returns {Promise<Uint8Array>}
419
+ */
420
+ async _decrypt(data, passphrase) {
421
+ if (data.length < SALT_BYTES + IV_BYTES + 1) {
422
+ throw new Error('Backup data too short to be valid');
423
+ }
424
+ const salt = data.slice(0, SALT_BYTES);
425
+ const iv = data.slice(SALT_BYTES, SALT_BYTES + IV_BYTES);
426
+ const ciphertext = data.slice(SALT_BYTES + IV_BYTES);
427
+
428
+ const key = await this._deriveKey(passphrase, salt);
429
+ const plaintext = await crypto.subtle.decrypt(
430
+ { name: 'AES-GCM', iv },
431
+ key,
432
+ ciphertext
433
+ );
434
+ return new Uint8Array(plaintext);
435
+ }
436
+
437
+ // ── Internal: OPFS storage ─────────────────────────────────────────
438
+
439
+ /**
440
+ * Write encrypted data to OPFS.
441
+ * @param {Uint8Array} data
442
+ * @returns {Promise<void>}
443
+ */
444
+ async _writeOPFS(data) {
445
+ const root = await navigator.storage.getDirectory();
446
+ const dir = await root.getDirectoryHandle(BACKUP_DIR, { create: true });
447
+ const fileHandle = await dir.getFileHandle(BACKUP_FILENAME, { create: true });
448
+ const writable = await fileHandle.createWritable();
449
+ try {
450
+ await writable.write(data);
451
+ } finally {
452
+ await writable.close();
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Read encrypted data from OPFS.
458
+ * @returns {Promise<Uint8Array | null>}
459
+ */
460
+ async _readOPFS() {
461
+ try {
462
+ const root = await navigator.storage.getDirectory();
463
+ const dir = await root.getDirectoryHandle(BACKUP_DIR);
464
+ const fileHandle = await dir.getFileHandle(BACKUP_FILENAME);
465
+ const file = await fileHandle.getFile();
466
+ const buffer = await file.arrayBuffer();
467
+ return new Uint8Array(buffer);
468
+ } catch (err) {
469
+ if (err.name === 'NotFoundError') return null;
470
+ throw err;
471
+ }
472
+ }
473
+ }