@gtc6244/immediacy-daemon 0.2.2 → 0.2.4

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.
Files changed (57) hide show
  1. package/dist/src/crypto.d.ts +18 -39
  2. package/dist/src/crypto.js +33 -152
  3. package/dist/src/crypto.js.map +1 -1
  4. package/dist/src/devices.d.ts +46 -0
  5. package/dist/src/devices.js +114 -0
  6. package/dist/src/devices.js.map +1 -0
  7. package/dist/src/envelope.d.ts +3 -3
  8. package/dist/src/envelope.js +3 -3
  9. package/dist/src/forward.d.ts +4 -4
  10. package/dist/src/forward.js +1 -1
  11. package/dist/src/forward.js.map +1 -1
  12. package/dist/src/framing.d.ts +76 -0
  13. package/dist/src/framing.js +133 -0
  14. package/dist/src/framing.js.map +1 -0
  15. package/dist/src/hub.d.ts +21 -9
  16. package/dist/src/hub.js +6 -5
  17. package/dist/src/hub.js.map +1 -1
  18. package/dist/src/index.d.ts +3 -3
  19. package/dist/src/index.js +79 -17
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/lan.d.ts +103 -0
  22. package/dist/src/lan.js +182 -0
  23. package/dist/src/lan.js.map +1 -0
  24. package/dist/src/pairing.d.ts +69 -0
  25. package/dist/src/pairing.js +125 -0
  26. package/dist/src/pairing.js.map +1 -0
  27. package/dist/src/protocol.d.ts +75 -21
  28. package/dist/src/relay-transport.d.ts +62 -0
  29. package/dist/src/relay-transport.js +180 -0
  30. package/dist/src/relay-transport.js.map +1 -0
  31. package/dist/src/relay-wire.d.ts +25 -0
  32. package/dist/src/relay-wire.js +37 -0
  33. package/dist/src/relay-wire.js.map +1 -0
  34. package/dist/src/rendezvous.d.ts +57 -0
  35. package/dist/src/rendezvous.js +152 -0
  36. package/dist/src/rendezvous.js.map +1 -0
  37. package/dist/src/server.d.ts +19 -7
  38. package/dist/src/server.js +181 -61
  39. package/dist/src/server.js.map +1 -1
  40. package/dist/src/session-socket.d.ts +34 -0
  41. package/dist/src/session-socket.js +84 -0
  42. package/dist/src/session-socket.js.map +1 -0
  43. package/dist/src/session.d.ts +151 -0
  44. package/dist/src/session.js +241 -0
  45. package/dist/src/session.js.map +1 -0
  46. package/dist/src/tcp-transport.d.ts +37 -0
  47. package/dist/src/tcp-transport.js +106 -0
  48. package/dist/src/tcp-transport.js.map +1 -0
  49. package/dist/src/telemetry.d.ts +69 -0
  50. package/dist/src/telemetry.js +64 -0
  51. package/dist/src/telemetry.js.map +1 -0
  52. package/dist/src/transport.d.ts +75 -0
  53. package/dist/src/transport.js +136 -0
  54. package/dist/src/transport.js.map +1 -0
  55. package/dist/src/workspace.d.ts +4 -4
  56. package/dist/src/workspace.js.map +1 -1
  57. package/package.json +16 -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":"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"}
@@ -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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"}