@johnhenry/wsh 0.13.0 → 0.15.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
@@ -1,4 +1,4 @@
1
- # wsh-upon-star
1
+ # @johnhenry/wsh
2
2
 
3
3
  Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.me/wsh/)
4
4
 
@@ -8,7 +8,7 @@ Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.m
8
8
 
9
9
  Browser-native remote command execution over WebTransport/WebSocket with Ed25519 authentication.
10
10
 
11
- 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.
11
+ wsh is a pure-JS client library that connects browsers to remote shells. It implements its own binary protocol — CBOR messages over QMux-multiplexed WebSocket or native WebTransport streams — with Ed25519 challenge-response auth, session management, and MCP tool bridging.
12
12
 
13
13
  ## Install
14
14
 
@@ -26,16 +26,28 @@ Or via CDN:
26
26
 
27
27
  ## Features
28
28
 
29
- - **Ed25519 authentication** -- challenge-response via Web Crypto API, SSH key format support
30
- - **Dual transport** -- WebTransport (native streams) and WebSocket (multiplexed virtual streams) with identical API
29
+ - **Ed25519 authentication** -- challenge-response via Web Crypto API with a transcript binding username and session id, SSH key format support
30
+ - **Dual transport** -- WebTransport (native streams) and WebSocket (QMux-multiplexed streams) with identical API
31
31
  - **CBOR encoding** -- compact binary wire format with length-prefixed framing
32
- - **Session management** -- open, attach, resume, detach, rename PTY/exec sessions
33
- - **Reverse mode** -- register as a peer and accept incoming connections through a relay
34
- - **File transfer** -- scp-like upload/download over dedicated streams in 64KB chunks
32
+ - **Session management** -- open, attach, resume, detach, rename PTY/exec sessions, with session-scoped resume tokens and per-principal access grants
33
+ - **Reverse mode** -- register as a peer (via a signed peer record) and accept incoming connections through a relay
34
+ - **File transfer** -- scp-like upload/download as `FileChunk` control messages in 64KB chunks
35
35
  - **MCP bridge** -- discover and invoke remote MCP tools through the control channel
36
36
  - **Session recording** -- asciicast v2 compatible recording and playback with seek/pause/resume
37
37
  - **Key management** -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
38
- - **80+ message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, and more
38
+ - **95 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, and more
39
+
40
+ ## Wire Protocol: QMux
41
+
42
+ Over WebSocket, wsh multiplexes streams with QMux (draft-ietf-quic-qmux-02):
43
+ QUIC-v1 frames -- RFC 9000 varints, STREAM, RESET_STREAM, STOP_SENDING,
44
+ MAX_DATA/MAX_STREAM_DATA/MAX_STREAMS, CONNECTION_CLOSE, DATAGRAM, plus
45
+ RESET_STREAM_AT from draft-ietf-quic-reliable-stream-reset -- carried in
46
+ self-delimiting Records over the ordered, reliable WebSocket byte stream.
47
+ The control channel is QMux stream 0, and every stream gets QUIC-style
48
+ windowed flow control, so a slow consumer exerts real backpressure.
49
+ `QMuxConnection` and the frame codec primitives are exported so alternate
50
+ server implementations can speak the same framing.
39
51
 
40
52
  ## Quick Start
41
53
 
@@ -94,6 +106,27 @@ console.log(new TextDecoder().decode(stdout));
94
106
  console.log('Exit code:', exitCode);
95
107
  ```
96
108
 
109
+ ## Attach and Resume
110
+
111
+ Opening a PTY/exec session returns a session-scoped credential alongside the channel:
112
+
113
+ ```js
114
+ session.sessionId; // server-assigned session id (undefined for e.g. file channels)
115
+ session.resumeToken; // token minted at open time; only the opener receives it
116
+
117
+ // The original opener, reclaiming its session from a fresh connection:
118
+ await client.resumeSession(session.sessionId, session.resumeToken);
119
+
120
+ // Any other authorized principal attaches without a token -- ownership
121
+ // or an ACL grant is enough:
122
+ await client.grantSessionAccess(session.sessionId, 'bob'); // by the owner
123
+ await otherClient.attachSession(session.sessionId); // by 'bob'
124
+
125
+ // Other session-management round trips:
126
+ await client.detach(session.sessionId); // leave it running server-side
127
+ await client.listRemoteSessions(); // sessions this key can see
128
+ ```
129
+
97
130
  ## API Overview
98
131
 
99
132
  ### Core Classes
@@ -117,17 +150,19 @@ console.log('Exit code:', exitCode);
117
150
  | `SessionPlayer` | Replay recordings with original timing |
118
151
  | `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
