@netnodeag/kraftwerk 0.70.0 → 0.71.1

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 (37) hide show
  1. package/README.md +39 -0
  2. package/dist/cli/daemon.js +6 -0
  3. package/dist/cli/doctor.js +3 -0
  4. package/dist/cli/kraftwerk.js +2 -0
  5. package/dist/cli/remote.d.ts +2 -0
  6. package/dist/cli/remote.js +89 -0
  7. package/dist/client/relay.d.ts +149 -0
  8. package/dist/client/relay.js +318 -0
  9. package/dist/client/routes.d.ts +2 -0
  10. package/dist/client/routes.js +2 -0
  11. package/dist/server/api/devices.d.ts +28 -2
  12. package/dist/server/api/devices.js +39 -5
  13. package/dist/server/api/index.d.ts +28 -2
  14. package/dist/server/relay.d.ts +77 -0
  15. package/dist/server/relay.js +321 -0
  16. package/dist/server/ws.js +3 -0
  17. package/package.json +7 -2
  18. package/web/dist/assets/{dist-DPXNFIR0.js → dist-4mt46bwa.js} +1 -1
  19. package/web/dist/assets/{dist-BeHtCIB0.js → dist-5bpaJOp6.js} +1 -1
  20. package/web/dist/assets/{dist-5v0UmV-F.js → dist-8As2tNSv.js} +1 -1
  21. package/web/dist/assets/{dist-B5BBuAK8.js → dist-BFetH-PC.js} +1 -1
  22. package/web/dist/assets/{dist-DGfHUaeU.js → dist-BcEOC-ps.js} +1 -1
  23. package/web/dist/assets/{dist-CxMl8Xeq.js → dist-BmsKU6ix.js} +1 -1
  24. package/web/dist/assets/{dist-DOUkQhjz.js → dist-CANEcq7F.js} +1 -1
  25. package/web/dist/assets/dist-CWrRm3RK.js +1 -0
  26. package/web/dist/assets/{dist-CkKJVLbX.js → dist-Coej_0FF.js} +1 -1
  27. package/web/dist/assets/{dist-D4wB4wkJ.js → dist-Du3myOTO.js} +1 -1
  28. package/web/dist/assets/{dist-B-eaMFbi.js → dist-FeMkWwZl.js} +1 -1
  29. package/web/dist/assets/{editor-oLm1tYCp.js → editor-B3am-j9U.js} +3 -3
  30. package/web/dist/assets/index-C8DF24q1.css +2 -0
  31. package/web/dist/assets/{index-V2VT7ZQP.js → index-CrBIiOwc.js} +26 -24
  32. package/web/dist/index.html +2 -2
  33. package/web/dist-remote/assets/index-2nAI1HTV.js +13 -0
  34. package/web/dist-remote/index.html +16 -0
  35. package/web/dist-remote/sw.js +1 -0
  36. package/web/dist/assets/dist-1EXSVa5k.js +0 -1
  37. package/web/dist/assets/index-CrD3grQE.css +0 -2
package/README.md CHANGED
@@ -108,6 +108,15 @@ The phone now sees your workspaces, each at `/w/<slug>/` on that address,
108
108
  and the workspace menu switches between them. One pairing covers all of them
