@johnhenry/wsh 0.7.0 → 0.13.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/package.json +4 -1
- package/src/auth.mjs +106 -0
- package/src/client.mjs +491 -174
- package/src/file-transfer.mjs +39 -166
- package/src/index.d.ts +110 -0
- package/src/index.mjs +13 -0
- package/src/messages.gen.mjs +66 -10
- package/src/messages.mjs +1 -1
- package/src/mlkem.mjs +144 -0
- package/src/qmux-connection.mjs +698 -0
- package/src/qmux.mjs +620 -0
- package/src/session.mjs +62 -0
- package/src/transport-ws.mjs +145 -352
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/wsh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
"LICENSE",
|
|
14
14
|
"README.md"
|
|
15
15
|
],
|
|
16
|
+
"optionalDependencies": {
|
|
17
|
+
"@noble/post-quantum": "^0.7.0"
|
|
18
|
+
},
|
|
16
19
|
"scripts": {
|
|
17
20
|
"test": "node --test test/*.test.mjs",
|
|
18
21
|
"examples": "for f in examples/0*.mjs; do echo \"== $f\" && node \"$f\" || exit 1; done",
|
package/src/auth.mjs
CHANGED
|
@@ -214,6 +214,112 @@ export async function verifyChallenge(publicKey, signature, sessionId, nonce, op
|
|
|
214
214
|
return verify(publicKey, signature, transcript);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// ── Signed peer records (reverse-mode registration) ────────────────────
|
|
218
|
+
//
|
|
219
|
+
// libp2p RFC 0002/0003 signed-envelope pattern: a reverse peer signs its
|
|
220
|
+
// own registration fields with its identity key, so `ReversePeers`
|
|
221
|
+
// entries are verifiable by an operator independent of trusting the
|
|
222
|
+
// relay server -- a relay could otherwise misreport (or a malicious one
|
|
223
|
+
// forge) a peer's capabilities/type/backend, or replay a stale record
|
|
224
|
+
// after a legitimate update. `PEER_RECORD_DOMAIN` intentionally differs
|
|
225
|
+
// from the auth-challenge transcript's domain (`PROTOCOL_VERSION`
|
|
226
|
+
// above) even though both are typically signed by the same Ed25519
|
|
227
|
+
// identity key -- a signature produced for one context must never
|
|
228
|
+
// verify in the other.
|
|
229
|
+
|
|
230
|
+
const PEER_RECORD_DOMAIN = 'wsh-peer-record-v1\0';
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Build the signed transcript for a peer record.
|
|
234
|
+
*
|
|
235
|
+
* transcript = SHA-256(
|
|
236
|
+
* "wsh-peer-record-v1\0" || lp(username) || lp(peerType) ||
|
|
237
|
+
* lp(shellBackend) || lp(capabilities.join(',')) || flags(1 byte) ||
|
|
238
|
+
* seq(8 BE bytes)
|
|
239
|
+
* )
|
|
240
|
+
*
|
|
241
|
+
* `capabilities` is joined and length-prefixed as a single field rather
|
|
242
|
+
* than iterated element-by-element -- simpler, and sufficient since the
|
|
243
|
+
* only thing that matters is that two different capability sets can't
|
|
244
|
+
* hash identically (true as long as no capability name itself contains
|
|
245
|
+
* the join separator in a way that creates ambiguity across the whole
|
|
246
|
+
* *list*, which none of wsh's fixed capability strings do).
|
|
247
|
+
*
|
|
248
|
+
* @param {object} record
|
|
249
|
+
* @param {string} record.username
|
|
250
|
+
* @param {string} [record.peerType]
|
|
251
|
+
* @param {string} [record.shellBackend]
|
|
252
|
+
* @param {string[]} [record.capabilities]
|
|
253
|
+
* @param {boolean} [record.supportsAttach]
|
|
254
|
+
* @param {boolean} [record.supportsReplay]
|
|
255
|
+
* @param {boolean} [record.supportsEcho]
|
|
256
|
+
* @param {boolean} [record.supportsTermSync]
|
|
257
|
+
* @param {number|bigint} record.seq - the signing peer's own monotonic
|
|
258
|
+
* counter (in practice, current-time-millis); a verifier must reject a
|
|
259
|
+
* record whose seq doesn't exceed the last one it accepted for that
|
|
260
|
+
* fingerprint.
|
|
261
|
+
* @returns {Promise<Uint8Array>} 32-byte SHA-256 hash
|
|
262
|
+
*/
|
|
263
|
+
export async function buildPeerRecordTranscript(record) {
|
|
264
|
+
const enc = new TextEncoder();
|
|
265
|
+
const domainBytes = enc.encode(PEER_RECORD_DOMAIN);
|
|
266
|
+
const usernameField = lengthPrefixed(enc.encode(record.username ?? ''));
|
|
267
|
+
const peerTypeField = lengthPrefixed(enc.encode(record.peerType ?? 'host'));
|
|
268
|
+
const shellBackendField = lengthPrefixed(enc.encode(record.shellBackend ?? 'pty'));
|
|
269
|
+
const capsField = lengthPrefixed(enc.encode((record.capabilities ?? []).join(',')));
|
|
270
|
+
const flags = new Uint8Array([
|
|
271
|
+
(record.supportsAttach ? 1 : 0) |
|
|
272
|
+
(record.supportsReplay ? 2 : 0) |
|
|
273
|
+
(record.supportsEcho ? 4 : 0) |
|
|
274
|
+
(record.supportsTermSync ? 8 : 0),
|
|
275
|
+
]);
|
|
276
|
+
const seqBytes = new Uint8Array(8);
|
|
277
|
+
new DataView(seqBytes.buffer).setBigUint64(0, BigInt(record.seq));
|
|
278
|
+
|
|
279
|
+
const total = domainBytes.length + usernameField.length + peerTypeField.length
|
|
280
|
+
+ shellBackendField.length + capsField.length + flags.length + seqBytes.length;
|
|
281
|
+
const data = new Uint8Array(total);
|
|
282
|
+
let offset = 0;
|
|
283
|
+
data.set(domainBytes, offset); offset += domainBytes.length;
|
|
284
|
+
data.set(usernameField, offset); offset += usernameField.length;
|
|
285
|
+
data.set(peerTypeField, offset); offset += peerTypeField.length;
|
|
286
|
+
data.set(shellBackendField, offset); offset += shellBackendField.length;
|
|
287
|
+
data.set(capsField, offset); offset += capsField.length;
|
|
288
|
+
data.set(flags, offset); offset += flags.length;
|
|
289
|
+
data.set(seqBytes, offset);
|
|
290
|
+
|
|
291
|
+
const hash = await crypto.subtle.digest('SHA-256', data);
|
|
292
|
+
return new Uint8Array(hash);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Client-side: sign a peer record with the identity key pair.
|
|
297
|
+
* @param {CryptoKey} privateKey
|
|
298
|
+
* @param {CryptoKey} publicKey
|
|
299
|
+
* @param {object} record - see `buildPeerRecordTranscript`
|
|
300
|
+
* @returns {Promise<{ signature: Uint8Array, publicKeyRaw: Uint8Array }>}
|
|
301
|
+
*/
|
|
302
|
+
export async function signPeerRecord(privateKey, publicKey, record) {
|
|
303
|
+
const transcript = await buildPeerRecordTranscript(record);
|
|
304
|
+
const [signature, publicKeyRaw] = await Promise.all([
|
|
305
|
+
sign(privateKey, transcript),
|
|
306
|
+
exportPublicKeyRaw(publicKey),
|
|
307
|
+
]);
|
|
308
|
+
return { signature, publicKeyRaw };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Verify a peer record's signature against the claimed record fields.
|
|
313
|
+
* @param {CryptoKey} publicKey
|
|
314
|
+
* @param {Uint8Array} signature
|
|
315
|
+
* @param {object} record - see `buildPeerRecordTranscript`
|
|
316
|
+
* @returns {Promise<boolean>}
|
|
317
|
+
*/
|
|
318
|
+
export async function verifyPeerRecord(publicKey, signature, record) {
|
|
319
|
+
const transcript = await buildPeerRecordTranscript(record);
|
|
320
|
+
return verify(publicKey, signature, transcript);
|
|
321
|
+
}
|
|
322
|
+
|
|
217
323
|
// ── Fingerprint ───────────────────────────────────────────────────────
|
|
218
324
|
|
|
219
325
|
/**
|