@johnhenry/wsh 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.6.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
  /**