@gtc6244/immediacy-daemon 0.2.1 → 0.2.3
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/dist/src/crypto.d.ts +18 -39
- package/dist/src/crypto.js +33 -152
- package/dist/src/crypto.js.map +1 -1
- package/dist/src/devices.d.ts +46 -0
- package/dist/src/devices.js +114 -0
- package/dist/src/devices.js.map +1 -0
- package/dist/src/envelope.d.ts +3 -3
- package/dist/src/envelope.js +3 -3
- package/dist/src/forward.d.ts +4 -4
- package/dist/src/forward.js +1 -1
- package/dist/src/forward.js.map +1 -1
- package/dist/src/framing.d.ts +76 -0
- package/dist/src/framing.js +133 -0
- package/dist/src/framing.js.map +1 -0
- package/dist/src/hub.d.ts +29 -9
- package/dist/src/hub.js +20 -5
- package/dist/src/hub.js.map +1 -1
- package/dist/src/index.d.ts +3 -3
- package/dist/src/index.js +79 -17
- package/dist/src/index.js.map +1 -1
- package/dist/src/lan.d.ts +103 -0
- package/dist/src/lan.js +182 -0
- package/dist/src/lan.js.map +1 -0
- package/dist/src/pairing.d.ts +69 -0
- package/dist/src/pairing.js +125 -0
- package/dist/src/pairing.js.map +1 -0
- package/dist/src/protocol.d.ts +66 -23
- package/dist/src/protocol.js +4 -1
- package/dist/src/protocol.js.map +1 -1
- package/dist/src/push.d.ts +104 -0
- package/dist/src/push.js +339 -0
- package/dist/src/push.js.map +1 -0
- package/dist/src/relay-transport.d.ts +62 -0
- package/dist/src/relay-transport.js +180 -0
- package/dist/src/relay-transport.js.map +1 -0
- package/dist/src/relay-wire.d.ts +25 -0
- package/dist/src/relay-wire.js +37 -0
- package/dist/src/relay-wire.js.map +1 -0
- package/dist/src/rendezvous.d.ts +57 -0
- package/dist/src/rendezvous.js +152 -0
- package/dist/src/rendezvous.js.map +1 -0
- package/dist/src/server.d.ts +19 -7
- package/dist/src/server.js +180 -63
- package/dist/src/server.js.map +1 -1
- package/dist/src/session-socket.d.ts +34 -0
- package/dist/src/session-socket.js +84 -0
- package/dist/src/session-socket.js.map +1 -0
- package/dist/src/session.d.ts +151 -0
- package/dist/src/session.js +241 -0
- package/dist/src/session.js.map +1 -0
- package/dist/src/tcp-transport.d.ts +37 -0
- package/dist/src/tcp-transport.js +106 -0
- package/dist/src/tcp-transport.js.map +1 -0
- package/dist/src/telemetry.d.ts +69 -0
- package/dist/src/telemetry.js +64 -0
- package/dist/src/telemetry.js.map +1 -0
- package/dist/src/transport.d.ts +75 -0
- package/dist/src/transport.js +136 -0
- package/dist/src/transport.js.map +1 -0
- package/dist/src/workspace.d.ts +20 -5
- package/dist/src/workspace.js +19 -1
- package/dist/src/workspace.js.map +1 -1
- package/package.json +17 -4
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionSocket — presents a LAN/relay session (a frame `Mux` + a handshake
|
|
3
|
+
* `SecureChannel`) to the server dispatch as a `SocketLike`, so one `handleFrame`
|
|
4
|
+
* serves every transport (docs/WEBRTC_MIGRATION.md Phase 4 wiring).
|
|
5
|
+
*
|
|
6
|
+
* App traffic rides the `rpc` channel: each envelope JSON is sealed with the
|
|
7
|
+
* session key and sent as one Mux message; inbound frames are opened back to a
|
|
8
|
+
* JSON string and handed to the dispatch. The Hub treats this socket with the
|
|
9
|
+
* cleartext codec (plain JSON in/out) — the sealing happens here, at the
|
|
10
|
+
* transport boundary, exactly like the WS `sessionCodec` does for the live path.
|
|
11
|
+
*/
|
|
12
|
+
import { Channel } from "./framing.js";
|
|
13
|
+
const OPEN = 1;
|
|
14
|
+
const CLOSED = 3;
|
|
15
|
+
export class SessionSocket {
|
|
16
|
+
mux;
|
|
17
|
+
channel;
|
|
18
|
+
closeTransport;
|
|
19
|
+
readyState = OPEN;
|
|
20
|
+
onMsg = null;
|
|
21
|
+
onCloseCb = null;
|
|
22
|
+
constructor(mux, channel, closeTransport) {
|
|
23
|
+
this.mux = mux;
|
|
24
|
+
this.channel = channel;
|
|
25
|
+
this.closeTransport = closeTransport;
|
|
26
|
+
// App traffic on the rpc channel: open the sealed frame → JSON → dispatch.
|
|
27
|
+
// (This overwrites the mux handler the handshake used, which is intended —
|
|
28
|
+
// the handshake is complete by the time a SessionSocket is created.)
|
|
29
|
+
this.mux.onMessage((channelId, message) => {
|
|
30
|
+
if (channelId !== Channel.rpc)
|
|
31
|
+
return;
|
|
32
|
+
let plaintext;
|
|
33
|
+
try {
|
|
34
|
+
plaintext = this.channel.open(message);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return; // tamper/replay — drop, keep the session
|
|
38
|
+
}
|
|
39
|
+
this.onMsg?.(plaintext.toString("utf8"));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Backpressure isn't surfaced per-session here; the Mux paces large sends. */
|
|
43
|
+
get bufferedAmount() {
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
send(data, cb) {
|
|
47
|
+
if (this.readyState !== OPEN) {
|
|
48
|
+
cb?.(new Error("socket not open"));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const sealed = this.channel.seal(Buffer.from(data, "utf8"));
|
|
53
|
+
void this.mux.send(Channel.rpc, sealed);
|
|
54
|
+
cb?.(undefined);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
cb?.(err instanceof Error ? err : new Error(String(err)));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Register the inbound-envelope handler (the server's dispatch). */
|
|
61
|
+
onMessage(cb) {
|
|
62
|
+
this.onMsg = cb;
|
|
63
|
+
}
|
|
64
|
+
/** Register the close handler (the server's teardown). */
|
|
65
|
+
onClose(cb) {
|
|
66
|
+
this.onCloseCb = cb;
|
|
67
|
+
}
|
|
68
|
+
/** Close from our side (tears down the underlying transport). */
|
|
69
|
+
close() {
|
|
70
|
+
if (this.readyState !== OPEN)
|
|
71
|
+
return;
|
|
72
|
+
this.readyState = CLOSED;
|
|
73
|
+
this.closeTransport();
|
|
74
|
+
this.onCloseCb?.();
|
|
75
|
+
}
|
|
76
|
+
/** The peer/transport dropped — mark closed and notify (no re-teardown). */
|
|
77
|
+
handlePeerClose() {
|
|
78
|
+
if (this.readyState !== OPEN)
|
|
79
|
+
return;
|
|
80
|
+
this.readyState = CLOSED;
|
|
81
|
+
this.onCloseCb?.();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=session-socket.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-socket.js","sourceRoot":"","sources":["../../src/session-socket.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAKvC,MAAM,IAAI,GAAG,CAAC,CAAC;AACf,MAAM,MAAM,GAAG,CAAC,CAAC;AAEjB,MAAM,OAAO,aAAa;IAML;IACA;IACA;IAPnB,UAAU,GAAG,IAAI,CAAC;IACV,KAAK,GAAoC,IAAI,CAAC;IAC9C,SAAS,GAAwB,IAAI,CAAC;IAE9C,YACmB,GAAQ,EACR,OAAsB,EACtB,cAA0B;QAF1B,QAAG,GAAH,GAAG,CAAK;QACR,YAAO,GAAP,OAAO,CAAe;QACtB,mBAAc,GAAd,cAAc,CAAY;QAE3C,2EAA2E;QAC3E,2EAA2E;QAC3E,qEAAqE;QACrE,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,SAAS,EAAE,OAAO,EAAE,EAAE;YACxC,IAAI,SAAS,KAAK,OAAO,CAAC,GAAG;gBAAE,OAAO;YACtC,IAAI,SAAiB,CAAC;YACtB,IAAI,CAAC;gBACH,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACzC,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,CAAC,yCAAyC;YACnD,CAAC;YACD,IAAI,CAAC,KAAK,EAAE,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;QAC3C,CAAC,CAAC,CAAC;IACL,CAAC;IAED,+EAA+E;IAC/E,IAAI,cAAc;QAChB,OAAO,CAAC,CAAC;IACX,CAAC;IAED,IAAI,CAAC,IAAY,EAAE,EAA0B;QAC3C,IAAI,IAAI,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;YAC7B,EAAE,EAAE,CAAC,IAAI,KAAK,CAAC,iBAAiB,CAAC,CAAC,CAAC;YACnC,OAAO;QACT,CAAC;QACD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;YAC5D,KAAK,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;YACxC,EAAE,EAAE,CAAC,SAAS,CAAC,CAAC;QAClB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,EAAE,EAAE,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QAC5D,CAAC;IACH,CAAC;IAED,qEAAqE;IACrE,SAAS,CAAC,EAA0B;QAClC,IAAI,CAAC,KAAK,GAAG,EAAE,CAAC;IAClB,CAAC;IAED,0DAA0D;IAC1D,OAAO,CAAC,EAAc;QACpB,IAAI,CAAC,SAAS,GAAG,EAAE,CAAC;IACtB,CAAC;IAED,iEAAiE;IACjE,KAAK;QACH,IAAI,IAAI,CAAC,UAAU,KAAK,IAAI;YAAE,OAAO;QACrC,IAAI,CAAC,UAAU,GAAG,MAAM,CAAC;QACzB,IAAI,CAAC,cAAc,EAAE,CAAC;QACtB,IAAI,CAAC,SAAS,EAAE,EAAE,CAAC;IACrB,CAAC;IAED,4EAA4E;IAC5E,eAAe;QACb,IAAI,IAAI,CAAC,UAAU,KAAK,IAAI;YAAE,OAAO;QACrC,IAAI,CAAC,UAAU,GAAG,MAAM,CAAC;QACzB,IAAI,CAAC,SAAS,EAAE,EAAE,CAAC;IACrB,CAAC;CACF"}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session handshake — the transport-independent §5.3 mutual authentication that
|
|
3
|
+
* replaces Cloudflare Access as the trust gate.
|
|
4
|
+
*
|
|
5
|
+
* This module only produces and consumes the two handshake messages and, on
|
|
6
|
+
* success, hands back a `SecureChannel`. It knows nothing about sockets — the
|
|
7
|
+
* transport (loopback, LAN, relay, WebRTC) pumps the messages. This is what lets
|
|
8
|
+
* every transport reuse one construction verbatim.
|
|
9
|
+
*
|
|
10
|
+
* Shape (initiator = client/device, responder = daemon):
|
|
11
|
+
* 1. Initiator mints a 16-byte random `sessionId`, generates an ephemeral
|
|
12
|
+
* X25519 keypair, and signs the transcript `ephPub || rendezvousId ||
|
|
13
|
+
* sessionId` with its long-term Ed25519 device key. It sends msg1 =
|
|
14
|
+
* { v, sid, eph, sig, dev } (dev = its long-term Ed25519 pubkey).
|
|
15
|
+
* 2. Responder looks up `dev` in its device table (unknown/revoked ⇒ ABORT —
|
|
16
|
+
* this is the revoke-fails-handshake guarantee), verifies the signature
|
|
17
|
+
* against `dev`, generates its own ephemeral X25519 keypair, and signs the
|
|
18
|
+
* same-shaped transcript with the daemon identity key. It replies msg2 =
|
|
19
|
+
* { v, eph, sig } and derives the session keys.
|
|
20
|
+
* 3. Initiator verifies msg2's signature against the *known* daemon Ed25519
|
|
21
|
+
* pubkey it pinned at pair time (mismatch ⇒ visible ABORT, never a silent
|
|
22
|
+
* retry) and derives the same session keys.
|
|
23
|
+
*
|
|
24
|
+
* Both sides then ECDH their ephemeral keys → HKDF-SHA256 → two directional
|
|
25
|
+
* keys. Frames are sealed with AES-256-GCM (12-byte nonce, 16-byte tag).
|
|
26
|
+
* Because the keys are ephemeral per session, nonces are a per-direction counter
|
|
27
|
+
* from 0 — safe, and never a counter with a long-term key.
|
|
28
|
+
*
|
|
29
|
+
* AEAD choice (refines docs/WEBRTC_MIGRATION.md §7): AES-256-GCM is the one AEAD
|
|
30
|
+
* available *natively* on all three clients — Node `crypto` (daemon),
|
|
31
|
+
* CryptoKit `AES.GCM` (macOS), and the pure-Dart `cryptography` package
|
|
32
|
+
* (Flutter) — so no client needs to link libsodium just for the sealer. X25519,
|
|
33
|
+
* Ed25519, and HKDF-SHA256 are likewise standard on all three. The wire
|
|
34
|
+
* construction is: `ecdh = X25519(ephSec, peerEphPub)`, `okm = HKDF-SHA256(salt,
|
|
35
|
+
* ecdh, info=SESSION_HKDF_INFO||sessionId, 64)`, split into two 32-byte keys.
|
|
36
|
+
*/
|
|
37
|
+
declare const PROTOCOL_VERSION: 1;
|
|
38
|
+
/** A handshake step failed. `code` is a stable machine-readable reason. */
|
|
39
|
+
export declare class HandshakeError extends Error {
|
|
40
|
+
code: "unknown_device" | "bad_signature" | "bad_message" | "bad_key";
|
|
41
|
+
constructor(code: "unknown_device" | "bad_signature" | "bad_message" | "bad_key", message: string);
|
|
42
|
+
}
|
|
43
|
+
/** Msg 1: initiator → responder. */
|
|
44
|
+
export interface HandshakeMsg1 {
|
|
45
|
+
v: typeof PROTOCOL_VERSION;
|
|
46
|
+
sid: string;
|
|
47
|
+
eph: string;
|
|
48
|
+
sig: string;
|
|
49
|
+
dev: string;
|
|
50
|
+
code?: string;
|
|
51
|
+
label?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Msg 2: responder → initiator. */
|
|
54
|
+
export interface HandshakeMsg2 {
|
|
55
|
+
v: typeof PROTOCOL_VERSION;
|
|
56
|
+
eph: string;
|
|
57
|
+
sig: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A per-session symmetric channel. Each direction seals with its own key and a
|
|
61
|
+
* counter nonce from 0. Assumes ordered delivery per direction (true for the
|
|
62
|
+
* loopback/LAN/relay reliable channels this Phase-1 handshake feeds); the recv
|
|
63
|
+
* counter enforces strict monotonic order and rejects replays.
|
|
64
|
+
*/
|
|
65
|
+
export declare class SecureChannel {
|
|
66
|
+
private readonly sendKey;
|
|
67
|
+
private readonly recvKey;
|
|
68
|
+
private sendCounter;
|
|
69
|
+
private recvCounter;
|
|
70
|
+
constructor(sendKey: Buffer, recvKey: Buffer);
|
|
71
|
+
private static nonce;
|
|
72
|
+
/**
|
|
73
|
+
* Seal a plaintext frame for the peer. Returns `ciphertext || tag` (the GCM
|
|
74
|
+
* tag appended, matching CryptoKit's `combined` layout minus the nonce, which
|
|
75
|
+
* is the implicit counter). Nonce derives from the send counter.
|
|
76
|
+
*/
|
|
77
|
+
seal(plaintext: Buffer): Buffer;
|
|
78
|
+
/** Open a sealed frame from the peer (next in order). Throws on tamper/replay. */
|
|
79
|
+
open(sealed: Buffer): Buffer;
|
|
80
|
+
}
|
|
81
|
+
/** Options for the initiator (client/device) side. */
|
|
82
|
+
export interface InitiatorOpts {
|
|
83
|
+
/** This device's long-term Ed25519 secret key (64 bytes). */
|
|
84
|
+
deviceSignSecret: Buffer;
|
|
85
|
+
/** This device's long-term Ed25519 public key, base64. */
|
|
86
|
+
devicePublicKeyB64: string;
|
|
87
|
+
/** The daemon's rendezvous id (pinned at pair time). */
|
|
88
|
+
rendezvousId: string;
|
|
89
|
+
/** The daemon's long-term Ed25519 public key, base64 (pinned at pair time). */
|
|
90
|
+
daemonPublicKeyB64: string;
|
|
91
|
+
/**
|
|
92
|
+
* First-pairing only: the single-use pairing code scanned from the daemon's QR.
|
|
93
|
+
* When set (and the device is not yet paired) it is carried in msg1 so the
|
|
94
|
+
* responder can pair the device inline. Omit on a normal (already-paired)
|
|
95
|
+
* handshake.
|
|
96
|
+
*/
|
|
97
|
+
code?: string;
|
|
98
|
+
/** First-pairing only: the human label to record for this device. */
|
|
99
|
+
label?: string;
|
|
100
|
+
}
|
|
101
|
+
/** Options for the responder (daemon) side. */
|
|
102
|
+
export interface ResponderOpts {
|
|
103
|
+
/** The daemon's long-term Ed25519 secret key (64 bytes). */
|
|
104
|
+
daemonSignSecret: Buffer;
|
|
105
|
+
/** The daemon's rendezvous id. */
|
|
106
|
+
rendezvousId: string;
|
|
107
|
+
/**
|
|
108
|
+
* Membership check against the device table. Return true iff `devicePubB64`
|
|
109
|
+
* is a currently-paired (non-revoked) device. A false result normally aborts
|
|
110
|
+
* the handshake — this is the revoke-fails-next-handshake guarantee — unless
|
|
111
|
+
* `redeemCode` accepts a first-pairing code (below).
|
|
112
|
+
*/
|
|
113
|
+
isPaired: (devicePubB64: string) => boolean;
|
|
114
|
+
/**
|
|
115
|
+
* First-pairing hook. Called only when `isPaired` is false and msg1 carries a
|
|
116
|
+
* `code`. Return true iff the code is a valid, unconsumed pairing code — which
|
|
117
|
+
* consumes it and authorizes the handshake to proceed; the caller then records
|
|
118
|
+
* the device (see `acceptHandshake`'s `paired` result). Omit to disable inline
|
|
119
|
+
* pairing on this transport (handshake stays paired-only).
|
|
120
|
+
*/
|
|
121
|
+
redeemCode?: (devicePubB64: string, code: string) => boolean;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Begin the handshake as the initiator. Returns msg1 to send and a `finish`
|
|
125
|
+
* that consumes the responder's msg2 and yields the `SecureChannel`.
|
|
126
|
+
*/
|
|
127
|
+
export declare function startHandshake(opts: InitiatorOpts): {
|
|
128
|
+
msg1: HandshakeMsg1;
|
|
129
|
+
finish: (msg2: HandshakeMsg2) => SecureChannel;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Handle the handshake as the responder. Verifies the initiator, produces msg2
|
|
133
|
+
* to send back, and returns the established `SecureChannel`. Throws
|
|
134
|
+
* `HandshakeError` on any failure (unknown/revoked device, bad signature,
|
|
135
|
+
* malformed message).
|
|
136
|
+
*/
|
|
137
|
+
export declare function acceptHandshake(msg1: HandshakeMsg1, opts: ResponderOpts): {
|
|
138
|
+
msg2: HandshakeMsg2;
|
|
139
|
+
channel: SecureChannel;
|
|
140
|
+
/** The initiator's long-term Ed25519 pubkey (base64). */
|
|
141
|
+
devicePub: string;
|
|
142
|
+
/**
|
|
143
|
+
* True iff this handshake just paired a new device via `redeemCode` — the
|
|
144
|
+
* caller MUST record it (`devices.add(devicePub, label)`); the code is already
|
|
145
|
+
* consumed. False on a normal handshake by an already-paired device.
|
|
146
|
+
*/
|
|
147
|
+
paired: boolean;
|
|
148
|
+
/** The label from msg1, when this was a first pairing (else undefined). */
|
|
149
|
+
label?: string;
|
|
150
|
+
};
|
|
151
|
+
export {};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session handshake — the transport-independent §5.3 mutual authentication that
|
|
3
|
+
* replaces Cloudflare Access as the trust gate.
|
|
4
|
+
*
|
|
5
|
+
* This module only produces and consumes the two handshake messages and, on
|
|
6
|
+
* success, hands back a `SecureChannel`. It knows nothing about sockets — the
|
|
7
|
+
* transport (loopback, LAN, relay, WebRTC) pumps the messages. This is what lets
|
|
8
|
+
* every transport reuse one construction verbatim.
|
|
9
|
+
*
|
|
10
|
+
* Shape (initiator = client/device, responder = daemon):
|
|
11
|
+
* 1. Initiator mints a 16-byte random `sessionId`, generates an ephemeral
|
|
12
|
+
* X25519 keypair, and signs the transcript `ephPub || rendezvousId ||
|
|
13
|
+
* sessionId` with its long-term Ed25519 device key. It sends msg1 =
|
|
14
|
+
* { v, sid, eph, sig, dev } (dev = its long-term Ed25519 pubkey).
|
|
15
|
+
* 2. Responder looks up `dev` in its device table (unknown/revoked ⇒ ABORT —
|
|
16
|
+
* this is the revoke-fails-handshake guarantee), verifies the signature
|
|
17
|
+
* against `dev`, generates its own ephemeral X25519 keypair, and signs the
|
|
18
|
+
* same-shaped transcript with the daemon identity key. It replies msg2 =
|
|
19
|
+
* { v, eph, sig } and derives the session keys.
|
|
20
|
+
* 3. Initiator verifies msg2's signature against the *known* daemon Ed25519
|
|
21
|
+
* pubkey it pinned at pair time (mismatch ⇒ visible ABORT, never a silent
|
|
22
|
+
* retry) and derives the same session keys.
|
|
23
|
+
*
|
|
24
|
+
* Both sides then ECDH their ephemeral keys → HKDF-SHA256 → two directional
|
|
25
|
+
* keys. Frames are sealed with AES-256-GCM (12-byte nonce, 16-byte tag).
|
|
26
|
+
* Because the keys are ephemeral per session, nonces are a per-direction counter
|
|
27
|
+
* from 0 — safe, and never a counter with a long-term key.
|
|
28
|
+
*
|
|
29
|
+
* AEAD choice (refines docs/WEBRTC_MIGRATION.md §7): AES-256-GCM is the one AEAD
|
|
30
|
+
* available *natively* on all three clients — Node `crypto` (daemon),
|
|
31
|
+
* CryptoKit `AES.GCM` (macOS), and the pure-Dart `cryptography` package
|
|
32
|
+
* (Flutter) — so no client needs to link libsodium just for the sealer. X25519,
|
|
33
|
+
* Ed25519, and HKDF-SHA256 are likewise standard on all three. The wire
|
|
34
|
+
* construction is: `ecdh = X25519(ephSec, peerEphPub)`, `okm = HKDF-SHA256(salt,
|
|
35
|
+
* ecdh, info=SESSION_HKDF_INFO||sessionId, 64)`, split into two 32-byte keys.
|
|
36
|
+
*/
|
|
37
|
+
import sodium from "sodium-native";
|
|
38
|
+
import { createCipheriv, createDecipheriv, hkdfSync } from "node:crypto";
|
|
39
|
+
import { signDetached, verifyDetached } from "./rendezvous.js";
|
|
40
|
+
const PROTOCOL_VERSION = 1;
|
|
41
|
+
/** Domain-separation label mixed into the session-key HKDF. */
|
|
42
|
+
const SESSION_HKDF_INFO = "immediacy-session-v1";
|
|
43
|
+
/** Fixed HKDF salt (public, constant); the ECDH secret supplies the entropy. */
|
|
44
|
+
const SESSION_HKDF_SALT = Buffer.from("immediacy-session-hkdf-salt-v1", "utf8");
|
|
45
|
+
const SESSION_ID_BYTES = 16;
|
|
46
|
+
const KEY_BYTES = 32; // AES-256 key length
|
|
47
|
+
const NONCE_BYTES = 12; // AES-GCM standard nonce
|
|
48
|
+
const TAG_BYTES = 16; // AES-GCM authentication tag
|
|
49
|
+
/** A handshake step failed. `code` is a stable machine-readable reason. */
|
|
50
|
+
export class HandshakeError extends Error {
|
|
51
|
+
code;
|
|
52
|
+
constructor(code, message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.code = code;
|
|
55
|
+
this.name = "HandshakeError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function randomBytes(n) {
|
|
59
|
+
const b = Buffer.alloc(n);
|
|
60
|
+
sodium.randombytes_buf(b);
|
|
61
|
+
return b;
|
|
62
|
+
}
|
|
63
|
+
/** Generate an ephemeral X25519 keypair (via crypto_box, which is X25519). */
|
|
64
|
+
function ephemeralKeypair() {
|
|
65
|
+
const pub = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
|
|
66
|
+
const sec = Buffer.alloc(sodium.crypto_box_SECRETKEYBYTES);
|
|
67
|
+
sodium.crypto_box_keypair(pub, sec);
|
|
68
|
+
return { pub, sec };
|
|
69
|
+
}
|
|
70
|
+
/** The signed transcript binding an ephemeral key to this session + daemon. */
|
|
71
|
+
function transcript(ephPub, rendezvousId, sessionId) {
|
|
72
|
+
return Buffer.concat([ephPub, Buffer.from(rendezvousId, "utf8"), sessionId]);
|
|
73
|
+
}
|
|
74
|
+
/** X25519 ECDH → raw shared secret (fed into HKDF, which hashes it). */
|
|
75
|
+
function ecdh(mySec, peerPub) {
|
|
76
|
+
if (peerPub.length !== sodium.crypto_scalarmult_BYTES) {
|
|
77
|
+
throw new HandshakeError("bad_key", "peer ephemeral key has the wrong length");
|
|
78
|
+
}
|
|
79
|
+
const shared = Buffer.alloc(sodium.crypto_scalarmult_BYTES);
|
|
80
|
+
sodium.crypto_scalarmult(shared, mySec, peerPub);
|
|
81
|
+
return shared;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* HKDF-SHA256 the ECDH secret into two directional 32-byte keys. `k0` is the
|
|
85
|
+
* initiator→responder key, `k1` the responder→initiator key. Deterministic on
|
|
86
|
+
* both sides because `sessionId` is shared and mixed into the info.
|
|
87
|
+
*/
|
|
88
|
+
function deriveKeys(shared, sessionId) {
|
|
89
|
+
const info = Buffer.concat([Buffer.from(SESSION_HKDF_INFO, "utf8"), sessionId]);
|
|
90
|
+
const okm = Buffer.from(hkdfSync("sha256", shared, SESSION_HKDF_SALT, info, KEY_BYTES * 2));
|
|
91
|
+
return { k0: okm.subarray(0, KEY_BYTES), k1: okm.subarray(KEY_BYTES) };
|
|
92
|
+
}
|
|
93
|
+
function decodeB64(value, field) {
|
|
94
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
95
|
+
throw new HandshakeError("bad_message", `missing ${field}`);
|
|
96
|
+
}
|
|
97
|
+
return Buffer.from(value, "base64");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A per-session symmetric channel. Each direction seals with its own key and a
|
|
101
|
+
* counter nonce from 0. Assumes ordered delivery per direction (true for the
|
|
102
|
+
* loopback/LAN/relay reliable channels this Phase-1 handshake feeds); the recv
|
|
103
|
+
* counter enforces strict monotonic order and rejects replays.
|
|
104
|
+
*/
|
|
105
|
+
export class SecureChannel {
|
|
106
|
+
sendKey;
|
|
107
|
+
recvKey;
|
|
108
|
+
sendCounter = 0;
|
|
109
|
+
recvCounter = 0;
|
|
110
|
+
constructor(sendKey, recvKey) {
|
|
111
|
+
this.sendKey = sendKey;
|
|
112
|
+
this.recvKey = recvKey;
|
|
113
|
+
}
|
|
114
|
+
static nonce(counter) {
|
|
115
|
+
const nonce = Buffer.alloc(NONCE_BYTES);
|
|
116
|
+
// 64-bit little-endian counter in the low 8 bytes; the rest stays zero.
|
|
117
|
+
nonce.writeBigUInt64LE(BigInt(counter), 0);
|
|
118
|
+
return nonce;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Seal a plaintext frame for the peer. Returns `ciphertext || tag` (the GCM
|
|
122
|
+
* tag appended, matching CryptoKit's `combined` layout minus the nonce, which
|
|
123
|
+
* is the implicit counter). Nonce derives from the send counter.
|
|
124
|
+
*/
|
|
125
|
+
seal(plaintext) {
|
|
126
|
+
const nonce = SecureChannel.nonce(this.sendCounter++);
|
|
127
|
+
const cipher = createCipheriv("aes-256-gcm", this.sendKey, nonce);
|
|
128
|
+
const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
129
|
+
const tag = cipher.getAuthTag();
|
|
130
|
+
return Buffer.concat([body, tag]);
|
|
131
|
+
}
|
|
132
|
+
/** Open a sealed frame from the peer (next in order). Throws on tamper/replay. */
|
|
133
|
+
open(sealed) {
|
|
134
|
+
if (sealed.length < TAG_BYTES) {
|
|
135
|
+
throw new HandshakeError("bad_message", "ciphertext shorter than the tag");
|
|
136
|
+
}
|
|
137
|
+
const nonce = SecureChannel.nonce(this.recvCounter);
|
|
138
|
+
const body = sealed.subarray(0, sealed.length - TAG_BYTES);
|
|
139
|
+
const tag = sealed.subarray(sealed.length - TAG_BYTES);
|
|
140
|
+
const decipher = createDecipheriv("aes-256-gcm", this.recvKey, nonce);
|
|
141
|
+
decipher.setAuthTag(tag);
|
|
142
|
+
try {
|
|
143
|
+
const plaintext = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
144
|
+
this.recvCounter++;
|
|
145
|
+
return plaintext;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
throw new HandshakeError("bad_message", "frame authentication failed");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Begin the handshake as the initiator. Returns msg1 to send and a `finish`
|
|
154
|
+
* that consumes the responder's msg2 and yields the `SecureChannel`.
|
|
155
|
+
*/
|
|
156
|
+
export function startHandshake(opts) {
|
|
157
|
+
const sessionId = randomBytes(SESSION_ID_BYTES);
|
|
158
|
+
const eph = ephemeralKeypair();
|
|
159
|
+
const sig = signDetached(transcript(eph.pub, opts.rendezvousId, sessionId), opts.deviceSignSecret);
|
|
160
|
+
const msg1 = {
|
|
161
|
+
v: PROTOCOL_VERSION,
|
|
162
|
+
sid: sessionId.toString("base64"),
|
|
163
|
+
eph: eph.pub.toString("base64"),
|
|
164
|
+
sig: sig.toString("base64"),
|
|
165
|
+
dev: opts.devicePublicKeyB64,
|
|
166
|
+
...(opts.code ? { code: opts.code } : {}),
|
|
167
|
+
...(opts.label ? { label: opts.label } : {}),
|
|
168
|
+
};
|
|
169
|
+
const daemonPub = Buffer.from(opts.daemonPublicKeyB64, "base64");
|
|
170
|
+
const finish = (msg2) => {
|
|
171
|
+
if (!msg2 || msg2.v !== PROTOCOL_VERSION) {
|
|
172
|
+
throw new HandshakeError("bad_message", "malformed handshake reply");
|
|
173
|
+
}
|
|
174
|
+
const peerEph = decodeB64(msg2.eph, "responder ephemeral key");
|
|
175
|
+
const peerSig = decodeB64(msg2.sig, "responder signature");
|
|
176
|
+
const ok = verifyDetached(peerSig, transcript(peerEph, opts.rendezvousId, sessionId), daemonPub);
|
|
177
|
+
if (!ok) {
|
|
178
|
+
// Wrong/rotated daemon key ⇒ possible MITM. Surface it, never silent-retry.
|
|
179
|
+
throw new HandshakeError("bad_signature", "daemon signature did not match the pinned identity key");
|
|
180
|
+
}
|
|
181
|
+
const shared = ecdh(eph.sec, peerEph);
|
|
182
|
+
const { k0, k1 } = deriveKeys(shared, sessionId);
|
|
183
|
+
// Initiator sends on k0, receives on k1.
|
|
184
|
+
return new SecureChannel(k0, k1);
|
|
185
|
+
};
|
|
186
|
+
return { msg1, finish };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Handle the handshake as the responder. Verifies the initiator, produces msg2
|
|
190
|
+
* to send back, and returns the established `SecureChannel`. Throws
|
|
191
|
+
* `HandshakeError` on any failure (unknown/revoked device, bad signature,
|
|
192
|
+
* malformed message).
|
|
193
|
+
*/
|
|
194
|
+
export function acceptHandshake(msg1, opts) {
|
|
195
|
+
if (!msg1 || msg1.v !== PROTOCOL_VERSION) {
|
|
196
|
+
throw new HandshakeError("bad_message", "malformed handshake message");
|
|
197
|
+
}
|
|
198
|
+
const sessionId = decodeB64(msg1.sid, "sessionId");
|
|
199
|
+
if (sessionId.length !== SESSION_ID_BYTES) {
|
|
200
|
+
throw new HandshakeError("bad_message", "sessionId has the wrong length");
|
|
201
|
+
}
|
|
202
|
+
const peerEph = decodeB64(msg1.eph, "initiator ephemeral key");
|
|
203
|
+
const peerSig = decodeB64(msg1.sig, "initiator signature");
|
|
204
|
+
// Authorization first: an unknown or revoked device never gets to a verify —
|
|
205
|
+
// UNLESS it presents a valid first-pairing code, which pairs it inline.
|
|
206
|
+
if (typeof msg1.dev !== "string") {
|
|
207
|
+
throw new HandshakeError("bad_message", "missing device key");
|
|
208
|
+
}
|
|
209
|
+
let paired = false;
|
|
210
|
+
if (!opts.isPaired(msg1.dev)) {
|
|
211
|
+
if (typeof msg1.code !== "string" ||
|
|
212
|
+
!opts.redeemCode ||
|
|
213
|
+
!opts.redeemCode(msg1.dev, msg1.code)) {
|
|
214
|
+
throw new HandshakeError("unknown_device", "device is not paired (or revoked)");
|
|
215
|
+
}
|
|
216
|
+
paired = true; // valid code — the caller records the device on success
|
|
217
|
+
}
|
|
218
|
+
const devicePub = Buffer.from(msg1.dev, "base64");
|
|
219
|
+
const ok = verifyDetached(peerSig, transcript(peerEph, opts.rendezvousId, sessionId), devicePub);
|
|
220
|
+
if (!ok) {
|
|
221
|
+
throw new HandshakeError("bad_signature", "device signature did not match its paired key");
|
|
222
|
+
}
|
|
223
|
+
const eph = ephemeralKeypair();
|
|
224
|
+
const sig = signDetached(transcript(eph.pub, opts.rendezvousId, sessionId), opts.daemonSignSecret);
|
|
225
|
+
const msg2 = {
|
|
226
|
+
v: PROTOCOL_VERSION,
|
|
227
|
+
eph: eph.pub.toString("base64"),
|
|
228
|
+
sig: sig.toString("base64"),
|
|
229
|
+
};
|
|
230
|
+
const shared = ecdh(eph.sec, peerEph);
|
|
231
|
+
const { k0, k1 } = deriveKeys(shared, sessionId);
|
|
232
|
+
// Responder receives on k0 (initiator's send key), sends on k1.
|
|
233
|
+
return {
|
|
234
|
+
msg2,
|
|
235
|
+
channel: new SecureChannel(k1, k0),
|
|
236
|
+
devicePub: msg1.dev,
|
|
237
|
+
paired,
|
|
238
|
+
...(paired && typeof msg1.label === "string" ? { label: msg1.label } : {}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,MAAM,MAAM,eAAe,CAAC;AACnC,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE/D,MAAM,gBAAgB,GAAG,CAAU,CAAC;AAEpC,+DAA+D;AAC/D,MAAM,iBAAiB,GAAG,sBAAsB,CAAC;AACjD,gFAAgF;AAChF,MAAM,iBAAiB,GAAG,MAAM,CAAC,IAAI,CAAC,gCAAgC,EAAE,MAAM,CAAC,CAAC;AAEhF,MAAM,gBAAgB,GAAG,EAAE,CAAC;AAC5B,MAAM,SAAS,GAAG,EAAE,CAAC,CAAC,qBAAqB;AAC3C,MAAM,WAAW,GAAG,EAAE,CAAC,CAAC,yBAAyB;AACjD,MAAM,SAAS,GAAG,EAAE,CAAC,CAAC,6BAA6B;AAEnD,2EAA2E;AAC3E,MAAM,OAAO,cAAe,SAAQ,KAAK;IAE9B;IADT,YACS,IAIM,EACb,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;QAPR,SAAI,GAAJ,IAAI,CAIE;QAIb,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAC;IAC/B,CAAC;CACF;AA2BD,SAAS,WAAW,CAAC,CAAS;IAC5B,MAAM,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC1B,MAAM,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;IAC1B,OAAO,CAAC,CAAC;AACX,CAAC;AAED,8EAA8E;AAC9E,SAAS,gBAAgB;IACvB,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,yBAAyB,CAAC,CAAC;IAC3D,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,yBAAyB,CAAC,CAAC;IAC3D,MAAM,CAAC,kBAAkB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IACpC,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC;AACtB,CAAC;AAED,+EAA+E;AAC/E,SAAS,UAAU,CACjB,MAAc,EACd,YAAoB,EACpB,SAAiB;IAEjB,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,YAAY,EAAE,MAAM,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC;AAC/E,CAAC;AAED,wEAAwE;AACxE,SAAS,IAAI,CAAC,KAAa,EAAE,OAAe;IAC1C,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,CAAC,uBAAuB,EAAE,CAAC;QACtD,MAAM,IAAI,cAAc,CAAC,SAAS,EAAE,yCAAyC,CAAC,CAAC;IACjF,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,uBAAuB,CAAC,CAAC;IAC5D,MAAM,CAAC,iBAAiB,CAAC,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC;IACjD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;GAIG;AACH,SAAS,UAAU,CAAC,MAAc,EAAE,SAAiB;IACnD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,iBAAiB,EAAE,MAAM,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC;IAChF,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,iBAAiB,EAAE,IAAI,EAAE,SAAS,GAAG,CAAC,CAAC,CACnE,CAAC;IACF,OAAO,EAAE,EAAE,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,SAAS,CAAC,EAAE,EAAE,EAAE,GAAG,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;AACzE,CAAC;AAED,SAAS,SAAS,CAAC,KAAc,EAAE,KAAa;IAC9C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpD,MAAM,IAAI,cAAc,CAAC,aAAa,EAAE,WAAW,KAAK,EAAE,CAAC,CAAC;IAC9D,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;AACtC,CAAC;AAED;;;;;GAKG;AACH,MAAM,OAAO,aAAa;IAIL;IACA;IAJX,WAAW,GAAG,CAAC,CAAC;IAChB,WAAW,GAAG,CAAC,CAAC;IACxB,YACmB,OAAe,EACf,OAAe;QADf,YAAO,GAAP,OAAO,CAAQ;QACf,YAAO,GAAP,OAAO,CAAQ;IAC/B,CAAC;IAEI,MAAM,CAAC,KAAK,CAAC,OAAe;QAClC,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;QACxC,wEAAwE;QACxE,KAAK,CAAC,gBAAgB,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC;QAC3C,OAAO,KAAK,CAAC;IACf,CAAC;IAED;;;;OAIG;IACH,IAAI,CAAC,SAAiB;QACpB,MAAM,KAAK,GAAG,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;QACtD,MAAM,MAAM,GAAG,cAAc,CAAC,aAAa,EAAE,IAAI,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QAClE,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;QACvE,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC;QAChC,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC;IACpC,CAAC;IAED,kFAAkF;IAClF,IAAI,CAAC,MAAc;QACjB,IAAI,MAAM,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;YAC9B,MAAM,IAAI,cAAc,CAAC,aAAa,EAAE,iCAAiC,CAAC,CAAC;QAC7E,CAAC;QACD,MAAM,KAAK,GAAG,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QACpD,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;QAC3D,MAAM,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;QACvD,MAAM,QAAQ,GAAG,gBAAgB,CAAC,aAAa,EAAE,IAAI,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACtE,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;QACzB,IAAI,CAAC;YACH,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;YAC3E,IAAI,CAAC,WAAW,EAAE,CAAC;YACnB,OAAO,SAAS,CAAC;QACnB,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,IAAI,cAAc,CAAC,aAAa,EAAE,6BAA6B,CAAC,CAAC;QACzE,CAAC;IACH,CAAC;CACF;AA8CD;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,IAAmB;IAIhD,MAAM,SAAS,GAAG,WAAW,CAAC,gBAAgB,CAAC,CAAC;IAChD,MAAM,GAAG,GAAG,gBAAgB,EAAE,CAAC;IAC/B,MAAM,GAAG,GAAG,YAAY,CACtB,UAAU,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,YAAY,EAAE,SAAS,CAAC,EACjD,IAAI,CAAC,gBAAgB,CACtB,CAAC;IACF,MAAM,IAAI,GAAkB;QAC1B,CAAC,EAAE,gBAAgB;QACnB,GAAG,EAAE,SAAS,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACjC,GAAG,EAAE,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAC/B,GAAG,EAAE,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAC3B,GAAG,EAAE,IAAI,CAAC,kBAAkB;QAC5B,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC7C,CAAC;IAEF,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;IAEjE,MAAM,MAAM,GAAG,CAAC,IAAmB,EAAiB,EAAE;QACpD,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,KAAK,gBAAgB,EAAE,CAAC;YACzC,MAAM,IAAI,cAAc,CAAC,aAAa,EAAE,2BAA2B,CAAC,CAAC;QACvE,CAAC;QACD,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,EAAE,yBAAyB,CAAC,CAAC;QAC/D,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,EAAE,qBAAqB,CAAC,CAAC;QAC3D,MAAM,EAAE,GAAG,cAAc,CACvB,OAAO,EACP,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,YAAY,EAAE,SAAS,CAAC,EACjD,SAAS,CACV,CAAC;QACF,IAAI,CAAC,EAAE,EAAE,CAAC;YACR,4EAA4E;YAC5E,MAAM,IAAI,cAAc,CACtB,eAAe,EACf,wDAAwD,CACzD,CAAC;QACJ,CAAC;QACD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QACtC,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,UAAU,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;QACjD,yCAAyC;QACzC,OAAO,IAAI,aAAa,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;IACnC,CAAC,CAAC;IAEF,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;AAC1B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAC7B,IAAmB,EACnB,IAAmB;IAenB,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,KAAK,gBAAgB,EAAE,CAAC;QACzC,MAAM,IAAI,cAAc,CAAC,aAAa,EAAE,6BAA6B,CAAC,CAAC;IACzE,CAAC;IACD,MAAM,SAAS,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC;IACnD,IAAI,SAAS,CAAC,MAAM,KAAK,gBAAgB,EAAE,CAAC;QAC1C,MAAM,IAAI,cAAc,CAAC,aAAa,EAAE,gCAAgC,CAAC,CAAC;IAC5E,CAAC;IACD,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,EAAE,yBAAyB,CAAC,CAAC;IAC/D,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,EAAE,qBAAqB,CAAC,CAAC;IAE3D,6EAA6E;IAC7E,wEAAwE;IACxE,IAAI,OAAO,IAAI,CAAC,GAAG,KAAK,QAAQ,EAAE,CAAC;QACjC,MAAM,IAAI,cAAc,CAAC,aAAa,EAAE,oBAAoB,CAAC,CAAC;IAChE,CAAC;IACD,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QAC7B,IACE,OAAO,IAAI,CAAC,IAAI,KAAK,QAAQ;YAC7B,CAAC,IAAI,CAAC,UAAU;YAChB,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,EACrC,CAAC;YACD,MAAM,IAAI,cAAc,CAAC,gBAAgB,EAAE,mCAAmC,CAAC,CAAC;QAClF,CAAC;QACD,MAAM,GAAG,IAAI,CAAC,CAAC,wDAAwD;IACzE,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;IAElD,MAAM,EAAE,GAAG,cAAc,CACvB,OAAO,EACP,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,YAAY,EAAE,SAAS,CAAC,EACjD,SAAS,CACV,CAAC;IACF,IAAI,CAAC,EAAE,EAAE,CAAC;QACR,MAAM,IAAI,cAAc,CACtB,eAAe,EACf,+CAA+C,CAChD,CAAC;IACJ,CAAC;IAED,MAAM,GAAG,GAAG,gBAAgB,EAAE,CAAC;IAC/B,MAAM,GAAG,GAAG,YAAY,CACtB,UAAU,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,YAAY,EAAE,SAAS,CAAC,EACjD,IAAI,CAAC,gBAAgB,CACtB,CAAC;IACF,MAAM,IAAI,GAAkB;QAC1B,CAAC,EAAE,gBAAgB;QACnB,GAAG,EAAE,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAC/B,GAAG,EAAE,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC;KAC5B,CAAC;IAEF,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IACtC,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,UAAU,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACjD,gEAAgE;IAChE,OAAO;QACL,IAAI;QACJ,OAAO,EAAE,IAAI,aAAa,CAAC,EAAE,EAAE,EAAE,CAAC;QAClC,SAAS,EAAE,IAAI,CAAC,GAAG;QACnB,MAAM;QACN,GAAG,CAAC,MAAM,IAAI,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC3E,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TCP transport (docs/WEBRTC_MIGRATION.md Phase 3, LAN direct).
|
|
3
|
+
*
|
|
4
|
+
* Adapts a Node `net.Socket` to the `Transport` interface so the frame codec +
|
|
5
|
+
* `Mux` (framing.ts / transport.ts) run over a LAN TCP connection unchanged.
|
|
6
|
+
*
|
|
7
|
+
* A TCP socket is a byte STREAM with no message boundaries, so each frame is
|
|
8
|
+
* length-delimited on the wire with a 4-byte big-endian prefix:
|
|
9
|
+
*
|
|
10
|
+
* ┌──────────────┬───────────────────────────┐
|
|
11
|
+
* │ len u32 (BE) │ encoded frame (len bytes) │
|
|
12
|
+
* └──────────────┴───────────────────────────┘
|
|
13
|
+
*
|
|
14
|
+
* The length prefix is internal to this transport — the `Mux` above only ever
|
|
15
|
+
* sees whole frames, exactly as it does over a WebSocket message boundary.
|
|
16
|
+
* Backpressure maps to the socket's own `writableLength` + `drain` event.
|
|
17
|
+
*/
|
|
18
|
+
import type { Socket } from "node:net";
|
|
19
|
+
import type { Transport } from "./transport.js";
|
|
20
|
+
export declare class TcpTransport implements Transport {
|
|
21
|
+
private readonly socket;
|
|
22
|
+
private frameCb;
|
|
23
|
+
private closeCb;
|
|
24
|
+
private inbound;
|
|
25
|
+
private drainWaiters;
|
|
26
|
+
private closed;
|
|
27
|
+
constructor(socket: Socket);
|
|
28
|
+
send(bytes: Buffer): void;
|
|
29
|
+
bufferedAmount(): number;
|
|
30
|
+
onFrame(cb: (bytes: Buffer) => void): void;
|
|
31
|
+
onClose(cb: () => void): void;
|
|
32
|
+
whenWritable(highWaterMark: number): Promise<void>;
|
|
33
|
+
close(): void;
|
|
34
|
+
private wakeDrainWaiters;
|
|
35
|
+
/** Accumulate stream bytes and emit each complete length-prefixed frame. */
|
|
36
|
+
private onData;
|
|
37
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TCP transport (docs/WEBRTC_MIGRATION.md Phase 3, LAN direct).
|
|
3
|
+
*
|
|
4
|
+
* Adapts a Node `net.Socket` to the `Transport` interface so the frame codec +
|
|
5
|
+
* `Mux` (framing.ts / transport.ts) run over a LAN TCP connection unchanged.
|
|
6
|
+
*
|
|
7
|
+
* A TCP socket is a byte STREAM with no message boundaries, so each frame is
|
|
8
|
+
* length-delimited on the wire with a 4-byte big-endian prefix:
|
|
9
|
+
*
|
|
10
|
+
* ┌──────────────┬───────────────────────────┐
|
|
11
|
+
* │ len u32 (BE) │ encoded frame (len bytes) │
|
|
12
|
+
* └──────────────┴───────────────────────────┘
|
|
13
|
+
*
|
|
14
|
+
* The length prefix is internal to this transport — the `Mux` above only ever
|
|
15
|
+
* sees whole frames, exactly as it does over a WebSocket message boundary.
|
|
16
|
+
* Backpressure maps to the socket's own `writableLength` + `drain` event.
|
|
17
|
+
*/
|
|
18
|
+
/** Length-prefix size. */
|
|
19
|
+
const LEN_BYTES = 4;
|
|
20
|
+
/** Refuse an absurd length prefix before allocating (guards a hostile peer). */
|
|
21
|
+
const MAX_FRAME_BYTES = 64 * 1024 + 64; // one chunk + header slack
|
|
22
|
+
export class TcpTransport {
|
|
23
|
+
socket;
|
|
24
|
+
frameCb = null;
|
|
25
|
+
closeCb = null;
|
|
26
|
+
inbound = Buffer.alloc(0);
|
|
27
|
+
drainWaiters = [];
|
|
28
|
+
closed = false;
|
|
29
|
+
constructor(socket) {
|
|
30
|
+
this.socket = socket;
|
|
31
|
+
socket.on("data", (chunk) => this.onData(chunk));
|
|
32
|
+
socket.on("drain", () => this.wakeDrainWaiters());
|
|
33
|
+
socket.on("close", () => {
|
|
34
|
+
this.closed = true;
|
|
35
|
+
for (const w of this.drainWaiters)
|
|
36
|
+
w.resolve();
|
|
37
|
+
this.drainWaiters = [];
|
|
38
|
+
this.closeCb?.();
|
|
39
|
+
});
|
|
40
|
+
socket.on("error", () => {
|
|
41
|
+
/* surfaced via 'close'; swallow so a peer reset doesn't crash the process */
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
send(bytes) {
|
|
45
|
+
if (this.closed)
|
|
46
|
+
return;
|
|
47
|
+
const framed = Buffer.allocUnsafe(LEN_BYTES + bytes.length);
|
|
48
|
+
framed.writeUInt32BE(bytes.length, 0);
|
|
49
|
+
bytes.copy(framed, LEN_BYTES);
|
|
50
|
+
this.socket.write(framed);
|
|
51
|
+
}
|
|
52
|
+
bufferedAmount() {
|
|
53
|
+
return this.socket.writableLength;
|
|
54
|
+
}
|
|
55
|
+
onFrame(cb) {
|
|
56
|
+
this.frameCb = cb;
|
|
57
|
+
}
|
|
58
|
+
onClose(cb) {
|
|
59
|
+
this.closeCb = cb;
|
|
60
|
+
}
|
|
61
|
+
whenWritable(highWaterMark) {
|
|
62
|
+
if (this.closed || this.socket.writableLength < highWaterMark) {
|
|
63
|
+
return Promise.resolve();
|
|
64
|
+
}
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
this.drainWaiters.push({ hw: highWaterMark, resolve });
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
close() {
|
|
70
|
+
if (this.closed)
|
|
71
|
+
return;
|
|
72
|
+
this.socket.end();
|
|
73
|
+
}
|
|
74
|
+
wakeDrainWaiters() {
|
|
75
|
+
if (this.drainWaiters.length === 0)
|
|
76
|
+
return;
|
|
77
|
+
const still = [];
|
|
78
|
+
for (const w of this.drainWaiters) {
|
|
79
|
+
if (this.socket.writableLength < w.hw)
|
|
80
|
+
w.resolve();
|
|
81
|
+
else
|
|
82
|
+
still.push(w);
|
|
83
|
+
}
|
|
84
|
+
this.drainWaiters = still;
|
|
85
|
+
}
|
|
86
|
+
/** Accumulate stream bytes and emit each complete length-prefixed frame. */
|
|
87
|
+
onData(chunk) {
|
|
88
|
+
this.inbound = this.inbound.length === 0 ? chunk : Buffer.concat([this.inbound, chunk]);
|
|
89
|
+
for (;;) {
|
|
90
|
+
if (this.inbound.length < LEN_BYTES)
|
|
91
|
+
return;
|
|
92
|
+
const len = this.inbound.readUInt32BE(0);
|
|
93
|
+
if (len > MAX_FRAME_BYTES) {
|
|
94
|
+
// Corrupt/hostile stream — drop the connection rather than allocate.
|
|
95
|
+
this.close();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (this.inbound.length < LEN_BYTES + len)
|
|
99
|
+
return; // frame not fully arrived
|
|
100
|
+
const frame = this.inbound.subarray(LEN_BYTES, LEN_BYTES + len);
|
|
101
|
+
this.frameCb?.(Buffer.from(frame));
|
|
102
|
+
this.inbound = this.inbound.subarray(LEN_BYTES + len);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=tcp-transport.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tcp-transport.js","sourceRoot":"","sources":["../../src/tcp-transport.ts"],"names":[],"mappings":"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"}
|