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 +21 -0
- package/README.md +146 -0
- package/package.json +41 -0
- package/src/auth.mjs +314 -0
- package/src/cbor.mjs +389 -0
- package/src/client.mjs +1842 -0
- package/src/file-transfer.mjs +413 -0
- package/src/index.d.ts +1454 -0
- package/src/index.mjs +70 -0
- package/src/keystore.mjs +473 -0
- package/src/mcp-bridge.mjs +228 -0
- package/src/messages.gen.mjs +928 -0
- package/src/messages.mjs +3 -0
- package/src/recording.mjs +402 -0
- package/src/session.mjs +557 -0
- package/src/transport-ws.mjs +462 -0
- package/src/transport.mjs +343 -0
- package/src/virtual-session.mjs +160 -0
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
|
+
}
|