119
152
  | `signChallenge()` | Build transcript + sign for auth handshake |
153
+ | `signPeerRecord()` / `verifyPeerRecord()` | Sign / verify reverse-mode peer records |
120
154
  | `fingerprint()` | SHA-256 hex fingerprint of a public key |
121
155
 
122
156
  ### Protocol
123
157
 
124
158
  | Export | Description |
125
159
  |--------|-------------|
126
- | `MSG` | 80+ message type constants (hex opcodes) |
160
+ | `MSG` | 95 message type constants (hex opcodes) |
127
161
  | `CHANNEL_KIND` | Channel types: `pty`, `exec`, `meta`, `file`, `tcp`, `udp`, `job` |
128
162
  | `AUTH_METHOD` | Auth methods: `pubkey`, `password` |
129
163
  | `cborEncode` / `cborDecode` | CBOR codec (maps, arrays, strings, ints, bytes, bools, null, floats) |
130
164
  | `frameEncode` / `FrameDecoder` | 4-byte big-endian length-prefixed framing |
165
+ | `QMuxConnection` + QMux primitives | QMux stream state machine, error codes, and stream-id helpers for building alternate servers |
131
166
 
132
167
  ## Protocol Specification
133
168
 
@@ -137,6 +172,25 @@ The `spec/` directory contains the protocol definition:
137
172
  - `wsh-v1.md` -- human-readable protocol specification
138
173
  - `codegen.mjs` -- generates `messages.gen.mjs` from the YAML spec
139
174
 
175
+ ## Security
176
+
177
+ - **Auth transcript binding** -- challenge signatures cover
178
+ `SHA-256("wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding)`,
179
+ so a signature can't be replayed against a different session or relabeled
180
+ to a different username.
181
+ - **Signed peer records** -- reverse-mode registration is self-signed by the
182
+ peer's identity key (the libp2p RFC 0002/0003 pattern), in a signing
183
+ domain separate from the auth challenge. `listPeers()` verifies every
184
+ entry client-side and reports a `verified` boolean, independent of
185
+ trusting the relay.
186
+ - **Hybrid post-quantum E2E (experimental)** --
187
+ `initiateE2E(sessionId, 'X25519+ML-KEM-768')` combines X25519 ECDH with
188
+ ML-KEM-768 via HKDF-SHA256, preferring native WebCrypto ML-KEM-768 (Node
189
+ 24.7+) with the optional `@noble/post-quantum` pure-JS fallback, and
190
+ falling back to classical X25519 automatically when the peer can't do
191
+ hybrid (check the returned `hybrid` flag). The derived AES-256-GCM key is
192
+ not yet wired to actual frame encryption.
193
+
140
194
  ## Browser Compatibility
141
195
 
142
196
  Requires a browser (or Node.js 24+) with:
@@ -147,6 +201,10 @@ Requires a browser (or Node.js 24+) with:
147
201
  - TextEncoder/TextDecoder
148
202
  - ReadableStream/WritableStream
149
203
 
204
+ Hybrid ML-KEM-768 key exchange prefers native WebCrypto ML-KEM (Node 24.7+,
205
+ some browsers); elsewhere the optional `@noble/post-quantum` dependency is
206
+ loaded dynamically.
207
+
150
208
  ## License
151
209
 
152
210
  MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.13.0",