109
109
  (see [Inspector on your phone](#inspector-on-your-phone-paired-devices)).
110
110
 
111
+ **From anywhere** (mobile data, another Wi-Fi), with no port opened:
112
+
113
+ 1. `kraftwerk remote on`. The daemon connects to the kraftwerk relay.
114
+ 2. `kraftwerk remote pair`, or settings → devices → pair a device, which shows
115
+ a QR code. Open the link (scan the code) on the phone and tap *Pair*.
116
+
117
+ The phone runs the same web UI, end to end encrypted through the relay
118
+ (see [From anywhere: the relay](#from-anywhere-the-relay)).
119
+
111
120
  **What to do first in the inspector.** Talk to **Ralv**, the workspace's
112
121
  general chat, in plain words: it knows what is in the workspace and helps
113
122
  set up the rest. From there:
@@ -239,6 +248,7 @@ kraftwerk daemon # one kraftwerk for every open workspace
239
248
  kraftwerk daemon install # start the daemon at login (macOS LaunchAgent); --lan, --port; `daemon uninstall` removes it
240
249
  kraftwerk workspaces # every workspace on this machine; workspaces start|stop|forget <ref>
241
250
  kraftwerk devices # paired devices (a phone, another computer); devices pair|revoke
251
+ kraftwerk remote on|off|pair # reach this machine from anywhere through the relay, end to end encrypted
242
252
  kraftwerk projects # goal-scoped project folders; projects create|show|link|log|remove
243
253
  kraftwerk trash # what was deleted; trash restore|purge <id>, trash empty
244
254
  kraftwerk journal <agent> ["<entry>"] # an agent's memory across sessions: print it, add a line (--kind)
@@ -417,6 +427,35 @@ Behind a reverse proxy on the same machine, the proxy must send
417
427
  other device. If it cannot, set `KRAFTWERK_UI_REQUIRE_PAIRING=1`, which
418
428
  makes every request pair, this machine's own included.
419
429
 
430
+ ### From anywhere: the relay
431
+
432
+ `kraftwerk remote on` makes the daemon hold a connection to the kraftwerk
433
+ relay (kraftwerk cloud; `--url` for another one). A paired device reaches the
434
+ machine through it from any network: no port forwarding, no tunnel, and the
435
+ machine needs no public address. `kraftwerk remote pair` (or settings →
436
+ devices → pair a device, with a QR code) gives a link to the cloud's remote
437
+ page. It carries this machine's key and a one-time code in its `#` part,
438
+ which never reaches a server. The phone pairs, then runs the whole web UI
439
+ through the relay. `kraftwerk remote off` disconnects, `kraftwerk remote`
440
+ shows the state, and settings → devices has the same switch.
441
+
442
+ Everything between the phone and the machine is end to end encrypted:
443
+
444
+ - X25519 key agreement on the machine's long-lived key plus a fresh key on
445
+ each side per connection, then AES-256-GCM with counters as nonces.
446
+ - The relay sees which machine, when and how much, never what. It cannot
447
+ forge or replay a message.
448
+ - The phone checks the machine's key (the fingerprint `kraftwerk remote` shows
449
+ is on the pairing page), so nobody else can answer in its place.
450
+ - Through the relay, a request counts as a network device's: it needs the
451
+ device's pairing like on your Wi-Fi, and unpairing ends it at once.
452
+ - One trust remains: the remote page's code comes from the cloud that serves
453
+ it. A native app will remove that.
454
+
455
+ The machine's key and its relay secret live in `~/.kraftwerk/relay.json`
456
+ (owner only). The daemon must run (`kraftwerk daemon install`), and the Mac
457
+ must be awake.
458
+
420
459
  For a client of your own, `@netnodeag/kraftwerk/client` takes the token:
421
460
  `createClient({ baseUrl, token })`.
422
461
 
@@ -2,7 +2,9 @@ import chalk from "chalk";
2
2
  import { DAEMON_PORT, findDaemon, lastOpenRoots, writeDaemonFile } from "../core/daemon.js";
3
3
  import { tildify } from "../core/instances.js";
4
4
  import { networkUrls } from "../server/bind.js";
5
+ import { applyRelay } from "../server/relay.js";
5
6
  import { startHub } from "../server/server.js";
7
+ import { getPkgVersion } from "../server/version.js";
6
8
  import { ensureBuilt, supervise } from "./ui.js";
7
9
  import { installDaemon, uninstallDaemon } from "./daemon-login.js";
8
10
  /**
@@ -45,6 +47,10 @@ export async function runDaemon(opts) {
45
47
  console.log(` ${chalk.cyan(o.url)} ${chalk.dim(o.name)}${o.aliasPort ? chalk.dim(` · also :${o.aliasPort}`) : ""}`);
46
48
  if (!hub.list().length)
47
49
  console.log(chalk.dim(" no workspace open yet — `kraftwerk ui` in a workspace folder opens it here"));
50
+ // The relay (`kraftwerk remote on`): devices reach this machine from anywhere, end to end encrypted.
51
+ const relay = await applyRelay(hub.port, await getPkgVersion(), (line) => console.log(chalk.dim(line)));
52
+ if (relay.state !== "off")
53
+ console.log(`${chalk.green("✔")} Reachable from anywhere through ${chalk.cyan(relay.url ?? "")} ${chalk.dim("(`kraftwerk remote pair` pairs a device)")}`);
48
54
  const lan = networkUrls(hub.port);
49
55
  if (lan.length)
50
56
  console.log(`${chalk.green("✔")} On your network: ${lan.map((u) => chalk.cyan(u)).join(", ")} ${chalk.dim("(add /w/<slug>/; devices pair first)")}`);
@@ -11,6 +11,7 @@ import { listDevices } from "../core/devices.js";
11
11
  import { slugClashes } from "../core/instances.js";
12
12
  import { findDaemon } from "../core/daemon.js";
13
13
  import { daemonLoginItem } from "./daemon-login.js";
14
+ import { readRelaySettings } from "../server/relay.js";
14
15
  const ICONS = {
15
16
  ok: chalk.green("✔"),
16
17
  warn: chalk.yellow("⚠"),
@@ -222,6 +223,8 @@ export async function runDoctor(cwd) {
222
223
  : daemon
223
224
  ? undefined
224
225
  : "`kraftwerk daemon` serves every workspace in one process");
226
+ const relay = await readRelaySettings();
227
+ report("info", relay?.enabled ? `remote access on through ${relay.url}` : "remote access off", relay?.enabled ? (daemon ? "`kraftwerk remote` shows whether the daemon is connected" : "the daemon connects it once it runs") : "`kraftwerk remote on` reaches this machine from anywhere, end to end encrypted");
225
228
  // Legacy: what older versions left behind, and what still runs or is installed at one.
226
229
  for (const f of await legacyFindings(project, pkg.version))
227
230
  report(f.level, f.label, f.detail);
@@ -19,6 +19,7 @@ import { registerVibeableCommands } from "./vibeables.js";
19
19
  import { registerTrashCommands } from "./trash.js";
20
20
  import { registerDevicesCommands } from "./devices.js";
21
21
  import { registerDaemonCommand } from "./daemon.js";
22
+ import { registerRemoteCommand } from "./remote.js";
22
23
  import { registerJournalCommands } from "./journal.js";
23
24
  import { applyDotenv } from "../dotenv.js";
24
25
  import { resolveWorkspace } from "../config.js";
@@ -295,6 +296,7 @@ registerVibeableCommands(program);
295
296
  registerTrashCommands(program);
296
297
  registerDevicesCommands(program);
297
298
  registerDaemonCommand(program);
299
+ registerRemoteCommand(program);
298
300
  registerJournalCommands(program);
299
301
  program
300
302
  .command("ui")
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerRemoteCommand(program: Command): void;
@@ -0,0 +1,89 @@
1
+ import chalk from "chalk";
2
+ import { createPairCode, PAIR_CODE_TTL_MS } from "../core/devices.js";
3
+ import { findDaemon } from "../core/daemon.js";
4
+ import { keyFingerprint, readRelaySettings, relayEndpoint, remoteLink, setRelay } from "../server/relay.js";
5
+ /** Ask the running daemon (this machine: local trust). */
6
+ async function daemonRelay(method, body) {
7
+ const d = await findDaemon();
8
+ if (!d)
9
+ return null;
10
+ const r = await fetch(`http://127.0.0.1:${d.port}/api/devices/relay`, {
11
+ method,
12
+ headers: body ? { "content-type": "application/json" } : {},
13
+ body: body ? JSON.stringify(body) : undefined,
14
+ signal: AbortSignal.timeout(20_000),
15
+ });
16
+ return r.ok ? (await r.json()) : null;
17
+ }
18
+ const describe = (s) => {
19
+ if (!s)
20
+ return chalk.yellow("the daemon is not running — `kraftwerk daemon` (or `kraftwerk daemon install`) connects it");
21
+ if (s.state === "connected")
22
+ return `${chalk.green("connected")}${s.clients ? chalk.dim(` · ${s.clients} device${s.clients === 1 ? "" : "s"} connected now`) : ""}`;
23
+ if (s.state === "connecting")
24
+ return chalk.yellow("connecting…");
25
+ if (s.state === "error")
26
+ return chalk.red(`not connected: ${s.error ?? "unknown error"}`);
27
+ return chalk.dim("off");
28
+ };
29
+ /** Wait briefly for the daemon's link to come up, so `remote on` says whether it worked. */
30
+ async function settle() {
31
+ let s = await daemonRelay("GET").catch(() => null);
32
+ for (let i = 0; i < 20 && s?.state === "connecting"; i++) {
33
+ await new Promise((r) => setTimeout(r, 250));
34
+ s = await daemonRelay("GET").catch(() => null);
35
+ }
36
+ return s;
37
+ }
38
+ export function registerRemoteCommand(program) {
39
+ const remote = program
40
+ .command("remote")
41
+ .description("Reach this machine from anywhere through the relay, end to end encrypted: on, off, pair a device")
42
+ .action(async () => {
43
+ const s = await readRelaySettings();
44
+ if (!s?.enabled)
45
+ return void console.log(`${chalk.dim("Remote access is off.")} ${chalk.dim("`kraftwerk remote on` turns it on.")}`);
46
+ console.log(`Remote access ${chalk.bold("on")} through ${chalk.cyan(s.url)} ${chalk.dim(`(machine key ${keyFingerprint(s.publicKey)})`)}`);
47
+ console.log(` ${describe(await daemonRelay("GET").catch(() => null))}`);
48
+ });
49
+ remote
50
+ .command("on")
51
+ .description("Turn remote access on: the daemon connects to the relay")
52
+ .option("--url <url>", "The cloud whose relay to use (default: the kraftwerk cloud)")
53
+ .action(async (opts) => {
54
+ if (opts.url && !/^https?:\/\//.test(opts.url)) {
55
+ console.error(chalk.red("✖ --url is the cloud's address, e.g. https://srv.kraftwerk-cloud.netnode.cloud"));
56
+ process.exit(2);
57
+ }
58
+ const s = await setRelay(true, opts.url);
59
+ // The daemon reads the file again and connects; without one running, it does when it starts.
60
+ await daemonRelay("POST", { enabled: true, ...(opts.url ? { url: opts.url } : {}) }).catch(() => null);
61
+ const status = await settle();
62
+ console.log(`${chalk.green("✔")} remote access on through ${chalk.cyan(s.url)} ${chalk.dim(`(${relayEndpoint(s.url, "server")})`)}`);
63
+ console.log(` ${describe(status)}`);
64
+ console.log(chalk.dim(" `kraftwerk remote pair` makes a link that pairs a device"));
65
+ });
66
+ remote
67
+ .command("off")
68
+ .description("Turn remote access off: the daemon leaves the relay")
69
+ .action(async () => {
70
+ await setRelay(false);
71
+ await daemonRelay("POST", { enabled: false }).catch(() => null);
72
+ console.log(`${chalk.green("✔")} remote access off ${chalk.dim("(paired devices keep their pairing; `kraftwerk devices revoke` unpairs one)")}`);
73
+ });
74
+ remote
75
+ .command("pair")
76
+ .description(`A link that pairs a device from anywhere, valid ${PAIR_CODE_TTL_MS / 60_000} minutes, once`)
77
+ .action(async () => {
78
+ const s = await readRelaySettings();
79
+ if (!s?.enabled) {
80
+ console.error(chalk.red("✖ remote access is off — `kraftwerk remote on` first"));
81
+ process.exit(1);
82
+ }
83
+ const { code } = await createPairCode();
84
+ console.log(`${chalk.green("✔")} open this on the device ${chalk.dim(`— valid ${PAIR_CODE_TTL_MS / 60_000} minutes, once`)}`);
85
+ console.log(` ${chalk.cyan(remoteLink(s.url, s.publicKey, code))}`);
86
+ console.log(chalk.dim(` The link carries this machine's key and the code ${code}; treat it like a password.`));
87
+ console.log(chalk.dim(" Or scan its QR code under Settings → Devices in the web UI."));
88
+ });
89
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The relay: reaching a machine's kraftwerk from anywhere, end to end
3
+ * encrypted. The daemon holds an outgoing socket to a relay (kraftwerk
4
+ * cloud); a client — a phone's browser, an app — opens a socket to the same
5
+ * relay naming the machine, and the relay pipes the two together. The relay
6
+ * sees who talks to which machine, when, and how much; never what.
7
+ *
8
+ * This module is both ends of the encryption and the client's half of the
9
+ * tunnel. It runs wherever WebCrypto does (Node 20+, browsers, service
10
+ * workers), so the daemon, the CLI and the phone share one implementation.
11
+ *
12
+ * Keys. The daemon has a long-lived X25519 key; its public half is the
13
+ * pairing link's trust anchor (`#k=…`), and the relay knows the machine by
14
+ * its hash (`relayId`). Each connection:
15
+ *
16
+ * client → daemon {t:"hello", v:1, key: e_c} plaintext
17
+ * daemon → client {t:"ready", key: e_d, box: seal("ready")} plaintext + sealed proof
18
+ *
19
+ * Both derive two AES-256-GCM keys (one per direction) with HKDF-SHA256 from
20
+ * DH(e_c, S_d) ‖ DH(e_c, e_d), salted with e_c ‖ S_d ‖ e_d. Only the holder
21
+ * of S_d can open the client's traffic or produce "ready" (the client knows
22
+ * it reached the right machine); e_d makes every connection's keys fresh, so
23
+ * a recorded session cannot be replayed to the daemon. Nonces are message
24
+ * counters per direction, so a dropped, repeated or reordered message
25
+ * breaks the channel instead of slipping through.
26
+ *
27
+ * The client is not authenticated by the handshake: inside the channel it
28
+ * presents a paired device's token like any network caller, and the daemon
29
+ * applies the same trust rules (src/server/trust.ts). Anyone holding the
30
+ * pairing link still needs a pairing code or a token.
31
+ */
32
+ export declare const RELAY_PROTOCOL = 1;
33
+ export declare function toBase64(bytes: Uint8Array): string;
34
+ export declare function fromBase64(text: string): Uint8Array;
35
+ export declare const toBase64Url: (bytes: Uint8Array) => string;
36
+ export declare const fromBase64Url: (text: string) => Uint8Array;
37
+ export interface KeyPair {
38
+ privateKey: CryptoKey;
39
+ /** The raw 32-byte public key. */
40
+ publicKey: Uint8Array;
41
+ }
42
+ export declare function generateKeyPair(): Promise<KeyPair>;
43
+ /** A key pair as JSON (the private half as a JWK) — what the daemon keeps on disk. */
44
+ export declare function exportKeyPair(pair: KeyPair): Promise<{
45
+ publicKey: string;
46
+ privateKey: JsonWebKey;
47
+ }>;
48
+ export declare function importKeyPair(saved: {
49
+ publicKey: string;
50
+ privateKey: JsonWebKey;
51
+ }): Promise<KeyPair>;
52
+ /** How the relay knows a machine: the hash of its public key, so the pairing link alone finds it. */
53
+ export declare function relayId(publicKey: Uint8Array): Promise<string>;
54
+ /** One connection's two directions: seal what goes out, open what comes in, each in strict order. */
55
+ export interface Channel {
56
+ seal(plaintext: string): Promise<string>;
57
+ open(sealed: string): Promise<string>;
58
+ }
59
+ export interface Hello {
60
+ t: "hello";
61
+ v: number;
62
+ key: string;
63
+ }
64
+ export interface Ready {
65
+ t: "ready";
66
+ key: string;
67
+ box: string;
68
+ }
69
+ /** The client's side: a hello to send, and `finish` to check the daemon's ready and get the channel. */
70
+ export declare function clientHandshake(daemonPublicKey: Uint8Array): Promise<{
71
+ hello: Hello;
72
+ finish(ready: Ready): Promise<Channel>;
73
+ }>;
74
+ /** The daemon's side: answer a client's hello with a ready, and get the channel. */
75
+ export declare function daemonHandshake(own: KeyPair, hello: Hello): Promise<{
76
+ ready: Ready;
77
+ channel: Channel;
78
+ }>;
79
+ /**
80
+ * Inside the channel, the client sends requests and the daemon answers them
81
+ * from its own server, streaming the body (event streams stay open):
82
+ *
83
+ * → {t:"req", id, method, url, headers, body?} body base64
84
+ * ← {t:"res", id, status, headers}
85
+ * ← {t:"data", id, chunk} base64, any number
86
+ * ← {t:"end", id} | {t:"fail", id, error}
87
+ * → {t:"abort", id} the client stopped reading
88
+ */
89
+ export type TunnelToDaemon = {
90
+ t: "req";
91
+ id: number;
92
+ method: string;
93
+ url: string;
94
+ headers: Record<string, string>;
95
+ body?: string;
96
+ } | {
97
+ t: "abort";
98
+ id: number;
99
+ };
100
+ export type TunnelToClient = {
101
+ t: "res";
102
+ id: number;
103
+ status: number;
104
+ headers: Record<string, string>;
105
+ } | {
106
+ t: "data";
107
+ id: number;
108
+ chunk: string;
109
+ } | {
110
+ t: "end";
111
+ id: number;
112
+ } | {
113
+ t: "fail";
114
+ id: number;
115
+ error: string;
116
+ };
117
+ /** The socket the client talks over — the browser's WebSocket and Node's global one both fit. */
118
+ export interface RelaySocket {
119
+ send(data: string): void;
120
+ close(code?: number, reason?: string): void;
121
+ addEventListener(type: "message", fn: (ev: {
122
+ data: unknown;
123
+ }) => void): void;
124
+ addEventListener(type: "close", fn: (ev: {
125
+ code?: number;
126
+ reason?: string;
127
+ }) => void): void;
128
+ addEventListener(type: "open" | "error", fn: () => void): void;
129
+ readonly readyState: number;
130
+ }
131
+ export interface Tunnel {
132
+ /** `fetch` through the machine: paths are the daemon's (`/w/<slug>/api/…`); hand it to createClient({fetch}). */
133
+ fetch: typeof fetch;
134
+ close(): void;
135
+ /** Resolves when the connection ends, with why. */
136
+ readonly closed: Promise<string>;
137
+ }
138
+ export interface ConnectOptions {
139
+ /** The relay's client endpoint, e.g. wss://srv.kraftwerk-cloud.netnode.cloud/api/relay/client */
140
+ relayUrl: string;
141
+ /** The machine's public key, from its pairing link. */
142
+ publicKey: Uint8Array;
143
+ /** Opens the socket (default: the global WebSocket). */
144
+ connect?: (url: string) => RelaySocket;
145
+ /** Abort the handshake after this long (default 15 s). */
146
+ timeoutMs?: number;
147
+ }
148
+ /** Connect to a machine through the relay: the handshake, then a `fetch` that goes through it. */
149
+ export declare function connectTunnel(opts: ConnectOptions): Promise<Tunnel>;
@@ -0,0 +1,318 @@
1
+ /**
2
+ * The relay: reaching a machine's kraftwerk from anywhere, end to end
3
+ * encrypted. The daemon holds an outgoing socket to a relay (kraftwerk
4
+ * cloud); a client — a phone's browser, an app — opens a socket to the same
5
+ * relay naming the machine, and the relay pipes the two together. The relay
6
+ * sees who talks to which machine, when, and how much; never what.
7
+ *
8
+ * This module is both ends of the encryption and the client's half of the
9
+ * tunnel. It runs wherever WebCrypto does (Node 20+, browsers, service
10
+ * workers), so the daemon, the CLI and the phone share one implementation.
11
+ *
12
+ * Keys. The daemon has a long-lived X25519 key; its public half is the
13
+ * pairing link's trust anchor (`#k=…`), and the relay knows the machine by
14
+ * its hash (`relayId`). Each connection:
15
+ *
16
+ * client → daemon {t:"hello", v:1, key: e_c} plaintext
17
+ * daemon → client {t:"ready", key: e_d, box: seal("ready")} plaintext + sealed proof
18
+ *
19
+ * Both derive two AES-256-GCM keys (one per direction) with HKDF-SHA256 from
20
+ * DH(e_c, S_d) ‖ DH(e_c, e_d), salted with e_c ‖ S_d ‖ e_d. Only the holder
21
+ * of S_d can open the client's traffic or produce "ready" (the client knows
22
+ * it reached the right machine); e_d makes every connection's keys fresh, so
23
+ * a recorded session cannot be replayed to the daemon. Nonces are message
24
+ * counters per direction, so a dropped, repeated or reordered message
25
+ * breaks the channel instead of slipping through.
26
+ *
27
+ * The client is not authenticated by the handshake: inside the channel it
28
+ * presents a paired device's token like any network caller, and the daemon
29
+ * applies the same trust rules (src/server/trust.ts). Anyone holding the
30
+ * pairing link still needs a pairing code or a token.
31
+ */
32
+ const subtle = () => globalThis.crypto.subtle;
33
+ const enc = new TextEncoder();
34
+ const dec = new TextDecoder();
35
+ export const RELAY_PROTOCOL = 1;
36
+ /* ---------- encoding ---------- */
37
+ export function toBase64(bytes) {
38
+ let s = "";
39
+ for (let i = 0; i < bytes.length; i += 0x8000)
40
+ s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
41
+ return btoa(s);
42
+ }
43
+ export function fromBase64(text) {
44
+ const s = atob(text);
45
+ const out = new Uint8Array(s.length);
46
+ for (let i = 0; i < s.length; i++)
47
+ out[i] = s.charCodeAt(i);
48
+ return out;
49
+ }
50
+ export const toBase64Url = (bytes) => toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
51
+ export const fromBase64Url = (text) => fromBase64(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4));
52
+ const concat = (...parts) => {
53
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
54
+ let o = 0;
55
+ for (const p of parts) {
56
+ out.set(p, o);
57
+ o += p.length;
58
+ }
59
+ return out;
60
+ };
61
+ export async function generateKeyPair() {
62
+ const pair = (await subtle().generateKey({ name: "X25519" }, true, ["deriveBits"]));
63
+ return { privateKey: pair.privateKey, publicKey: new Uint8Array(await subtle().exportKey("raw", pair.publicKey)) };
64
+ }
65
+ /** A key pair as JSON (the private half as a JWK) — what the daemon keeps on disk. */
66
+ export async function exportKeyPair(pair) {
67
+ return { publicKey: toBase64Url(pair.publicKey), privateKey: await subtle().exportKey("jwk", pair.privateKey) };
68
+ }
69
+ export async function importKeyPair(saved) {
70
+ const privateKey = await subtle().importKey("jwk", saved.privateKey, { name: "X25519" }, true, ["deriveBits"]);
71
+ return { privateKey, publicKey: fromBase64Url(saved.publicKey) };
72
+ }
73
+ const importPublic = (raw) => {
74
+ if (raw.length !== 32)
75
+ throw new Error("invalid public key");
76
+ return subtle().importKey("raw", raw, { name: "X25519" }, false, []);
77
+ };
78
+ /** X25519; WebCrypto refuses a low-order peer key (an all-zero secret). */
79
+ const dh = async (own, peer) => new Uint8Array(await subtle().deriveBits({ name: "X25519", public: await importPublic(peer) }, own, 256));
80
+ /** How the relay knows a machine: the hash of its public key, so the pairing link alone finds it. */
81
+ export async function relayId(publicKey) {
82
+ const digest = new Uint8Array(await subtle().digest("SHA-256", publicKey));
83
+ return toBase64Url(digest.subarray(0, 16));
84
+ }
85
+ async function channelKeys(ikm, salt) {
86
+ const base = await subtle().importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
87
+ const derive = (info) => subtle().deriveKey({ name: "HKDF", hash: "SHA-256", salt: salt, info: enc.encode(`kraftwerk-relay-v1 ${info}`) }, base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
88
+ return { toDaemon: await derive("to-daemon"), toClient: await derive("to-client") };
89
+ }
90
+ const nonce = (n) => {
91
+ const iv = new Uint8Array(12);
92
+ new DataView(iv.buffer).setBigUint64(4, BigInt(n));
93
+ return iv;
94
+ };
95
+ function makeChannel(out, inn) {
96
+ let sent = 0;
97
+ let received = 0;
98
+ // Sealing is async: queue it so messages keep their counter order however callers interleave.
99
+ let sealing = Promise.resolve();
100
+ let opening = Promise.resolve();
101
+ return {
102
+ seal(plaintext) {
103
+ const run = sealing.then(async () => {
104
+ const iv = nonce(sent++);
105
+ return toBase64(new Uint8Array(await subtle().encrypt({ name: "AES-GCM", iv: iv }, out, enc.encode(plaintext))));
106
+ });
107
+ sealing = run.catch(() => { });
108
+ return run;
109
+ },
110
+ open(sealed) {
111
+ const run = opening.then(async () => {
112
+ const iv = nonce(received++);
113
+ try {
114
+ return dec.decode(await subtle().decrypt({ name: "AES-GCM", iv: iv }, inn, fromBase64(sealed)));
115
+ }
116
+ catch {
117
+ throw new Error("relay message failed to decrypt — tampered, replayed or out of order");
118
+ }
119
+ });
120
+ opening = run.catch(() => { });
121
+ return run;
122
+ },
123
+ };
124
+ }
125
+ /** The client's side: a hello to send, and `finish` to check the daemon's ready and get the channel. */
126
+ export async function clientHandshake(daemonPublicKey) {
127
+ const eph = await generateKeyPair();
128
+ return {
129
+ hello: { t: "hello", v: RELAY_PROTOCOL, key: toBase64(eph.publicKey) },
130
+ async finish(ready) {
131
+ if (ready?.t !== "ready" || typeof ready.key !== "string" || typeof ready.box !== "string")
132
+ throw new Error("not a relay handshake answer");
133
+ const daemonEph = fromBase64(ready.key);
134
+ const ikm = concat(await dh(eph.privateKey, daemonPublicKey), await dh(eph.privateKey, daemonEph));
135
+ const keys = await channelKeys(ikm, concat(eph.publicKey, daemonPublicKey, daemonEph));
136
+ const channel = makeChannel(keys.toDaemon, keys.toClient);
137
+ if ((await channel.open(ready.box).catch(() => "")) !== "ready")
138
+ throw new Error("the machine did not prove its key — wrong pairing link, or not the machine you paired with");
139
+ return channel;
140
+ },
141
+ };
142
+ }
143
+ /** The daemon's side: answer a client's hello with a ready, and get the channel. */
144
+ export async function daemonHandshake(own, hello) {
145
+ if (hello?.t !== "hello" || typeof hello.key !== "string")
146
+ throw new Error("not a relay hello");
147
+ if (hello.v !== RELAY_PROTOCOL)
148
+ throw new Error(`relay protocol ${hello.v} is not supported (this kraftwerk speaks ${RELAY_PROTOCOL})`);
149
+ const clientEph = fromBase64(hello.key);
150
+ const eph = await generateKeyPair();
151
+ const ikm = concat(await dh(own.privateKey, clientEph), await dh(eph.privateKey, clientEph));
152
+ const keys = await channelKeys(ikm, concat(clientEph, own.publicKey, eph.publicKey));
153
+ const channel = makeChannel(keys.toClient, keys.toDaemon);
154
+ return { ready: { t: "ready", key: toBase64(eph.publicKey), box: await channel.seal("ready") }, channel };
155
+ }
156
+ const OPEN = 1;
157
+ /** Connect to a machine through the relay: the handshake, then a `fetch` that goes through it. */
158
+ export async function connectTunnel(opts) {
159
+ const id = await relayId(opts.publicKey);
160
+ const url = `${opts.relayUrl}${opts.relayUrl.includes("?") ? "&" : "?"}id=${encodeURIComponent(id)}`;
161
+ // The keys first: once the socket exists, its events may fire at any await — the listeners below must already be there.
162
+ const hs = await clientHandshake(opts.publicKey);
163
+ const ws = opts.connect ? opts.connect(url) : new WebSocket(url);
164
+ let channel = null;
165
+ let closedWhy = "";
166
+ let resolveClosed;
167
+ const closed = new Promise((r) => (resolveClosed = r));
168
+ const pending = new Map();
169
+ let nextId = 1;
170
+ const finish = (why) => {
171
+ if (closedWhy)
172
+ return;
173
+ closedWhy = why || "the connection closed";
174
+ for (const p of pending.values()) {
175
+ p.reject(new Error(closedWhy));
176
+ try {
177
+ p.stream?.error(new Error(closedWhy));
178
+ }
179
+ catch { }
180
+ }
181
+ pending.clear();
182
+ resolveClosed(closedWhy);
183
+ };
184
+ const ready = new Promise((resolve, reject) => {
185
+ const timer = setTimeout(() => {
186
+ reject(new Error("the machine did not answer through the relay"));
187
+ ws.close();
188
+ }, opts.timeoutMs ?? 15_000);
189
+ ws.addEventListener("open", () => ws.send(JSON.stringify(hs.hello)));
190
+ ws.addEventListener("close", (ev) => {
191
+ clearTimeout(timer);
192
+ const why = ev.reason || (ev.code === 4404 ? "the machine is not connected to the relay" : "the relay closed the connection");
193
+ reject(new Error(why));
194
+ finish(why);
195
+ });
196
+ ws.addEventListener("error", () => { });
197
+ let first = true;
198
+ // Messages are opened one after another (the channel counts them); a promise chain keeps the order.
199
+ let queue = Promise.resolve();
200
+ ws.addEventListener("message", (ev) => {
201
+ const text = typeof ev.data === "string" ? ev.data : dec.decode(ev.data);
202
+ queue = queue.then(async () => {
203
+ if (first) {
204
+ first = false;
205
+ try {
206
+ channel = await hs.finish(JSON.parse(text));
207
+ clearTimeout(timer);
208
+ resolve(channel);
209
+ }
210
+ catch (err) {
211
+ clearTimeout(timer);
212
+ reject(err);
213
+ ws.close(4400, "handshake failed");
214
+ }
215
+ return;
216
+ }
217
+ if (!channel)
218
+ return;
219
+ let msg;
220
+ try {
221
+ msg = JSON.parse(await channel.open(text));
222
+ }
223
+ catch (err) {
224
+ ws.close(4400, "bad message");
225
+ finish(err.message);
226
+ return;
227
+ }
228
+ receive(msg);
229
+ });
230
+ });
231
+ });
232
+ const receive = (msg) => {
233
+ const p = pending.get(msg.id);
234
+ if (!p)
235
+ return;
236
+ if (msg.t === "res") {
237
+ const body = new ReadableStream({
238
+ start: (c) => {
239
+ p.stream = c;
240
+ },
241
+ cancel: () => {
242
+ pending.delete(msg.id);
243
+ void send({ t: "abort", id: msg.id });
244
+ },
245
+ });
246
+ const status = msg.status;
247
+ // A Response cannot carry a body for these; the stream stays unused.
248
+ const noBody = status === 204 || status === 304 || (status >= 100 && status < 200);
249
+ p.resolve(new Response(noBody ? null : body, { status, headers: msg.headers }));
250
+ }
251
+ else if (msg.t === "data") {
252
+ try {
253
+ p.stream?.enqueue(fromBase64(msg.chunk));
254
+ }
255
+ catch { }
256
+ }
257
+ else if (msg.t === "end") {
258
+ pending.delete(msg.id);
259
+ try {
260
+ p.stream?.close();
261
+ }
262
+ catch { }
263
+ }
264
+ else if (msg.t === "fail") {
265
+ pending.delete(msg.id);
266
+ const err = new Error(msg.error);
267
+ p.reject(err);
268
+ try {
269
+ p.stream?.error(err);
270
+ }
271
+ catch { }
272
+ }
273
+ };
274
+ const send = async (msg) => {
275
+ if (!channel || ws.readyState !== OPEN)
276
+ return;
277
+ ws.send(await channel.seal(JSON.stringify(msg)));
278
+ };
279
+ await ready;
280
+ const tunnelFetch = async (input, init) => {
281
+ if (closedWhy)
282
+ throw new Error(closedWhy);
283
+ const req = new Request(typeof input === "string" && input.startsWith("/") ? `http://relay${input}` : input, init);
284
+ const u = new URL(req.url);
285
+ const headers = {};
286
+ req.headers.forEach((v, k) => (headers[k] = v));
287
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : new Uint8Array(await req.arrayBuffer());
288
+ const id = nextId++;
289
+ return new Promise((resolve, reject) => {
290
+ pending.set(id, { resolve, reject, signal: req.signal });
291
+ req.signal?.addEventListener("abort", () => {
292
+ const p = pending.get(id);
293
+ if (!p)
294
+ return;
295
+ pending.delete(id);
296
+ const err = new DOMException("aborted", "AbortError");
297
+ p.reject(err);
298
+ try {
299
+ p.stream?.error(err);
300
+ }
301
+ catch { }
302
+ void send({ t: "abort", id });
303
+ });
304
+ send({ t: "req", id, method: req.method, url: u.pathname + u.search, headers, ...(body?.length ? { body: toBase64(body) } : {}) }).catch((err) => {
305
+ pending.delete(id);
306
+ reject(err);
307
+ });
308
+ });
309
+ };
310
+ return {
311
+ fetch: tunnelFetch,
312
+ close: () => {
313
+ ws.close(1000, "bye");
314
+ finish("closed");
315
+ },
316
+ closed,
317
+ };
318
+ }