@johnhenry/wsh 0.23.0 → 0.24.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/README.md CHANGED
@@ -543,7 +543,7 @@ the host to find it:
543
543
  | Current stock client | `@johnhenry/wsh/server` (advertises `stream-announce`) | The client's transport announces the stream with an empty STREAM frame. **No primer is written.** |
544
544
  | Current stock client | Host without `stream-announce` (e.g. the Rust `wsh-server`, older vendored hosts) | The client writes a one-byte primer (`0x00`) on the new stream so the host sees it; that host strips it. `primer: false` disables this (then such a host never binds the stream and the output is lost). |
545
545
  | A client that primes anyway (older stock client, `primer` forced, other implementations) | `@johnhenry/wsh/server` | **The leading `0x00` is dropped:** if the first data on an exec stream is exactly one `0x00` byte it is discarded, never forwarded to the process's stdin. Only that first chunk is inspected; a later `0x00`, or a first chunk that is longer than one byte, is delivered untouched. The cost: a client whose genuine first stdin chunk is a lone NUL loses it. |
546
- | A client that neither announces nor primes | `@johnhenry/wsh/server` | The host waits `bindTimeoutMs` (default 3000) for the stream, then drops the output and logs it. |
546
+ | A client that neither announces nor primes (a third-party QMux client; the stock client always does one or the other) | `@johnhenry/wsh/server` | The host waits `bindTimeoutMs` (default 3000) for the stream, then drops the output and logs it. This cannot be fixed host-side: nothing about the stream has been sent, so there is nothing to bind. Such a client must send `QMuxStream.announce()` (an empty STREAM frame) or any first write -- a zero-length `write()` now does the same. |
547
547
 
548
548
  ## Attach and Resume
549
549
 
@@ -659,6 +659,7 @@ so options the platform gains later need no change here.
659
659
  | `signChallenge()` | Build transcript + sign for auth handshake |
660
660
  | `signPeerRecord()` / `verifyPeerRecord()` | Sign / verify reverse-mode peer records |
661
661
  | `fingerprint()` | SHA-256 hex fingerprint of a public key |
662
+ | `podId()` / `fingerprintToPodId()` / `podIdToFingerprint()` | The same SHA-256 as base64url, the BrowserMesh pod ID: `await podId(raw) === await derivePodId(cryptoKey)` (`@johnhenry/browsermesh-primitives`) for the same Ed25519 key, so one key carries both the wsh fingerprint and the mesh identity. The two converters are pure re-encodings between the two forms |
662
663
  | `parseCertificateHash()` | Decode a certificate digest from hex / base64 / bytes |
663
664
  | `normalizeWebTransportOptions()` | Build a `WebTransportOptions` dictionary from loose input |
664
665
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.23.0",
3
+ "version": "0.24.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",
@@ -42,6 +42,7 @@
42
42
  "devDependencies": {
43
43
  "@fails-components/webtransport": "^1.6.4",
44
44
  "@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
45
+ "@johnhenry/browsermesh-primitives": "^0.2.0",
45
46
  "typescript": "^7.0.0",
46
47
  "ws": "^8.21.0"
47
48
  },
package/src/auth.mjs CHANGED
@@ -396,7 +396,8 @@ export async function fingerprint(publicKeyRaw) {
396
396
 
397
397
  /**
398
398
  * Compute the base64url-encoded SHA-256 pod ID of a raw public key.
399
- * This is the BrowserMesh identity format (43 chars).
399
+ * This is the BrowserMesh identity format (43 chars): for the same key it equals
400
+ * `@johnhenry/browsermesh-primitives`' `derivePodId(cryptoKey)` and `PodIdentity.podId`.
400
401
  * @param {Uint8Array} publicKeyRaw - 32-byte raw Ed25519 public key
401
402
  * @returns {Promise<string>} base64url-encoded pod ID
402
403
  */
@@ -411,6 +412,9 @@ export async function podId(publicKeyRaw) {
411
412
  * @returns {string} base64url pod ID
412
413
  */
413
414
  export function fingerprintToPodId(hexFingerprint) {
415
+ if (typeof hexFingerprint !== 'string' || !/^(?:[0-9a-fA-F]{2})+$/.test(hexFingerprint)) {
416
+ throw new TypeError('fingerprintToPodId: expected a hex fingerprint');
417
+ }
414
418
  const bytes = new Uint8Array(hexFingerprint.length / 2);
415
419
  for (let i = 0; i < bytes.length; i++) {
416
420
  bytes[i] = parseInt(hexFingerprint.slice(i * 2, i * 2 + 2), 16);
package/src/index.d.ts CHANGED
@@ -1004,6 +1004,26 @@ export function verifyPeerRecord(publicKey: CryptoKey, signature: Uint8Array, re
1004
1004
  */
1005
1005
  export function fingerprint(publicKeyRaw: Uint8Array): Promise<string>;
1006
1006
 
1007
+ /**
1008
+ * The BrowserMesh pod ID of a raw public key: base64url (no padding, 43
1009
+ * characters) of the SHA-256 of the 32 raw key bytes. For the same key this
1010
+ * equals `@johnhenry/browsermesh-primitives`' `derivePodId(cryptoKey)` /
1011
+ * `PodIdentity.podId`, and is the same hash as `fingerprint()` in a different
1012
+ * encoding (hex there, base64url here).
1013
+ */
1014
+ export function podId(publicKeyRaw: Uint8Array): Promise<string>;
1015
+
1016
+ /**
1017
+ * Re-encode a hex `fingerprint()` as a pod ID (pure; no hashing).
1018
+ * @throws TypeError if the argument is not an even-length hex string
1019
+ */
1020
+ export function fingerprintToPodId(hexFingerprint: string): string;
1021
+
1022
+ /**
1023
+ * Re-encode a pod ID as the hex `fingerprint()` of the same key (pure; no hashing).
1024
+ */
1025
+ export function podIdToFingerprint(podId: string): string;
1026
+
1007
1027
  /**
1008
1028
  * Get the shortest unique prefix of a fingerprint within a set.
1009
1029
  */
package/src/index.mjs CHANGED
@@ -49,6 +49,7 @@ export {
49
49
  sign, verify, buildTranscript, signChallenge, verifyChallenge,
50
50
  buildPeerRecordTranscript, signPeerRecord, verifyPeerRecord,
51
51
  fingerprint, shortFingerprint, generateNonce,
52
+ podId, fingerprintToPodId, podIdToFingerprint,
52
53
  parseSSHPublicKey, extractRawFromSSHWire, base64Decode,
53
54
  } from './auth.mjs';
54
55
 
@@ -116,6 +116,12 @@ class QMuxStream {
116
116
  if (this.#sendState === SEND_STATE.DATA_SENT) {
117
117
  throw new Error(`Cannot write to stream ${this.id}: already closed (FIN sent)`);
118
118
  }
119
+ if (bytes.byteLength === 0) {
120
+ // Nothing to send, but a first write is still the caller saying "this
121
+ // stream exists": announce it (a no-op once anything has been sent).
122
+ this.announce();
123
+ return;
124
+ }
119
125
  this.#sendState = SEND_STATE.SEND;
120
126
 
121
127
  let offset = 0;