4
- "description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
3
+ "version": "0.15.0",
4
+ "description": "Web Shell — browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
7
7
  "exports": {
package/src/client.mjs CHANGED
@@ -134,8 +134,18 @@ export class WshClient {
134
134
  /** @type {string|null} Session ID assigned by the server after authentication. */
135
135
  #sessionId = null;
136
136
 
137
- /** @type {string|null} Resume token from AUTH_OK. */
138
- #resumeToken = null;
137
+ /**
138
+ * @type {string|null} Connection-level token from AUTH_OK, bound to
139
+ * this connection's own auth session_id (from CHALLENGE/SERVER_HELLO) --
140
+ * NOT a PTY/exec session's token. Renamed from `#resumeToken` (clawser
141
+ * #48): the old name invited exactly the bug this fix addresses --
142
+ * attachSession() used to send this as if it were a valid token for the
143
+ * *target* session_id, which it can never be (it's scoped to a
144
+ * different session_id entirely). Not used for anything else currently;
145
+ * kept and exposed via `authToken` for potential future connection-level
146
+ * resume use, mirroring the Rust client's `WshClient::token()`.
147
+ */
148
+ #authToken = null;
139
149
 
140
150
  /** @type {import('./transport.mjs').WshTransport|null} Active transport. */
141
151
  #transport = null;
@@ -282,6 +292,16 @@ export class WshClient {
282
292
  return this.#sessionId;
283
293
  }
284
294
 
295
+ /**
296
+ * This connection's own AUTH-level token (from AUTH_OK), scoped to this
297
+ * connection's auth session_id -- NOT a PTY/exec session token. See
298
+ * `WshSession.resumeToken` for the per-session credential used by
299
+ * `resumeSession`.
300
+ */
301
+ get authToken() {
302
+ return this.#authToken;
303
+ }
304
+
285
305
  /** Read-only view of active sessions. */
286
306
  get sessions() {
287
307
  return new Map(this.#sessions);
@@ -341,7 +361,7 @@ export class WshClient {
341
361
  this.#channelCounter = 0;
342
362
  this.#waiters.clear();
343
363
  this.#sessionId = null;
344
- this.#resumeToken = null;
364
+ this.#authToken = null;
345
365
 
346
366
  try {
347
367
  // ── Select and connect transport ──────────────────────────────
@@ -384,7 +404,7 @@ export class WshClient {
384
404
  if (challengeMsg.type === MSG.AUTH_OK) {
385
405
  // Server accepted without challenge (e.g. trusted key).
386
406
  this.#sessionId = challengeMsg.session_id || tempSessionId;
387
- this.#resumeToken = challengeMsg.token || null;
407
+ this.#authToken = challengeMsg.token || null;
388
408
  this.#state = STATE_AUTHENTICATED;
389
409
  this.#startPing();
390
410
  return this.#sessionId;
@@ -460,7 +480,7 @@ export class WshClient {
460
480
  }
461
481
 
462
482
  this.#sessionId = authResult.session_id || tempSessionId;
463
- this.#resumeToken = authResult.token || null;
483
+ this.#authToken = authResult.token || null;
464
484
  this.#state = STATE_AUTHENTICATED;
465
485
  this.#startPing();
466
486
 
@@ -538,37 +558,48 @@ export class WshClient {
538
558
  /**
539
559
  * Attach to an existing remote session (collaborative or read-only).
540
560
  *
561
+ * Authorization is satisfied by EITHER `token` OR the caller already
562
+ * owning/being ACL-granted access to `targetSessionId` server-side (see
563
+ * wsh-server's `check_session_access`) -- `token` is optional and, for
564
+ * the common case of attaching via ownership or a `grantSessionAccess`
565
+ * grant, should simply be omitted (a granted principal never receives
566
+ * the session's token to begin with -- only the opener does, via
567
+ * `WshSession.resumeToken`). Pass one explicitly only if you have it
568
+ * (e.g. this same process opened the session earlier, or the owner
569
+ * shared it with you out of band).
570
+ *
571
+ * Previously (clawser #48) this always sent this connection's own
572
+ * AUTH-level token, which can never verify against a *different*
573
+ * session_id -- making attachSession() unreachable for everyone,
574
+ * including the session's own owner from a fresh connection. Also fixed
575
+ * alongside that: the server actually replies with PRESENCE (success)
576
+ * or ERROR (failure), not OPEN_OK/OPEN_FAIL -- this used to wait on the
577
+ * wrong response stream entirely and would have hung even once the
578
+ * token bug was fixed.
579
+ *
541
580
  * @param {string} targetSessionId - Remote session ID to attach to
542
581
  * @param {object} [opts]
543
582
  * @param {boolean} [opts.readOnly=false] - Attach in read-only mode
583
+ * @param {Uint8Array} [opts.token] - Session-scoped token (optional; see above)
544
584
  * @param {number} [opts.timeout] - Timeout in ms
545
- * @returns {Promise<object>} Server's response
585
+ * @returns {Promise<object>} Server's PRESENCE response
546
586
  */
547
- async attachSession(targetSessionId, { readOnly = false, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
587
+ async attachSession(targetSessionId, { readOnly = false, token, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
548
588
  this.#assertAuthenticated('attachSession');
549
589
 
550
590
  const attachMode = readOnly ? 'readonly' : 'control';
551
591
  await this.#transport.sendControl(
552
- attachMsg({ sessionId: targetSessionId, token: this.#resumeToken, mode: attachMode })
592
+ attachMsg({ sessionId: targetSessionId, token, mode: attachMode })
553
593
  );
554
594
 
555
- // Shares #pendingOpens with openSession() -- both wait on the same
556
- // OPEN_OK/OPEN_FAIL response stream, in the order their requests were
557
- // sent (see #pendingOpens's doc comment). Unlike openSession(),
558
- // attachSession() doesn't construct a WshSession, so it just wants
559
- // the raw response either way.
560
- const response = await new Promise((resolve, reject) => {
561
- const entry = { mode: 'raw', resolve, reject };
562
- entry.timer = setTimeout(() => {
563
- const idx = this.#pendingOpens.indexOf(entry);
564
- if (idx !== -1) this.#pendingOpens.splice(idx, 1);
565
- reject(new Error('Timed out waiting for attach response'));
566
- }, timeout);
567
- this.#pendingOpens.push(entry);
568
- });
595
+ const response = await this.#waitForMessage(
596
+ [MSG.PRESENCE, MSG.ERROR],
597
+ timeout,
598
+ 'Timed out waiting for attach response'
599
+ );
569
600
 
570
- if (response.type === MSG.OPEN_FAIL) {
571
- throw new Error(`Failed to attach: ${response.reason || 'rejected'}`);
601
+ if (response.type === MSG.ERROR) {
602
+ throw new Error(`Failed to attach: ${response.message || 'rejected'}`);
572
603
  }
573
604
 
574
605
  return response;
@@ -577,26 +608,35 @@ export class WshClient {
577
608
  /**
578
609
  * Resume a previously disconnected session.
579
610
  *
611
+ * Unlike attachSession(), `token` is required and verified
612
+ * unconditionally server-side -- Resume is specifically for the
613
+ * connection that was handed this exact token (via
614
+ * `WshSession.resumeToken`, when it originally opened the session)
615
+ * coming back. A principal who only has ACL/ownership access but never
616
+ * held the token should use attachSession() instead.
617
+ *
580
618
  * @param {string} targetSessionId - Session ID to resume
581
- * @param {string} token - Resume token from original AUTH_OK
582
- * @param {number} [opts.timeout] - Timeout in ms
583
- * @returns {Promise<object>} Server's response
619
+ * @param {Uint8Array} token - Session-scoped resume token (see `WshSession.resumeToken`)
620
+ * @param {object} [opts]
621
+ * @param {number} [opts.lastSeq=0] - Last sequence number this client already has (currently advisory -- the ring buffer replays its full contents regardless)
622
+ * @param {number} [opts.timeout=10000] - Timeout in ms
623
+ * @returns {Promise<object>} Server's PRESENCE response
584
624
  */
585
- async resumeSession(targetSessionId, token, { timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
625
+ async resumeSession(targetSessionId, token, { lastSeq = 0, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
586
626
  this.#assertAuthenticated('resumeSession');
587
627
 
588
628
  await this.#transport.sendControl(
589
- resumeMsg({ sessionId: targetSessionId, token })
629
+ resumeMsg({ sessionId: targetSessionId, token, lastSeq })
590
630
  );
591
631
 
592
632
  const response = await this.#waitForMessage(
593
- [MSG.AUTH_OK, MSG.AUTH_FAIL],
633
+ [MSG.PRESENCE, MSG.ERROR],
594
634
  timeout,
595
635
  'Timed out waiting for resume response'
596
636
  );
597
637
 
598
- if (response.type === MSG.AUTH_FAIL) {
599
- throw new Error(`Failed to resume: ${response.reason || 'rejected'}`);
638
+ if (response.type === MSG.ERROR) {
639
+ throw new Error(`Failed to resume: ${response.message || 'rejected'}`);
600
640
  }
601
641
 
602
642
  return response;
@@ -1667,7 +1707,7 @@ export class WshClient {
1667
1707
 
1668
1708
  if (challengeMsg.type === MSG.AUTH_OK) {
1669
1709
  this.#sessionId = challengeMsg.session_id || tempSessionId;
1670
- this.#resumeToken = challengeMsg.token || null;
1710
+ this.#authToken = challengeMsg.token || null;
1671
1711
  this.#state = STATE_AUTHENTICATED;
1672
1712
  this.#startPing();
1673
1713
  return this.#sessionId;
@@ -1713,7 +1753,7 @@ export class WshClient {
1713
1753
  }
1714
1754
 
1715
1755
  this.#sessionId = authResult.session_id || tempSessionId;
1716
- this.#resumeToken = authResult.token || null;
1756
+ this.#authToken = authResult.token || null;
1717
1757
  this.#state = STATE_AUTHENTICATED;
1718
1758
  this.#startPing();
1719
1759
 
@@ -1773,14 +1813,6 @@ export class WshClient {
1773
1813
  if (!pending) return;
1774
1814
  clearTimeout(pending.timer);
1775
1815
 
1776
- // attachSession() wants the raw response either way and never
1777
- // constructs a session -- it has no #sessions-registration race to
1778
- // protect against.
1779
- if (pending.mode === 'raw') {
1780
- pending.resolve(msg);
1781
- return;
1782
- }
1783
-
1784
1816
  if (type === MSG.OPEN_FAIL) {
1785
1817
  pending.reject(new Error(`Failed to open session: ${msg.reason || 'rejected'}`));
1786
1818
  return;
@@ -1796,7 +1828,18 @@ export class WshClient {
1796
1828
  serverChannelId,
1797
1829
  streamIds,
1798
1830
  pending.kind,
1799
- { dataMode, capabilities }
1831
+ {
1832
+ dataMode,
1833
+ capabilities,
1834
+ // clawser #48: OpenOk now carries the session_id/token this
1835
+ // channel belongs to (pty/exec only -- undefined for e.g. file
1836
+ // channels, which have no Attach/Resume-able session). Exposed
1837
+ // via WshSession.sessionId/resumeToken for a later
1838
+ // attachSession()/resumeSession() call, possibly from a
1839
+ // different connection.
1840
+ sessionId: msg.session_id,
1841
+ resumeToken: msg.token,
1842
+ }
1800
1843
  );
1801
1844
  this.#sessions.set(serverChannelId, session);
1802
1845
 
@@ -0,0 +1,177 @@
1
+ /**
2
+ * EncryptedFrame sealing/opening for wsh's opt-in end-to-end encryption
3
+ * layer (see `client.mjs`'s `initiateE2E`, which derives the AES-256-GCM
4
+ * `CryptoKey` this module seals/opens with, and `spec/wsh-v1.yaml`'s
5
+ * `EncryptedFrame` message).
6
+ *
7
+ * This is PR 1 of a multi-PR E2E rollout: it implements the AEAD
8
+ * primitives and wires them into virtual-mode sessions only (see
9
+ * `session.mjs`'s `enableE2E`). Stream-mode sessions (raw WebTransport
10
+ * byte streams with no message boundaries) are explicitly out of scope
11
+ * here -- they need separate ad hoc chunk-framing design.
12
+ *
13
+ * ── Nonce construction ─────────────────────────────────────────────
14
+ * Nonces are the AES-GCM-mandated 96 bits (12 bytes), built as:
15
+ *
16
+ * [ 8-byte big-endian monotonic counter ][ 4-byte sender-role tag ]
17
+ *
18
+ * The counter starts at 0 and increments by one for every frame a given
19
+ * sender seals (never reused, per `openFrame`'s strict expected-counter
20
+ * check below). The 4-byte role tag is fixed per sender for the
21
+ * lifetime of one E2E-enabled connection (see `session.mjs`'s
22
+ * `enableE2E({ role })`) and differs between the two peers of a
23
+ * session, so the two directions of one session structurally cannot
24
+ * produce a colliding nonce even under fully concurrent bidirectional
25
+ * traffic -- the 8-byte counter alone would collide if both sides ever
26
+ * reached the same count, but the two peers' tags never match, so the
27
+ * full 12-byte nonces never match either.
28
+ *
29
+ * ── AAD ────────────────────────────────────────────────────────────
30
+ * The frame's `session_id` (UTF-8 bytes) is bound as AES-GCM
31
+ * "additional authenticated data": it's authenticated but never
32
+ * encrypted, so ciphertext can't be spliced from one session onto
33
+ * another by a relay that only ever sees ciphertext (the server, for a
34
+ * genuinely E2E session) -- decryption fails if the session_id supplied
35
+ * to `openFrame` doesn't match the one the ciphertext was sealed under.
36
+ *
37
+ * ── Replay/reorder protection ─────────────────────────────────────
38
+ * `openFrame` requires the frame's nonce counter to be *exactly* the
39
+ * caller-supplied `expectedCounter` -- not merely "not yet seen" or
40
+ * "not too old". This is intentionally strict for v1: any replayed,
41
+ * duplicated, dropped, or reordered frame is rejected outright rather
42
+ * than tolerated or reordered-and-delivered. A later PR can relax this
43
+ * to a sliding window if wsh ever needs to tolerate out-of-order
44
+ * delivery for E2E frames; today's virtual-mode transport already
45
+ * delivers control messages in order, so strict-next-counter is not a
46
+ * practical limitation.
47
+ *
48
+ * ── Key lifetime ───────────────────────────────────────────────────
49
+ * The `key` passed to `sealFrame`/`openFrame` is connection-scoped, not
50
+ * session-scoped: a session that is detached and later Resumed/Attached
51
+ * on a new connection MUST run a fresh `initiateE2E()` and call
52
+ * `enableE2E()` again with the new key. Never persist or reuse a
53
+ * `sharedSecret` (or its counters) across a resume -- see
54
+ * `session.mjs`'s `enableE2E` doc comment.
55
+ */
56
+
57
+ const NONCE_LENGTH = 12;
58
+ const COUNTER_LENGTH = 8;
59
+ const ROLE_TAG_LENGTH = 4;
60
+
61
+ const textEncoder = new TextEncoder();
62
+
63
+ /**
64
+ * Encode a non-negative integer counter as an 8-byte big-endian value.
65
+ * @param {number} counter
66
+ * @returns {Uint8Array}
67
+ */
68
+ function encodeCounter(counter) {
69
+ if (!Number.isInteger(counter) || counter < 0) {
70
+ throw new Error(`e2e-frame: counter must be a non-negative integer, got ${counter}`);
71
+ }
72
+ const bytes = new Uint8Array(COUNTER_LENGTH);
73
+ const view = new DataView(bytes.buffer);
74
+ // DataView.setBigUint64 covers the full 64-bit range; counters here
75
+ // never realistically approach it, but there's no reason to cap early.
76
+ view.setBigUint64(0, BigInt(counter), false);
77
+ return bytes;
78
+ }
79
+
80
+ /**
81
+ * Build the 12-byte nonce for a given (counter, role tag) pair. Exported
82
+ * mainly for tests -- callers should go through `sealFrame`/`openFrame`.
83
+ *
84
+ * @param {number} counter
85
+ * @param {Uint8Array} roleTag - exactly 4 bytes
86
+ * @returns {Uint8Array}
87
+ */
88
+ export function buildNonce(counter, roleTag) {
89
+ if (!(roleTag instanceof Uint8Array) || roleTag.length !== ROLE_TAG_LENGTH) {
90
+ throw new Error(`e2e-frame: roleTag must be a ${ROLE_TAG_LENGTH}-byte Uint8Array`);
91
+ }
92
+ const nonce = new Uint8Array(NONCE_LENGTH);
93
+ nonce.set(encodeCounter(counter), 0);
94
+ nonce.set(roleTag, COUNTER_LENGTH);
95
+ return nonce;
96
+ }
97
+
98
+ /**
99
+ * Seal a plaintext frame under an AES-256-GCM key.
100
+ *
101
+ * @param {CryptoKey} key - non-extractable AES-GCM CryptoKey (e.g. from `client.mjs`'s `initiateE2E`)
102
+ * @param {string} sessionId - bound as AAD
103
+ * @param {Uint8Array} roleTag - 4-byte sender role tag, distinct between the two peers of a session
104
+ * @param {number} counter - this sender's next monotonic send counter (0, 1, 2, ...)
105
+ * @param {Uint8Array} plaintext
106
+ * @returns {Promise<{nonce: Uint8Array, ciphertext: Uint8Array}>}
107
+ */
108
+ export async function sealFrame(key, sessionId, roleTag, counter, plaintext) {
109
+ if (!key || typeof key !== 'object') {
110
+ throw new Error('e2e-frame: sealFrame requires a CryptoKey');
111
+ }
112
+ const nonce = buildNonce(counter, roleTag);
113
+ const aad = textEncoder.encode(sessionId);
114
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
115
+ { name: 'AES-GCM', iv: nonce, additionalData: aad },
116
+ key,
117
+ plaintext
118
+ ));
119
+ return { nonce, ciphertext };
120
+ }
121
+
122
+ /**
123
+ * Open (decrypt + authenticate) a sealed frame.
124
+ *
125
+ * Throws if:
126
+ * - the AEAD authentication tag doesn't verify (tampered ciphertext,
127
+ * wrong key, or `sessionId` mismatch since it's bound as AAD), or
128
+ * - the frame's nonce counter isn't exactly `expectedCounter` (replay,
129
+ * reorder, or drop -- see this module's doc comment).
130
+ *
131
+ * @param {CryptoKey} key
132
+ * @param {string} sessionId - must match what the frame was sealed with (bound as AAD)
133
+ * @param {number} expectedCounter - the exact next counter this peer expects from the sender
134
+ * @param {{nonce: Uint8Array, ciphertext: Uint8Array}} frame
135
+ * @returns {Promise<Uint8Array>} plaintext
136
+ */
137
+ export async function openFrame(key, sessionId, expectedCounter, { nonce, ciphertext }) {
138
+ if (!key || typeof key !== 'object') {
139
+ throw new Error('e2e-frame: openFrame requires a CryptoKey');
140
+ }
141
+ if (!(nonce instanceof Uint8Array) || nonce.length !== NONCE_LENGTH) {
142
+ throw new Error(`e2e-frame: nonce must be a ${NONCE_LENGTH}-byte Uint8Array`);
143
+ }
144
+ const actualCounter = Number(new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength).getBigUint64(0, false));
145
+ if (actualCounter !== expectedCounter) {
146
+ throw new Error(
147
+ `e2e-frame: replay/reorder detected -- expected counter ${expectedCounter}, got ${actualCounter}`
148
+ );
149
+ }
150
+ const aad = textEncoder.encode(sessionId);
151
+ try {
152
+ return new Uint8Array(await crypto.subtle.decrypt(
153
+ { name: 'AES-GCM', iv: nonce, additionalData: aad },
154
+ key,
155
+ ciphertext
156
+ ));
157
+ } catch (err) {
158
+ throw new Error(`e2e-frame: authentication failed while opening frame (${err.message})`);
159
+ }
160
+ }
161
+
162
+ export const E2E_NONCE_LENGTH = NONCE_LENGTH;
163
+ export const E2E_ROLE_TAG_LENGTH = ROLE_TAG_LENGTH;
164
+
165
+ /**
166
+ * Deterministic 4-byte role tags for the two sides of one E2E-enabled
167
+ * connection. Callers pick a role at `enableE2E()` time -- 'initiator'
168
+ * is naturally the side that called `initiateE2E()` first / sent round-1
169
+ * KeyExchange, 'responder' the other side; any consistent, non-
170
+ * overlapping assignment works since these are just fixed structural
171
+ * tags, not secret values.
172
+ * @type {Record<'initiator'|'responder', Uint8Array>}
173
+ */
174
+ export const ROLE_TAGS = Object.freeze({
175
+ initiator: Uint8Array.from([0x69, 0x6e, 0x69, 0x74]), // "init"
176
+ responder: Uint8Array.from([0x72, 0x65, 0x73, 0x70]), // "resp"
177
+ });