@johnhenry/wsh 0.27.0 → 0.28.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 +112 -40
- package/package.json +1 -1
- package/src/client.mjs +51 -47
- package/src/e2e-exchange.mjs +227 -0
- package/src/index.d.ts +50 -3
- package/src/index.mjs +4 -1
- package/src/messages.gen.mjs +13 -7
- package/src/server/connection.mjs +87 -3
- package/src/server/index.d.ts +25 -0
- package/src/server/index.mjs +6 -1
- package/src/server/relay.mjs +103 -24
- package/src/server/reverse-host.mjs +93 -28
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ Or via CDN:
|
|
|
63
63
|
- **MCP bridge** -- discover and invoke remote MCP tools through the control channel
|
|
64
64
|
- **Session recording** -- own JSON schema (not asciicast v2 -- see note below) recording and playback with seek/pause/resume
|
|
65
65
|
- **Key management** -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
|
|
66
|
-
- **
|
|
66
|
+
- **98 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, authorized-key management, and more
|
|
67
67
|
|
|
68
68
|
> **Session recording is not asciicast v2.** Both `SessionRecorder`
|
|
69
69
|
> implementations capture more than asciicast v2's three event codes
|
|
@@ -342,23 +342,45 @@ const s = await client.openSession({ type: 'exec', command: 'echo hello' });
|
|
|
342
342
|
checked against the allowlist (a non-forwardable or undecodable inner is
|
|
343
343
|
dropped, logged), and re-wrapped with the real sender; traffic from a connection
|
|
344
344
|
that is not bridged is never forwarded.
|
|
345
|
-
- **One operator per peer at a time
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
with `
|
|
351
|
-
(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
`
|
|
361
|
-
|
|
345
|
+
- **One operator per peer at a time, unless you raise it.** The default: a
|
|
346
|
+
second `ReverseConnect` to a peer that already has an operator (or a request
|
|
347
|
+
awaiting its answer) is rejected with `ReverseReject.reason` `busy: this peer
|
|
348
|
+
already has an operator ...` (exported as `BUSY_PEER` from
|
|
349
|
+
`src/server/relay.mjs`), and an operator that already has a bridge or a
|
|
350
|
+
request in flight is rejected with `busy: you already have a bridge ...`
|
|
351
|
+
(`BUSY_OPERATOR`); a request the peer does not answer within
|
|
352
|
+
`connectTimeoutMs` (default 8 s) is `peer did not respond`. A bridge lasts as
|
|
353
|
+
long as both connections: when either ends, the relay closes the other, so
|
|
354
|
+
nothing an operator started outlives it (the reverse host kills its
|
|
355
|
+
processes). `createReverseHost` redials with backoff by default
|
|
356
|
+
(`reconnect: false` to turn it off), so it is registered again for the next
|
|
357
|
+
operator.
|
|
358
|
+
- **Several operators per peer (opt-in on both ends).** Set
|
|
359
|
+
`relay: { maxOperatorsPerPeer: n }` on the relay and
|
|
360
|
+
`createReverseHost({ maxOperators: n })` on the peer. Three things make it
|
|
361
|
+
work, all optional on the wire so nothing older breaks: a peer states
|
|
362
|
+
`relay-multi-operator` in `ReverseAccept.features`; it addresses each message
|
|
363
|
+
to one operator with `RelayForward.to_fingerprint` (the relay routes it there
|
|
364
|
+
and re-wraps it with the peer as sender -- the field is never delivered, and
|
|
365
|
+
a forward addressed to nobody it serves is dropped, as is an unaddressed one
|
|
366
|
+
while two or more are bridged; with exactly one operator an unaddressed reply
|
|
367
|
+
still goes to it); and when one operator leaves the relay sends the peer a
|
|
368
|
+
`ReverseClose { target_fingerprint }` and keeps the peer registered, instead
|
|
369
|
+
of closing it. Each operator is accepted on its own identity (`accept` runs
|
|
370
|
+
per operator) and gets its own connection state on the host -- channels,
|
|
371
|
+
processes, files -- so when one leaves only its work is cleaned up. The relay
|
|
372
|
+
advertises `relay-multi-operator` in its `ServerHello` when it is configured
|
|
373
|
+
for several, and a host only claims it then, so a host with `maxOperators` on
|
|
374
|
+
an older or default relay serves one operator exactly as before. A peer that
|
|
375
|
+
never stated the feature stays held to one operator (and is closed when its
|
|
376
|
+
operator leaves) even on a multi-operator relay; a peer answers one
|
|
377
|
+
`ReverseConnect` at a time (a concurrent one is told `busy: ... retry in a
|
|
378
|
+
moment`), since a `ReverseAccept` does not name its operator; and at the cap
|
|
379
|
+
the reason is `busy: this peer already has its maximum of n operators`
|
|
380
|
+
(`busyPeerFull(n)`). The **Rust relay is still one operator per peer**: it
|
|
381
|
+
never sends `ReverseClose` and rejects the second operator; the new spec
|
|
382
|
+
fields are regenerated into the Rust types and ignored there. Tracked for the
|
|
383
|
+
Rust relay in #93.
|
|
362
384
|
- **On the peer**, the bridged operator is served by the same connection code as
|
|
363
385
|
a direct client, over an in-memory message pipe instead of a socket. Nothing a
|
|
364
386
|
client opens can cross the relay but control messages, so exec sessions are
|
|
@@ -368,17 +390,39 @@ const s = await client.openSession({ type: 'exec', command: 'echo hello' });
|
|
|
368
390
|
are on the spec's `forwardable` list, so an operator and a peer can run
|
|
369
391
|
`initiateE2E()` / `enableE2E()` with each other through the relay: it carries
|
|
370
392
|
the CBOR of each as opaque bytes and holds no key, so it sees only public
|
|
371
|
-
values and ciphertext (`test/server-relay-bridge.test.mjs`
|
|
372
|
-
never holds the plaintext).
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
393
|
+
values and ciphertext (`test/server-relay-bridge.test.mjs` and
|
|
394
|
+
`test/reverse-host-e2e.test.mjs` check the relay never holds the plaintext).
|
|
395
|
+
**`createReverseHost` answers it too** (`e2e` option, on by default; `false`
|
|
396
|
+
turns it off): for a session the host has opened (the id is the one in
|
|
397
|
+
`OpenOk`), an operator's `KeyExchange` is answered -- X25519, or hybrid
|
|
398
|
+
X25519+ML-KEM-768 when asked and available -- and from then on that session's
|
|
399
|
+
output is sealed into `EncryptedFrame`s (AAD = the session id, strict
|
|
400
|
+
counters, the `responder` role tag) and its input is opened from them.
|
|
401
|
+
Plaintext `SessionData` input for an encrypted session is ignored, so a relay
|
|
402
|
+
cannot inject stdin around the encryption, and a replayed, tampered or
|
|
403
|
+
spliced frame is dropped without consuming a counter. The host advertises
|
|
404
|
+
`e2e` (and `e2e-sign`) in `ReverseAccept.features`. Start E2E before the
|
|
405
|
+
session produces output you need confidential: output sent before the key
|
|
406
|
+
exists is plaintext, and a frame that reaches the operator before it has
|
|
407
|
+
called `enableE2E()` is dropped (open the session with a command that waits
|
|
408
|
+
for input, or use a shell and let the first prompt precede the exchange).
|
|
409
|
+
This is virtual-mode E2E (`EncryptedFrame`), so it is offered over a relay
|
|
410
|
+
bridge, not on direct connections (`createWshServer`).
|
|
411
|
+
**`KeyExchange` is unauthenticated by itself**, which protects against a
|
|
412
|
+
relay that only observes; to defeat one that substitutes keys the host
|
|
413
|
+
**signs its reply** with its identity key (Ed25519 over the label, session
|
|
414
|
+
id, algorithm and both ephemeral keys; the optional `KeyExchange.signature`),
|
|
415
|
+
and the operator checks it: `client.initiateE2E(sessionId, algorithm,
|
|
416
|
+
timeout, { verifyPeer: hostPublicKey })` -- the host's raw key, e.g. the
|
|
417
|
+
`public_key` of the record `listPeers()` verified -- rejects with
|
|
418
|
+
`code: 'E2E_PEER_UNAUTHENTICATED'` on a missing or wrong signature, and
|
|
419
|
+
resolves `peerAuthenticated: true` otherwise. Without `verifyPeer` nothing is
|
|
420
|
+
checked, as before. Signing is `e2e: { sign: 'auto' }` by default: the host
|
|
421
|
+
signs when the relay's `ServerHello` carries `e2e-sign` (this package's relay
|
|
422
|
+
does) because a relay built before the field existed -- notably Rust before
|
|
423
|
+
the release that regenerates it -- drops a connection that sends one;
|
|
424
|
+
`sign: true` forces it, `false` never. Key exchange from a connection that
|
|
425
|
+
is not bridged is never carried.
|
|
382
426
|
- **Feature gates follow the host behind the bridge.** The host's own features
|
|
383
427
|
(`file-write`, `file-rename`, `mcp-call-id`) are stated in the optional
|
|
384
428
|
`ReverseAccept.features`; the relay forwards the list (bounded: at most 64
|
|
@@ -723,9 +767,9 @@ whoever supplies the `Server`/`Client`, and the package root stays
|
|
|
723
767
|
dependency-free and browser-safe.
|
|
724
768
|
|
|
725
769
|
**Compatibility.** Purely additive. Old clients never open `rpc` sessions; an old
|
|
726
|
-
host
|
|
727
|
-
`
|
|
728
|
-
|
|
770
|
+
host does not advertise `rpc`, so a new client fails fast with
|
|
771
|
+
`UNSUPPORTED_PROTOCOL` instead of waiting on an `OpenFail`. `@johnhenry/wsh/server`
|
|
772
|
+
and the Rust `wsh-server` (see below) serve `rpc` today. Not goals: a
|
|
729
773
|
replacement for byte streams (exec/pty are unchanged), a new transport (`rpc`
|
|
730
774
|
rides WebSocket/QMux and WebTransport streams), or an auth change -- an `rpc`
|
|
731
775
|
session is opened on an authenticated connection and per-method authorization is
|
|
@@ -733,6 +777,33 @@ the handler's job (`-32003`; `ctx.user` / `ctx.fingerprint` identify the caller)
|
|
|
733
777
|
Handlers run per channel, so register methods synchronously (or before the
|
|
734
778
|
returned promise settles: inbound messages are held until it does).
|
|
735
779
|
|
|
780
|
+
**The Rust `wsh-server`** (since `rust-v0.4.0`) serves `wsh-host` and `wsh-fs`
|
|
781
|
+
exactly as the JS server does -- same features, methods, parameters, results,
|
|
782
|
+
error codes and messages, same CBOR-sequence decoder and size bound, same
|
|
783
|
+
`$/cancel` / `$/progress` -- over a WebSocket (QMux) connection. It does not
|
|
784
|
+
serve `mcp` (hosting an MCP server is an embedding concern) and does not offer
|
|
785
|
+
`rpc` on the native WebTransport listener, whose single control stream has no
|
|
786
|
+
client-opened data streams to carry a channel. `wsh-host` is on by default;
|
|
787
|
+
`wsh-fs` is **off until you give it a root** (file access is opt-in), and is
|
|
788
|
+
confined to it the way the JS `fs` option is. In `~/.wsh/config.toml`:
|
|
789
|
+
|
|
790
|
+
```toml
|
|
791
|
+
[rpc]
|
|
792
|
+
enabled = true # default; false turns rpc off entirely
|
|
793
|
+
max_message_bytes = 1048576 # advertised as rpc-max-message:<n>; at least 256
|
|
794
|
+
max_inflight = 64 # concurrent requests per channel before -32002
|
|
795
|
+
fs_root = "/srv/files" # unset (the default): wsh-fs is not offered
|
|
796
|
+
fs_read_only = false
|
|
797
|
+
fs_max_file_bytes = 67108864
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
Key options apply as they do to other channels: a key restricted to a forced
|
|
801
|
+
command opens no rpc channel, and `wsh-fs` needs the `file-transfer` scope
|
|
802
|
+
(`permit-file-transfer` under `restrict`). `host.info` reports the crate version
|
|
803
|
+
and `hostFingerprint: null` (the Rust server has no host key). The JS client
|
|
804
|
+
needs nothing new: it reads `rpc` from `ServerHello`, and its file helpers prefer
|
|
805
|
+
`wsh-fs` automatically once the server offers it.
|
|
806
|
+
|
|
736
807
|
## Attach and Resume
|
|
737
808
|
|
|
738
809
|
Opening a PTY/exec session returns a session-scoped credential alongside the channel:
|
|
@@ -865,7 +936,7 @@ so options the platform gains later need no change here.
|
|
|
865
936
|
|
|
866
937
|
| Export | Description |
|
|
867
938
|
|--------|-------------|
|
|
868
|
-
| `MSG` |
|
|
939
|
+
| `MSG` | 98 message type constants (hex opcodes) |
|
|
869
940
|
| `CHANNEL_KIND` | Channel types: `pty`, `exec`, `meta`, `file`, `tcp`, `udp`, `job` |
|
|
870
941
|
| `AUTH_METHOD` | Auth methods: `pubkey`, `password` |
|
|
871
942
|
| `cborEncode` / `cborDecode` | CBOR codec (maps, arrays, strings, ints, bytes, bools, null, floats) |
|
|
@@ -912,10 +983,10 @@ than left to be found.
|
|
|
912
983
|
| Remove a remote file | `WshClient.fileRemove()` | `file_transfer::remove()`, `wsh sftp`'s `rm` | **Wire-unified**: `FileOp`/`FileResult` (`op: "remove"`) -- refused today by every `wsh-server` release ("not yet implemented"), the same refusal on both sides |
|
|
913
984
|
| Install an authorized key | `WshClient.addAuthorizedKey()` | `wsh_client::WshClient::add_authorized_key()`, `wsh copy-id` | **Wire-unified** (wsh #59): `AuthorizedKeyAdd`/`AuthorizedKeyResult`, replacing a Rust-CLI-only shell command with a message every implementation can send |
|
|
914
985
|
| Host identity / TOFU | `WshKnownHosts` (localStorage-backed) | `KnownHosts`/`HostStatus` (`~/.wsh/known_hosts`-backed) | **Record unified, policy is not** (wsh #59): both pin `ServerHello.host_fingerprint`, which `@johnhenry/wsh/server` populates (with a proof of possession, [Host key](#host-key-fingerprint--tofu)) but no Rust `wsh-server` release does yet (see [Security](#security-model)). *When* to trust, prompt, or persist is deliberately left per-implementation -- a browser and a CLI have different UX for "first time seeing this host" |
|
|
915
|
-
| Typed RPC channels (`openRpc`, `rpc` sessions; `wsh-host`, `wsh-fs`, `mcp`) | `WshClient.openRpc()`, `RpcChannel` |
|
|
986
|
+
| Typed RPC channels (`openRpc`, `rpc` sessions; `wsh-host`, `wsh-fs`, `mcp`) | `WshClient.openRpc()`, `RpcChannel` | Rust `wsh-server` (`rust-v0.4.0`+): `wsh-host`, and `wsh-fs` when `[rpc] fs_root` is set; no client yet in `wsh-client` | **No wire change**: `Open { kind: 'rpc', command: <protocol> }` plus `rpc*` `ServerHello` features, negotiated so it fails fast elsewhere; the Rust server serves it on WebSocket/QMux only |
|
|
916
987
|
| Interactive shell UI | none -- this SDK is a protocol client, not a terminal emulator; pair with xterm.js/ghostty-web | `wsh connect`, `wsh sftp` (line-oriented REPL) | **Deliberately not unified** -- a browser embeds a terminal widget the host page owns; a CLI process owns its own TTY |
|
|
917
988
|
| Attach / resume | `attachSession()`, `resumeSession()`, `WshSession.seq` | `attach_session()`, `resume_session()` | **Wire-unified** (`Attach`/`Resume`/`Presence`); `seq` = cumulative output bytes. `@johnhenry/wsh/server` honours `last_seq` (bounded ring, gap errors) and answers Presence with `channel_id`/`seq`; the Rust `wsh-server` does the same (`rust-v0.2.0`+; fixed 256 KiB ring) |
|
|
918
|
-
| Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()`; `@johnhenry/wsh/server`: `relay` option and `createReverseHost()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service. The Node relay is default-deny and bridges one operator per peer (`busy:` reasons say so); E2E and bridged feature gates need `rust-v0.3.0`+ on the Rust side |
|
|
989
|
+
| Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()`; `@johnhenry/wsh/server`: `relay` option and `createReverseHost()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service. The Node relay is default-deny and bridges one operator per peer unless `maxOperatorsPerPeer` is raised (`busy:` reasons say so); E2E and bridged feature gates need `rust-v0.3.0`+ on the Rust side; several operators per peer and the signed `KeyExchange` are JS-relay only for now |
|
|
919
990
|
| Post-quantum E2E (experimental) | `initiateE2E()` (WebCrypto ML-KEM-768 or `@noble/post-quantum` fallback) | `E2eKeyExchange` (`ml-kem` crate) | **Wire-unified** algorithm and transcript; key material backends differ by platform necessity |
|
|
920
991
|
| Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast v2) | none | **JS-only** -- no current Rust consumer needs playback; the format itself (asciicast v2) is not proprietary if one is added later |
|
|
921
992
|
| Self-signed cert pinning | `serverCertificateHashes` (WebTransport option) | N/A -- Rust dials a cert its own TLS stack already trusts, or `--generate-cert`'s self-signed cert out of band | **Deliberately not unified** -- this is a browser-specific WebTransport API shape, not a wire-protocol concept |
|
|
@@ -987,11 +1058,12 @@ out explicitly below rather than left to be discovered.
|
|
|
987
1058
|
`SHA-256("wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding)`,
|
|
988
1059
|
so a signature can't be replayed against a different session or relabeled
|
|
989
1060
|
to a different username.
|
|
990
|
-
- **E2E through a relay is opt-in
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1061
|
+
- **E2E through a relay is opt-in.** The relay carries `KeyExchange` /
|
|
1062
|
+
`EncryptedFrame` between a bridged operator and peer without being able to
|
|
1063
|
+
read the sealed frames. `KeyExchange` is unauthenticated unless the peer signs
|
|
1064
|
+
it and the operator asks for that (`initiateE2E(..., { verifyPeer })`; a
|
|
1065
|
+
`createReverseHost` signs by default): without `verifyPeer` a relay that
|
|
1066
|
+
substitutes keys is not detected (see the [relay section](#relay--reverse-mode)).
|
|
995
1067
|
- **Signed peer records.** Reverse-mode registration is self-signed by the
|
|
996
1068
|
peer's identity key (the libp2p RFC 0002/0003 pattern), in a signing
|
|
997
1069
|
domain separate from the auth challenge. `listPeers()` verifies every
|
package/package.json
CHANGED
package/src/client.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
hello, auth as authMsg, open as openMsg, close as closeMsg,
|
|
16
16
|
attach as attachMsg, resume as resumeMsg, ping as pingMsg, pong as pongMsg,
|
|
17
17
|
reverseRegister as reverseRegisterMsg, reverseList as reverseListMsg,
|
|
18
|
-
reverseConnect as reverseConnectMsg,
|
|
18
|
+
reverseConnect as reverseConnectMsg, relayForward as relayForwardMsg,
|
|
19
19
|
mcpDiscover as mcpDiscoverMsg, mcpCall as mcpCallMsg,
|
|
20
20
|
suspendSession as suspendSessionMsg, restartPty as restartPtyMsg,
|
|
21
21
|
metricsRequest as metricsRequestMsg,
|
|
@@ -39,8 +39,9 @@ import {
|
|
|
39
39
|
} from './messages.mjs';
|
|
40
40
|
import { signChallenge, exportPublicKeyRaw, signPeerRecord, verifyPeerRecord, importPublicKeyRaw, fingerprint as computeFingerprint } from './auth.mjs';
|
|
41
41
|
import { generateMlKemKeyPair, mlKemEncapsulate, mlKemDecapsulate } from './mlkem.mjs';
|
|
42
|
+
import { compareBytes, combineHybridSecret, verifyKeyExchangeSignature } from './e2e-exchange.mjs';
|
|
42
43
|
import { WshSession } from './session.mjs';
|
|
43
|
-
import { cborDecode } from './cbor.mjs';
|
|
44
|
+
import { cborDecode, cborEncode } from './cbor.mjs';
|
|
44
45
|
import {
|
|
45
46
|
RpcChannel, RpcError, RPC_ERROR, RPC_FEATURE, RPC_PROTOCOL_NAME_RE, RPC_DEFAULT_MAX_MESSAGE,
|
|
46
47
|
rpcProtocolFeature, parseRpcFeatures,
|
|
@@ -148,44 +149,6 @@ async function verifyPeerInfoRecord(peer) {
|
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
/**
|
|
152
|
-
* Lexicographic byte comparison, used by `initiateE2E`'s hybrid mode to
|
|
153
|
-
* deterministically assign the ML-KEM "encapsulator" role without an
|
|
154
|
-
* extra round trip: both sides already have both ephemeral X25519
|
|
155
|
-
* public keys after round 1, so whichever side's own key sorts lower
|
|
156
|
-
* encapsulates.
|
|
157
|
-
* @returns {number} <0 if a<b, >0 if a>b, 0 if equal
|
|
158
|
-
*/
|
|
159
|
-
function compareBytes(a, b) {
|
|
160
|
-
const len = Math.min(a.length, b.length);
|
|
161
|
-
for (let i = 0; i < len; i++) {
|
|
162
|
-
if (a[i] !== b[i]) return a[i] - b[i];
|
|
163
|
-
}
|
|
164
|
-
return a.length - b.length;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Combine the classical (X25519 ECDH) and post-quantum (ML-KEM-768) key
|
|
169
|
-
* exchange outputs into one AES-256-GCM key via HKDF-SHA256, so the
|
|
170
|
-
* final key is only as weak as the *stronger* of the two if either
|
|
171
|
-
* primitive is ever broken.
|
|
172
|
-
* @param {Uint8Array} x25519Bits - 32-byte ECDH shared secret
|
|
173
|
-
* @param {Uint8Array} kemSharedSecret - 32-byte ML-KEM-768 shared secret
|
|
174
|
-
* @returns {Promise<Uint8Array>} 32 bytes of combined key material
|
|
175
|
-
*/
|
|
176
|
-
async function combineHybridSecret(x25519Bits, kemSharedSecret) {
|
|
177
|
-
const ikm = new Uint8Array(x25519Bits.length + kemSharedSecret.length);
|
|
178
|
-
ikm.set(x25519Bits, 0);
|
|
179
|
-
ikm.set(kemSharedSecret, x25519Bits.length);
|
|
180
|
-
const hkdfKey = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
|
181
|
-
const bits = await crypto.subtle.deriveBits(
|
|
182
|
-
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode('wsh-hybrid-e2e-v1') },
|
|
183
|
-
hkdfKey,
|
|
184
|
-
256
|
|
185
|
-
);
|
|
186
|
-
return new Uint8Array(bits);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
152
|
/**
|
|
190
153
|
* `expectHostKey` accepts a hex fingerprint, a raw 32-byte key, or an
|
|
191
154
|
* `ssh-ed25519 AAAA...` line; all normalize to the hex fingerprint.
|
|
@@ -1259,10 +1222,19 @@ export class WshClient {
|
|
|
1259
1222
|
* the transport internals directly.
|
|
1260
1223
|
*
|
|
1261
1224
|
* @param {object} msg
|
|
1225
|
+
* @param {{ to?: string }} [opts] - `to`: the operator this is for, when this peer serves several
|
|
1226
|
+
* (`relay-multi-operator`, wsh #89): the message is sent as a `RelayForward` addressed to that
|
|
1227
|
+
* fingerprint. Leave it out for a peer's only operator.
|
|
1262
1228
|
* @returns {Promise<void>}
|
|
1263
1229
|
*/
|
|
1264
|
-
async sendRelayControl(msg) {
|
|
1230
|
+
async sendRelayControl(msg, { to } = {}) {
|
|
1265
1231
|
this.#assertAuthenticated('sendRelayControl');
|
|
1232
|
+
if (to !== undefined) {
|
|
1233
|
+
// A peer serving several operators addresses each message: the relay routes the wrapper to `to`
|
|
1234
|
+
// and re-wraps it with this peer's fingerprint (from_fingerprint here is overwritten, not trusted).
|
|
1235
|
+
if (typeof to !== 'string' || !to) throw new TypeError('sendRelayControl: to must be an operator fingerprint');
|
|
1236
|
+
msg = relayForwardMsg({ fromFingerprint: '', toFingerprint: to, inner: cborEncode(msg) });
|
|
1237
|
+
}
|
|
1266
1238
|
await this.#transport.sendControl(msg);
|
|
1267
1239
|
}
|
|
1268
1240
|
|
|
@@ -1784,12 +1756,19 @@ export class WshClient {
|
|
|
1784
1756
|
* agility, not a hard cutover; check the returned `hybrid` flag to see
|
|
1785
1757
|
* which actually happened.
|
|
1786
1758
|
*
|
|
1759
|
+
* `KeyExchange` carries only ephemeral keys, so on its own it does not stop a relay that substitutes them.
|
|
1760
|
+
* Pass `verifyPeer` -- the peer's long-term Ed25519 public key, e.g. the `public_key` of the peer record you
|
|
1761
|
+
* listed -- to require that the peer signed its half (a reverse host does: `e2e-sign` in its features); the
|
|
1762
|
+
* exchange is refused with `code: 'E2E_PEER_UNAUTHENTICATED'` if the signature is missing or does not verify.
|
|
1763
|
+
*
|
|
1787
1764
|
* @param {string} sessionId - Session ID
|
|
1788
1765
|
* @param {string} [algorithm='X25519'] - 'X25519' or 'X25519+ML-KEM-768'
|
|
1789
1766
|
* @param {number} [timeout=10000]
|
|
1790
|
-
* @
|
|
1767
|
+
* @param {object} [opts]
|
|
1768
|
+
* @param {Uint8Array | CryptoKey} [opts.verifyPeer] - the peer's Ed25519 public key (raw 32 bytes or imported)
|
|
1769
|
+
* @returns {Promise<{sharedSecret: CryptoKey, peerPublicKey: Uint8Array, hybrid: boolean, peerAuthenticated: boolean}>}
|
|
1791
1770
|
*/
|
|
1792
|
-
async initiateE2E(sessionId, algorithm = 'X25519', timeout = DEFAULT_OPEN_TIMEOUT) {
|
|
1771
|
+
async initiateE2E(sessionId, algorithm = 'X25519', timeout = DEFAULT_OPEN_TIMEOUT, { verifyPeer } = {}) {
|
|
1793
1772
|
this.#assertAuthenticated('initiateE2E');
|
|
1794
1773
|
const wantHybrid = algorithm === 'X25519+ML-KEM-768';
|
|
1795
1774
|
|
|
@@ -1835,6 +1814,22 @@ export class WshClient {
|
|
|
1835
1814
|
|
|
1836
1815
|
const peerMsg = await peerMsgPromise;
|
|
1837
1816
|
|
|
1817
|
+
let peerAuthenticated = false;
|
|
1818
|
+
if (verifyPeer !== undefined) {
|
|
1819
|
+
const ok = await verifyKeyExchangeSignature(verifyPeer, peerMsg.signature, {
|
|
1820
|
+
sessionId, algorithm, initiatorKey: localPub, responderKey: new Uint8Array(peerMsg.public_key ?? []),
|
|
1821
|
+
});
|
|
1822
|
+
if (!ok) {
|
|
1823
|
+
throw Object.assign(
|
|
1824
|
+
new Error(peerMsg.signature === undefined
|
|
1825
|
+
? 'initiateE2E: the peer did not sign its key exchange (verifyPeer was given)'
|
|
1826
|
+
: 'initiateE2E: the peer\'s key exchange signature does not verify against verifyPeer'),
|
|
1827
|
+
{ code: 'E2E_PEER_UNAUTHENTICATED' },
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
peerAuthenticated = true;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1838
1833
|
// Round 2 has the same shape as round 1, so it needs the same treatment.
|
|
1839
1834
|
// Whether we encapsulate or decapsulate is decided entirely by data
|
|
1840
1835
|
// already in hand, so decide it now and register the ciphertext waiter
|
|
@@ -1899,7 +1894,7 @@ export class WshClient {
|
|
|
1899
1894
|
['encrypt', 'decrypt']
|
|
1900
1895
|
);
|
|
1901
1896
|
|
|
1902
|
-
return { sharedSecret, peerPublicKey: new Uint8Array(peerMsg.public_key), hybrid: hybridActive };
|
|
1897
|
+
return { sharedSecret, peerPublicKey: new Uint8Array(peerMsg.public_key), hybrid: hybridActive, peerAuthenticated };
|
|
1903
1898
|
}
|
|
1904
1899
|
|
|
1905
1900
|
// ── Structured File Channel ───────────────────────────────────────
|
|
@@ -2476,7 +2471,7 @@ export class WshClient {
|
|
|
2476
2471
|
* @param {object} msg
|
|
2477
2472
|
* @private
|
|
2478
2473
|
*/
|
|
2479
|
-
#handleControl(msg) {
|
|
2474
|
+
#handleControl(msg, from) {
|
|
2480
2475
|
const type = msg.type;
|
|
2481
2476
|
|
|
2482
2477
|
// Unwrap RelayForward: only deliver the inner message if it came from a
|
|
@@ -2502,7 +2497,7 @@ export class WshClient {
|
|
|
2502
2497
|
console.warn('[wsh:client] dropping RelayForward wrapping a non-forwardable type:', inner.type);
|
|
2503
2498
|
return;
|
|
2504
2499
|
}
|
|
2505
|
-
this.#handleControl(inner);
|
|
2500
|
+
this.#handleControl(inner, msg.from_fingerprint);
|
|
2506
2501
|
return;
|
|
2507
2502
|
}
|
|
2508
2503
|
|
|
@@ -2668,7 +2663,7 @@ export class WshClient {
|
|
|
2668
2663
|
// sessions share the same top-level API.
|
|
2669
2664
|
if (this.onRelayMessage && this._isRelayForwardable(type)) {
|
|
2670
2665
|
try {
|
|
2671
|
-
this.onRelayMessage(msg);
|
|
2666
|
+
this.onRelayMessage(msg, from);
|
|
2672
2667
|
} catch (err) {
|
|
2673
2668
|
console.error('[wsh:client] onRelayMessage handler error:', err);
|
|
2674
2669
|
}
|
|
@@ -2701,6 +2696,15 @@ export class WshClient {
|
|
|
2701
2696
|
this.#transport?.sendControl(pingMsg({ id: ++this.#pingId })).catch(() => {});
|
|
2702
2697
|
break;
|
|
2703
2698
|
|
|
2699
|
+
case MSG.REVERSE_CLOSE:
|
|
2700
|
+
// The relay telling a peer that serves several operators that one of them left (wsh #89).
|
|
2701
|
+
try {
|
|
2702
|
+
this.onReverseClose?.(msg);
|
|
2703
|
+
} catch (err) {
|
|
2704
|
+
console.error('[wsh:client] onReverseClose handler error:', err);
|
|
2705
|
+
}
|
|
2706
|
+
break;
|
|
2707
|
+
|
|
2704
2708
|
case MSG.REVERSE_CONNECT:
|
|
2705
2709
|
try {
|
|
2706
2710
|
this.onReverseConnect?.(msg);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key-exchange half of wsh's opt-in end-to-end encryption that is not the
|
|
3
|
+
* initiating client: the RESPONDER a host runs when an operator sends it a
|
|
4
|
+
* `KeyExchange` (wsh #90), plus the pieces both sides share -- the hybrid
|
|
5
|
+
* secret combiner, the byte comparison that picks the ML-KEM encapsulator, and
|
|
6
|
+
* the signed transcript that lets a host authenticate its half.
|
|
7
|
+
*
|
|
8
|
+
* `WshClient.initiateE2E()` is the initiator. The two sides derive the same
|
|
9
|
+
* AES-256-GCM key (see `e2e-frame.mjs` for how it is used to seal frames).
|
|
10
|
+
*
|
|
11
|
+
* ── Authenticating the responder ────────────────────────────────────
|
|
12
|
+
* `KeyExchange` carries only ephemeral public keys, so by itself it protects
|
|
13
|
+
* against a relay that observes, not one that substitutes keys. A responder
|
|
14
|
+
* given a signing key therefore signs, with its long-term Ed25519 identity, a
|
|
15
|
+
* transcript binding the label, the session id, the algorithm, and BOTH
|
|
16
|
+
* ephemeral X25519 keys (initiator's first). The initiator verifies it against
|
|
17
|
+
* the host key it meant to reach (`initiateE2E(..., { verifyPeer })`): a relay
|
|
18
|
+
* that swaps either ephemeral key cannot produce a matching signature.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { sign as signBytes, verify as verifyBytes, importPublicKeyRaw } from './auth.mjs';
|
|
22
|
+
import { keyExchange } from './messages.gen.mjs';
|
|
23
|
+
import { generateMlKemKeyPair, mlKemEncapsulate, mlKemDecapsulate, MLKEM768_PUBLIC_KEY_LENGTH } from './mlkem.mjs';
|
|
24
|
+
|
|
25
|
+
export const E2E_ALGORITHM_CLASSICAL = 'X25519';
|
|
26
|
+
export const E2E_ALGORITHM_HYBRID = 'X25519+ML-KEM-768';
|
|
27
|
+
/** Host feature (in `ReverseAccept.features`): this host answers `KeyExchange` and seals/opens `EncryptedFrame`s. */
|
|
28
|
+
export const E2E_FEATURE = 'e2e';
|
|
29
|
+
/** Host feature: the host signs its `KeyExchange` reply, so the operator can authenticate it. */
|
|
30
|
+
export const E2E_SIGN_FEATURE = 'e2e-sign';
|
|
31
|
+
|
|
32
|
+
const LABEL = new TextEncoder().encode('wsh-e2e-keyexchange-v1\0');
|
|
33
|
+
const enc = new TextEncoder();
|
|
34
|
+
const MAX_PENDING = 8;
|
|
35
|
+
const PENDING_TTL_MS = 15_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Lexicographic byte comparison, used by hybrid mode to assign the ML-KEM
|
|
39
|
+
* "encapsulator" role without an extra round trip.
|
|
40
|
+
* @returns {number} <0 if a<b, >0 if a>b, 0 if equal
|
|
41
|
+
*/
|
|
42
|
+
export function compareBytes(a, b) {
|
|
43
|
+
const len = Math.min(a.length, b.length);
|
|
44
|
+
for (let i = 0; i < len; i++) {
|
|
45
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
46
|
+
}
|
|
47
|
+
return a.length - b.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Combine the classical (X25519 ECDH) and post-quantum (ML-KEM-768) outputs
|
|
52
|
+
* into one AES-256-GCM key via HKDF-SHA256, so the final key is only as weak
|
|
53
|
+
* as the *stronger* of the two.
|
|
54
|
+
* @param {Uint8Array} x25519Bits - 32-byte ECDH shared secret
|
|
55
|
+
* @param {Uint8Array} kemSharedSecret - 32-byte ML-KEM-768 shared secret
|
|
56
|
+
* @returns {Promise<Uint8Array>} 32 bytes of combined key material
|
|
57
|
+
*/
|
|
58
|
+
export async function combineHybridSecret(x25519Bits, kemSharedSecret) {
|
|
59
|
+
const ikm = new Uint8Array(x25519Bits.length + kemSharedSecret.length);
|
|
60
|
+
ikm.set(x25519Bits, 0);
|
|
61
|
+
ikm.set(kemSharedSecret, x25519Bits.length);
|
|
62
|
+
const hkdfKey = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
|
63
|
+
const bits = await crypto.subtle.deriveBits(
|
|
64
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: enc.encode('wsh-hybrid-e2e-v1') },
|
|
65
|
+
hkdfKey,
|
|
66
|
+
256
|
|
67
|
+
);
|
|
68
|
+
return new Uint8Array(bits);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const lp = (bytes) => {
|
|
72
|
+
const out = new Uint8Array(4 + bytes.length);
|
|
73
|
+
new DataView(out.buffer).setUint32(0, bytes.length);
|
|
74
|
+
out.set(bytes, 4);
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The bytes a responder signs: label, session id, the INITIATOR's algorithm string, then the initiator's and
|
|
80
|
+
* responder's ephemeral X25519 public keys, each length-prefixed.
|
|
81
|
+
* @param {{ sessionId: string, algorithm: string, initiatorKey: Uint8Array, responderKey: Uint8Array }} t
|
|
82
|
+
* @returns {Uint8Array}
|
|
83
|
+
*/
|
|
84
|
+
export function keyExchangeTranscript({ sessionId, algorithm, initiatorKey, responderKey }) {
|
|
85
|
+
const parts = [LABEL, lp(enc.encode(sessionId)), lp(enc.encode(algorithm)), lp(initiatorKey), lp(responderKey)];
|
|
86
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
87
|
+
let off = 0;
|
|
88
|
+
for (const p of parts) { out.set(p, off); off += p.length; }
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Verify a responder's `KeyExchange.signature`.
|
|
94
|
+
* @param {Uint8Array | CryptoKey} peerIdentity - the host's raw 32-byte Ed25519 public key (or an imported one)
|
|
95
|
+
* @param {Uint8Array | undefined} signature
|
|
96
|
+
* @param {Parameters<typeof keyExchangeTranscript>[0]} transcript
|
|
97
|
+
* @returns {Promise<boolean>}
|
|
98
|
+
*/
|
|
99
|
+
export async function verifyKeyExchangeSignature(peerIdentity, signature, transcript) {
|
|
100
|
+
if (!(signature instanceof Uint8Array) || signature.length !== 64) return false;
|
|
101
|
+
try {
|
|
102
|
+
const key = peerIdentity instanceof Uint8Array ? await importPublicKeyRaw(peerIdentity) : peerIdentity;
|
|
103
|
+
return await verifyBytes(key, signature, keyExchangeTranscript(transcript));
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The responder side of `KeyExchange`, for a host. One instance per connection.
|
|
111
|
+
*
|
|
112
|
+
* `respond(msg)` takes each inbound `KeyExchange` and returns the messages to send back and, once the exchange is
|
|
113
|
+
* complete, the derived key:
|
|
114
|
+
* - a round-1 message (has `public_key`): generate an ephemeral key, answer, and -- classical, or hybrid with the
|
|
115
|
+
* host as encapsulator -- derive straight away; as decapsulator it waits for the round-2 ciphertext;
|
|
116
|
+
* - a round-2 message (only `kem_ciphertext`): finish a pending hybrid exchange.
|
|
117
|
+
*/
|
|
118
|
+
export class E2EResponder {
|
|
119
|
+
#sign;
|
|
120
|
+
#hybrid;
|
|
121
|
+
/** @type {Map<string, { sharedBits: Uint8Array, localKem: { secretKeySeed: Uint8Array }, timer: any }>} */
|
|
122
|
+
#pending = new Map();
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {object} [opts]
|
|
126
|
+
* @param {CryptoKey | null} [opts.signKey] - long-term Ed25519 private key; given, the reply is signed
|
|
127
|
+
* @param {boolean} [opts.hybrid=true] - offer X25519+ML-KEM-768 when the initiator asks for it
|
|
128
|
+
*/
|
|
129
|
+
constructor({ signKey = null, hybrid = true } = {}) {
|
|
130
|
+
this.#sign = signKey;
|
|
131
|
+
this.#hybrid = hybrid;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get signs() { return !!this.#sign; }
|
|
135
|
+
|
|
136
|
+
/** Forget unfinished exchanges (the connection ended). */
|
|
137
|
+
close() {
|
|
138
|
+
for (const p of this.#pending.values()) clearTimeout(p.timer);
|
|
139
|
+
this.#pending.clear();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `emit(reply)` (optional) is called with each reply as soon as it is ready -- the round-1 reply BEFORE the key
|
|
144
|
+
* derivation and ML-KEM encapsulation, so the peer has it (and has registered for the ciphertext) well before the
|
|
145
|
+
* ciphertext follows. Without `emit` the replies are returned in `replies`.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} msg - a KeyExchange
|
|
148
|
+
* @param {(reply: object) => Promise<void> | void} [emit]
|
|
149
|
+
* @returns {Promise<{ replies: object[], key?: CryptoKey, sessionId: string, hybrid?: boolean }>}
|
|
150
|
+
*/
|
|
151
|
+
async respond(msg, emit) {
|
|
152
|
+
const sessionId = msg.session_id;
|
|
153
|
+
if (typeof sessionId !== 'string' || !sessionId) throw new Error('KeyExchange without a session_id');
|
|
154
|
+
if (msg.public_key === undefined && msg.kem_ciphertext !== undefined) return this.#finish(msg, sessionId);
|
|
155
|
+
return this.#begin(msg, sessionId, emit);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async #begin(msg, sessionId, emit) {
|
|
159
|
+
const replies = [];
|
|
160
|
+
const out = async (reply) => { if (emit) await emit(reply); else replies.push(reply); };
|
|
161
|
+
const algorithm = msg.algorithm;
|
|
162
|
+
if (algorithm !== E2E_ALGORITHM_CLASSICAL && algorithm !== E2E_ALGORITHM_HYBRID) throw new Error(`unsupported E2E algorithm ${JSON.stringify(algorithm)}`);
|
|
163
|
+
const peerPub = msg.public_key;
|
|
164
|
+
if (!(peerPub instanceof Uint8Array) || peerPub.length !== 32) throw new Error('KeyExchange public_key must be a 32-byte X25519 key');
|
|
165
|
+
const peerKem = msg.kem_public_key;
|
|
166
|
+
const wantHybrid = algorithm === E2E_ALGORITHM_HYBRID && this.#hybrid
|
|
167
|
+
&& peerKem instanceof Uint8Array && peerKem.length === MLKEM768_PUBLIC_KEY_LENGTH;
|
|
168
|
+
|
|
169
|
+
// A fresh exchange for this session replaces an unfinished one.
|
|
170
|
+
this.#drop(sessionId);
|
|
171
|
+
|
|
172
|
+
const ephemeral = await crypto.subtle.generateKey({ name: 'X25519' }, false, ['deriveBits']);
|
|
173
|
+
const localPub = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));
|
|
174
|
+
let localKem = null;
|
|
175
|
+
if (wantHybrid) {
|
|
176
|
+
try { localKem = await generateMlKemKeyPair(); } catch { localKem = null; } // no ML-KEM backend here: fall back to classical
|
|
177
|
+
}
|
|
178
|
+
const hybrid = !!localKem;
|
|
179
|
+
|
|
180
|
+
const reply = {
|
|
181
|
+
algorithm: hybrid ? E2E_ALGORITHM_HYBRID : E2E_ALGORITHM_CLASSICAL,
|
|
182
|
+
publicKey: localPub,
|
|
183
|
+
sessionId,
|
|
184
|
+
kemPublicKey: localKem?.publicKey,
|
|
185
|
+
};
|
|
186
|
+
if (this.#sign) {
|
|
187
|
+
reply.signature = await signBytes(this.#sign, keyExchangeTranscript({ sessionId, algorithm, initiatorKey: peerPub, responderKey: localPub }));
|
|
188
|
+
}
|
|
189
|
+
await out(keyExchange(reply));
|
|
190
|
+
|
|
191
|
+
const peerKey = await crypto.subtle.importKey('raw', peerPub, { name: 'X25519' }, false, []);
|
|
192
|
+
const sharedBits = new Uint8Array(await crypto.subtle.deriveBits({ name: 'X25519', public: peerKey }, ephemeral.privateKey, 256));
|
|
193
|
+
|
|
194
|
+
if (!hybrid) return { replies, key: await toAesKey(sharedBits), sessionId, hybrid: false };
|
|
195
|
+
|
|
196
|
+
if (compareBytes(localPub, peerPub) < 0) {
|
|
197
|
+
// The lower key encapsulates against the other side's ML-KEM key.
|
|
198
|
+
const { ciphertext, sharedSecret } = await mlKemEncapsulate(peerKem);
|
|
199
|
+
await new Promise((resolve) => setTimeout(resolve, 0)); // let the round-1 reply land before the ciphertext follows
|
|
200
|
+
await out(keyExchange({ algorithm: E2E_ALGORITHM_HYBRID, sessionId, kemCiphertext: ciphertext }));
|
|
201
|
+
return { replies, key: await toAesKey(await combineHybridSecret(sharedBits, sharedSecret)), sessionId, hybrid: true };
|
|
202
|
+
}
|
|
203
|
+
// The higher key decapsulates: wait for the peer's ciphertext.
|
|
204
|
+
while (this.#pending.size >= MAX_PENDING) this.#drop(this.#pending.keys().next().value);
|
|
205
|
+
const timer = setTimeout(() => this.#drop(sessionId), PENDING_TTL_MS);
|
|
206
|
+
timer.unref?.();
|
|
207
|
+
this.#pending.set(sessionId, { sharedBits, localKem, timer });
|
|
208
|
+
return { replies, sessionId, hybrid: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async #finish(msg, sessionId) {
|
|
212
|
+
const pending = this.#pending.get(sessionId);
|
|
213
|
+
if (!pending) throw new Error('unexpected KeyExchange ciphertext (no hybrid exchange in progress for this session)');
|
|
214
|
+
this.#drop(sessionId);
|
|
215
|
+
const kemSecret = await mlKemDecapsulate(pending.localKem.secretKeySeed, new Uint8Array(msg.kem_ciphertext));
|
|
216
|
+
return { replies: [], key: await toAesKey(await combineHybridSecret(pending.sharedBits, kemSecret)), sessionId, hybrid: true };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#drop(sessionId) {
|
|
220
|
+
const p = this.#pending.get(sessionId);
|
|
221
|
+
if (p) { clearTimeout(p.timer); this.#pending.delete(sessionId); }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function toAesKey(bits) {
|
|
226
|
+
return crypto.subtle.importKey('raw', bits, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
227
|
+
}
|