wsh-upon-star 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Henry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # wsh-upon-star
2
+
3
+ Browser-native remote command execution over WebTransport/WebSocket with Ed25519 authentication.
4
+
5
+ wsh-upon-star is a pure-JS client library that connects browsers to remote shells. It implements its own binary protocol (CBOR over length-prefixed frames) with Ed25519 challenge-response auth, channel multiplexing, session management, and MCP tool bridging.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install wsh-upon-star
11
+ ```
12
+
13
+ Or via CDN:
14
+
15
+ ```html
16
+ <script type="module">
17
+ import { WshClient, generateKeyPair } from 'https://esm.sh/wsh-upon-star';
18
+ </script>
19
+ ```
20
+
21
+ ## Features
22
+
23
+ - **Ed25519 authentication** -- challenge-response via Web Crypto API, SSH key format support
24
+ - **Dual transport** -- WebTransport (native streams) and WebSocket (multiplexed virtual streams) with identical API
25
+ - **CBOR encoding** -- compact binary wire format with length-prefixed framing
26
+ - **Session management** -- open, attach, resume, detach, rename PTY/exec sessions
27
+ - **Reverse mode** -- register as a peer and accept incoming connections through a relay
28
+ - **File transfer** -- scp-like upload/download over dedicated streams in 64KB chunks
29
+ - **MCP bridge** -- discover and invoke remote MCP tools through the control channel
30
+ - **Session recording** -- asciicast v2 compatible recording and playback with seek/pause/resume
31
+ - **Key management** -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
32
+ - **80+ message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, and more
33
+
34
+ ## Quick Start
35
+
36
+ ```js
37
+ import { WshClient, generateKeyPair } from 'wsh-upon-star';
38
+
39
+ // Generate an Ed25519 key pair
40
+ const keyPair = await generateKeyPair(true);
41
+
42
+ // Connect to a wsh server
43
+ const client = new WshClient();
44
+ const sessionId = await client.connect('wss://shell.example.com', {
45
+ username: 'alice',
46
+ keyPair,
47
+ transport: 'ws',
48
+ });
49
+
50
+ // Open a PTY session
51
+ const session = await client.openSession({
52
+ type: 'pty',
53
+ command: '/bin/bash',
54
+ cols: 120,
55
+ rows: 40,
56
+ });
57
+
58
+ // Handle output
59
+ session.onData = (data) => {
60
+ const text = new TextDecoder().decode(data);
61
+ process.stdout.write(text);
62
+ };
63
+
64
+ // Write input
65
+ await session.write('echo hello world\n');
66
+
67
+ // Resize the terminal
68
+ await session.resize(160, 50);
69
+
70
+ // Close when done
71
+ await session.close();
72
+ await client.disconnect();
73
+ ```
74
+
75
+ ## One-Shot Command Execution
76
+
77
+ ```js
78
+ import { WshClient, generateKeyPair } from 'wsh-upon-star';
79
+
80
+ const keyPair = await generateKeyPair(true);
81
+ const { stdout, exitCode } = await WshClient.exec(
82
+ 'wss://shell.example.com',
83
+ 'ls -la /tmp',
84
+ { username: 'alice', keyPair }
85
+ );
86
+
87
+ console.log(new TextDecoder().decode(stdout));
88
+ console.log('Exit code:', exitCode);
89
+ ```
90
+
91
+ ## API Overview
92
+
93
+ ### Core Classes
94
+
95
+ | Class | Description |
96
+ |-------|-------------|
97
+ | `WshClient` | Full lifecycle client: connect, auth, sessions, reverse mode, MCP |
98
+ | `WshSession` | Single PTY or exec channel with read/write/resize/signal |
99
+ | `WshTransport` | Abstract transport base class |
100
+ | `WebTransportTransport` | WebTransport implementation (native streams) |
101
+ | `WebSocketTransport` | WebSocket implementation (multiplexed virtual streams) |
102
+
103
+ ### Utilities
104
+
105
+ | Class / Function | Description |
106
+ |------------------|-------------|
107
+ | `WshKeyStore` | Ed25519 key management via IndexedDB + OPFS encrypted backup |
108
+ | `WshFileTransfer` | File upload/download over dedicated streams |
109
+ | `WshMcpBridge` | Remote MCP tool discovery and invocation |
110
+ | `SessionRecorder` | Record PTY I/O with timestamps (asciicast v2) |
111
+ | `SessionPlayer` | Replay recordings with original timing |
112
+ | `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
113
+ | `signChallenge()` | Build transcript + sign for auth handshake |
114
+ | `fingerprint()` | SHA-256 hex fingerprint of a public key |
115
+
116
+ ### Protocol
117
+
118
+ | Export | Description |
119
+ |--------|-------------|
120
+ | `MSG` | 80+ message type constants (hex opcodes) |
121
+ | `CHANNEL_KIND` | Channel types: `pty`, `exec`, `meta`, `file`, `tcp`, `udp`, `job` |
122
+ | `AUTH_METHOD` | Auth methods: `pubkey`, `password` |
123
+ | `cborEncode` / `cborDecode` | CBOR codec (maps, arrays, strings, ints, bytes, bools, null, floats) |
124
+ | `frameEncode` / `FrameDecoder` | 4-byte big-endian length-prefixed framing |
125
+
126
+ ## Protocol Specification
127
+
128
+ The `spec/` directory contains the protocol definition:
129
+
130
+ - `wsh-v1.yaml` -- machine-readable protocol schema
131
+ - `wsh-v1.md` -- human-readable protocol specification
132
+ - `codegen.mjs` -- generates `messages.gen.mjs` from the YAML spec
133
+
134
+ ## Browser Compatibility
135
+
136
+ Requires a browser (or Node.js 20+) with:
137
+
138
+ - Web Crypto API with Ed25519 support
139
+ - WebSocket (all browsers)
140
+ - WebTransport (Chrome 97+, Edge 97+, Firefox 114+)
141
+ - TextEncoder/TextDecoder
142
+ - ReadableStream/WritableStream
143
+
144
+ ## License
145
+
146
+ MIT
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "wsh-upon-star",
3
+ "version": "0.1.0",
4
+ "description": "Web Shell — browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs"
9
+ },
10
+ "types": "./src/index.d.ts",
11
+ "files": [
12
+ "src",
13
+ "LICENSE",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --test test/*.test.mjs"
18
+ },
19
+ "keywords": [
20
+ "wsh",
21
+ "web-shell",
22
+ "terminal",
23
+ "webtransport",
24
+ "websocket",
25
+ "ed25519",
26
+ "browser",
27
+ "cbor",
28
+ "remote-shell",
29
+ "ssh",
30
+ "pty"
31
+ ],
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/johnhenry/wsh-upon-star"
36
+ },
37
+ "author": "John Henry",
38
+ "engines": {
39
+ "node": ">=20.0.0"
40
+ }
41
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Ed25519 key generation, signing, and verification via Web Crypto API.
3
+ * Also builds authentication transcripts for the wsh challenge-response flow.
4
+ */
5
+
6
+ import { PROTOCOL_VERSION } from './messages.mjs';
7
+
8
+ // ── Key Generation ────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Generate a new Ed25519 key pair.
12
+ * @param {boolean} [extractable=false] - Whether private key can be exported
13
+ * @returns {Promise<CryptoKeyPair>} { publicKey, privateKey }
14
+ */
15
+ export async function generateKeyPair(extractable = false) {
16
+ return crypto.subtle.generateKey('Ed25519', extractable, ['sign', 'verify']);
17
+ }
18
+
19
+ // ── Export / Import ───────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Export public key as raw 32-byte Ed25519 point.
23
+ * @param {CryptoKey} publicKey
24
+ * @returns {Promise<Uint8Array>}
25
+ */
26
+ export async function exportPublicKeyRaw(publicKey) {
27
+ const buf = await crypto.subtle.exportKey('raw', publicKey);
28
+ return new Uint8Array(buf);
29
+ }
30
+
31
+ /**
32
+ * Export public key in SSH wire format: ssh-ed25519 AAAA...
33
+ * @param {CryptoKey} publicKey
34
+ * @returns {Promise<string>}
35
+ */
36
+ export async function exportPublicKeySSH(publicKey) {
37
+ const raw = await exportPublicKeyRaw(publicKey);
38
+ const keyType = 'ssh-ed25519';
39
+ const typeBytes = new TextEncoder().encode(keyType);
40
+
41
+ // SSH wire format: [4-byte len][key type string][4-byte len][key data]
42
+ const buf = new Uint8Array(4 + typeBytes.length + 4 + raw.length);
43
+ const view = new DataView(buf.buffer);
44
+ let offset = 0;
45
+
46
+ view.setUint32(offset, typeBytes.length);
47
+ offset += 4;
48
+ buf.set(typeBytes, offset);
49
+ offset += typeBytes.length;
50
+
51
+ view.setUint32(offset, raw.length);
52
+ offset += 4;
53
+ buf.set(raw, offset);
54
+
55
+ return `${keyType} ${base64Encode(buf)}`;
56
+ }
57
+
58
+ /**
59
+ * Import a raw 32-byte Ed25519 public key.
60
+ * @param {Uint8Array} raw
61
+ * @returns {Promise<CryptoKey>}
62
+ */
63
+ export async function importPublicKeyRaw(raw) {
64
+ return crypto.subtle.importKey('raw', raw, 'Ed25519', true, ['verify']);
65
+ }
66
+
67
+ /**
68
+ * Export private key as PKCS8 bytes.
69
+ * @param {CryptoKey} privateKey - Must have been created with extractable=true
70
+ * @returns {Promise<Uint8Array>}
71
+ */
72
+ export async function exportPrivateKeyPKCS8(privateKey) {
73
+ const buf = await crypto.subtle.exportKey('pkcs8', privateKey);
74
+ return new Uint8Array(buf);
75
+ }
76
+
77
+ /**
78
+ * Import a PKCS8-encoded Ed25519 private key.
79
+ * @param {Uint8Array} pkcs8
80
+ * @param {boolean} [extractable=false]
81
+ * @returns {Promise<CryptoKey>}
82
+ */
83
+ export async function importPrivateKeyPKCS8(pkcs8, extractable = false) {
84
+ return crypto.subtle.importKey('pkcs8', pkcs8, 'Ed25519', extractable, ['sign']);
85
+ }
86
+
87
+ // ── Signing / Verification ────────────────────────────────────────────
88
+
89
+ /**
90
+ * Sign data with an Ed25519 private key.
91
+ * @param {CryptoKey} privateKey
92
+ * @param {Uint8Array} data
93
+ * @returns {Promise<Uint8Array>} 64-byte signature
94
+ */
95
+ export async function sign(privateKey, data) {
96
+ const sig = await crypto.subtle.sign('Ed25519', privateKey, data);
97
+ return new Uint8Array(sig);
98
+ }
99
+
100
+ /**
101
+ * Verify an Ed25519 signature.
102
+ * @param {CryptoKey} publicKey
103
+ * @param {Uint8Array} signature
104
+ * @param {Uint8Array} data
105
+ * @returns {Promise<boolean>}
106
+ */
107
+ export async function verify(publicKey, signature, data) {
108
+ return crypto.subtle.verify('Ed25519', publicKey, signature, data);
109
+ }
110
+
111
+ // ── Authentication Transcript ─────────────────────────────────────────
112
+
113
+ /**
114
+ * Build the authentication transcript hash for challenge-response signing.
115
+ *
116
+ * transcript = SHA-256("wsh-v1\0" || session_id || nonce || channel_binding)
117
+ *
118
+ * @param {string} sessionId
119
+ * @param {Uint8Array} nonce - 32-byte server nonce
120
+ * @param {Uint8Array} [channelBinding] - Optional TLS channel binding
121
+ * @returns {Promise<Uint8Array>} 32-byte SHA-256 hash
122
+ */
123
+ export async function buildTranscript(sessionId, nonce, channelBinding = new Uint8Array(0)) {
124
+ const enc = new TextEncoder();
125
+ const versionBytes = enc.encode(PROTOCOL_VERSION + '\0');
126
+ const sessionBytes = enc.encode(sessionId);
127
+
128
+ const total = versionBytes.length + sessionBytes.length + nonce.length + channelBinding.length;
129
+ const data = new Uint8Array(total);
130
+ let offset = 0;
131
+
132
+ data.set(versionBytes, offset); offset += versionBytes.length;
133
+ data.set(sessionBytes, offset); offset += sessionBytes.length;
134
+ data.set(nonce, offset); offset += nonce.length;
135
+ data.set(channelBinding, offset);
136
+
137
+ const hash = await crypto.subtle.digest('SHA-256', data);
138
+ return new Uint8Array(hash);
139
+ }
140
+
141
+ /**
142
+ * Perform the full client-side auth signing:
143
+ * 1. Build transcript hash
144
+ * 2. Sign with private key
145
+ * 3. Export public key for sending to server
146
+ *
147
+ * @param {CryptoKey} privateKey
148
+ * @param {CryptoKey} publicKey
149
+ * @param {string} sessionId
150
+ * @param {Uint8Array} nonce
151
+ * @param {Uint8Array} [channelBinding]
152
+ * @returns {Promise<{ signature: Uint8Array, publicKeyRaw: Uint8Array }>}
153
+ */
154
+ export async function signChallenge(privateKey, publicKey, sessionId, nonce, channelBinding) {
155
+ const transcript = await buildTranscript(sessionId, nonce, channelBinding);
156
+ const [signature, publicKeyRaw] = await Promise.all([
157
+ sign(privateKey, transcript),
158
+ exportPublicKeyRaw(publicKey),
159
+ ]);
160
+ return { signature, publicKeyRaw };
161
+ }
162
+
163
+ /**
164
+ * Server-side: verify a client's challenge response.
165
+ *
166
+ * @param {CryptoKey} publicKey
167
+ * @param {Uint8Array} signature
168
+ * @param {string} sessionId
169
+ * @param {Uint8Array} nonce
170
+ * @param {Uint8Array} [channelBinding]
171
+ * @returns {Promise<boolean>}
172
+ */
173
+ export async function verifyChallenge(publicKey, signature, sessionId, nonce, channelBinding) {
174
+ const transcript = await buildTranscript(sessionId, nonce, channelBinding);
175
+ return verify(publicKey, signature, transcript);
176
+ }
177
+
178
+ // ── Fingerprint ───────────────────────────────────────────────────────
179
+
180
+ /**
181
+ * Compute the SHA-256 fingerprint of a raw public key.
182
+ * @param {Uint8Array} publicKeyRaw - 32-byte raw Ed25519 public key
183
+ * @returns {Promise<string>} hex-encoded fingerprint
184
+ */
185
+ export async function fingerprint(publicKeyRaw) {
186
+ const hash = await crypto.subtle.digest('SHA-256', publicKeyRaw);
187
+ return hexEncode(new Uint8Array(hash));
188
+ }
189
+
190
+ /**
191
+ * Compute the base64url-encoded SHA-256 pod ID of a raw public key.
192
+ * This is the BrowserMesh identity format (43 chars).
193
+ * @param {Uint8Array} publicKeyRaw - 32-byte raw Ed25519 public key
194
+ * @returns {Promise<string>} base64url-encoded pod ID
195
+ */
196
+ export async function podId(publicKeyRaw) {
197
+ const hash = await crypto.subtle.digest('SHA-256', publicKeyRaw);
198
+ return base64urlEncode(new Uint8Array(hash));
199
+ }
200
+
201
+ /**
202
+ * Convert a hex fingerprint to a base64url pod ID.
203
+ * @param {string} hexFingerprint - 64-char hex fingerprint
204
+ * @returns {string} base64url pod ID
205
+ */
206
+ export function fingerprintToPodId(hexFingerprint) {
207
+ const bytes = new Uint8Array(hexFingerprint.length / 2);
208
+ for (let i = 0; i < bytes.length; i++) {
209
+ bytes[i] = parseInt(hexFingerprint.slice(i * 2, i * 2 + 2), 16);
210
+ }
211
+ return base64urlEncode(bytes);
212
+ }
213
+
214
+ /**
215
+ * Convert a base64url pod ID to a hex fingerprint.
216
+ * @param {string} podIdStr - base64url pod ID
217
+ * @returns {string} hex fingerprint
218
+ */
219
+ export function podIdToFingerprint(podIdStr) {
220
+ const bytes = base64urlDecode(podIdStr);
221
+ return hexEncode(bytes);
222
+ }
223
+
224
+ /**
225
+ * Get the shortest unique prefix of a fingerprint within a set.
226
+ * @param {string} fp - Full hex fingerprint
227
+ * @param {string[]} allFingerprints - All fingerprints in the context
228
+ * @param {number} [minLen=4] - Minimum prefix length
229
+ * @returns {string}
230
+ */
231
+ export function shortFingerprint(fp, allFingerprints = [], minLen = 4) {
232
+ const others = allFingerprints.filter(f => f !== fp);
233
+ for (let len = minLen; len <= fp.length; len++) {
234
+ const prefix = fp.slice(0, len);
235
+ if (!others.some(f => f.startsWith(prefix))) return prefix;
236
+ }
237
+ return fp;
238
+ }
239
+
240
+ // ── Nonce ─────────────────────────────────────────────────────────────
241
+
242
+ /**
243
+ * Generate a random 32-byte nonce.
244
+ * @returns {Uint8Array}
245
+ */
246
+ export function generateNonce() {
247
+ return crypto.getRandomValues(new Uint8Array(32));
248
+ }
249
+
250
+ // ── Helpers ───────────────────────────────────────────────────────────
251
+
252
+ function hexEncode(bytes) {
253
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
254
+ }
255
+
256
+ function base64urlEncode(bytes) {
257
+ let binary = '';
258
+ for (const b of bytes) binary += String.fromCharCode(b);
259
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
260
+ }
261
+
262
+ function base64urlDecode(str) {
263
+ let b64 = str.replace(/-/g, '+').replace(/_/g, '/');
264
+ while (b64.length % 4) b64 += '=';
265
+ const binary = atob(b64);
266
+ const bytes = new Uint8Array(binary.length);
267
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
268
+ return bytes;
269
+ }
270
+
271
+ function base64Encode(bytes) {
272
+ let binary = '';
273
+ for (const b of bytes) binary += String.fromCharCode(b);
274
+ return btoa(binary);
275
+ }
276
+
277
+ export function base64Decode(str) {
278
+ const binary = atob(str);
279
+ const bytes = new Uint8Array(binary.length);
280
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
281
+ return bytes;
282
+ }
283
+
284
+ /**
285
+ * Parse an SSH public key string ("ssh-ed25519 AAAA... comment").
286
+ * @param {string} line
287
+ * @returns {{ type: string, data: Uint8Array, comment: string } | null}
288
+ */
289
+ export function parseSSHPublicKey(line) {
290
+ const parts = line.trim().split(/\s+/);
291
+ if (parts.length < 2) return null;
292
+ const [type, b64, ...rest] = parts;
293
+ if (type !== 'ssh-ed25519') return null;
294
+ try {
295
+ const data = base64Decode(b64);
296
+ return { type, data, comment: rest.join(' ') };
297
+ } catch {
298
+ return null;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Extract the raw 32-byte Ed25519 public key from SSH wire format.
304
+ * @param {Uint8Array} wireData - SSH wire-encoded public key
305
+ * @returns {Uint8Array} 32-byte raw key
306
+ */
307
+ export function extractRawFromSSHWire(wireData) {
308
+ const view = new DataView(wireData.buffer, wireData.byteOffset, wireData.byteLength);
309
+ // Skip key type string
310
+ const typeLen = view.getUint32(0);
311
+ const keyOffset = 4 + typeLen + 4;
312
+ const keyLen = view.getUint32(4 + typeLen);
313
+ return wireData.slice(keyOffset, keyOffset + keyLen);
314
+ }