fied 0.2.3 → 0.2.5

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 (3) hide show
  1. package/README.md +71 -0
  2. package/dist/bin.js +110 -15
  3. package/package.json +3 -2
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # fied
2
+
3
+ Share your tmux session in the browser. End-to-end encrypted.
4
+
5
+ ```
6
+ npx fied
7
+ ```
8
+
9
+ You get a link. Anyone with the link sees your terminal in real time and can type into it. The server never sees your data — the encryption key lives in the URL fragment (`#`), which never leaves the browser.
10
+
11
+ ## How it works
12
+
13
+ ```
14
+ tmux on your machine
15
+ ↕ AES-256-GCM encrypted WebSocket
16
+ fied.app relay (sees only opaque bytes)
17
+ ↕ AES-256-GCM encrypted WebSocket
18
+ Browser viewer (decrypts with key from URL #fragment)
19
+ ```
20
+
21
+ 1. `npx fied` attaches to your tmux session and generates a 256-bit AES key
22
+ 2. You get a URL like `https://fied.app/s/a1b2c3d4#<key>`
23
+ 3. The viewer opens the link, decrypts in-browser, renders via xterm.js
24
+ 4. Keystrokes travel back the same encrypted path — fully interactive
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ # Share the only tmux session (auto-detected)
30
+ npx fied
31
+
32
+ # Share a specific session
33
+ npx fied -s mysession
34
+
35
+ # Use a custom relay (self-hosted)
36
+ npx fied --relay https://your-relay.com
37
+ ```
38
+
39
+ Running `npx fied` with no active share session opens a picker to select a tmux session. After the link is shown, you can send it to the background and manage active shares by running `npx fied` again.
40
+
41
+ ## Requirements
42
+
43
+ - Node.js 18+
44
+ - A running tmux session
45
+
46
+ ## Features
47
+
48
+ - **End-to-end encrypted** — AES-256-GCM, key never reaches the server
49
+ - **Interactive** — viewers can type, not just watch
50
+ - **Background mode** — share persists after closing the terminal
51
+ - **Session management** — list, stop, or reconnect to active shares
52
+ - **Mobile support** — works on phones and tablets
53
+ - **Up to 5 concurrent viewers** per session
54
+ - **Auto-reconnect** — survives brief network interruptions
55
+ - **Zero config** — just `npx fied`
56
+
57
+ ## Security
58
+
59
+ - AES-256-GCM with random IV per message
60
+ - Key placed in URL fragment — [never sent to the server](https://datatracker.ietf.org/doc/html/rfc3986#section-3.5)
61
+ - Relay forwards opaque binary blobs
62
+ - All traffic over WSS (TLS)
63
+ - Rate-limited session creation
64
+
65
+ ## Self-hosting
66
+
67
+ The relay is a Cloudflare Worker. See the [repo](https://github.com/gstohl/fied) for deployment instructions.
68
+
69
+ ## License
70
+
71
+ MIT
package/dist/bin.js CHANGED
@@ -34,13 +34,21 @@ async function importKey(rawKey) {
34
34
  function generateIV() {
35
35
  return getRandomValues(new Uint8Array(IV_LENGTH));
36
36
  }
37
- async function encrypt(key, plaintext) {
37
+ async function encrypt(key, plaintext, additionalData) {
38
38
  const iv = generateIV();
39
- const encrypted = await getSubtleCrypto().encrypt({ name: ALGORITHM, iv }, key, plaintext);
39
+ const algorithm = { name: ALGORITHM, iv };
40
+ if (additionalData) {
41
+ algorithm.additionalData = additionalData;
42
+ }
43
+ const encrypted = await getSubtleCrypto().encrypt(algorithm, key, plaintext);
40
44
  return { iv, ciphertext: new Uint8Array(encrypted) };
41
45
  }
42
- async function decrypt(key, iv, ciphertext) {
43
- const decrypted = await getSubtleCrypto().decrypt({ name: ALGORITHM, iv }, key, ciphertext);
46
+ async function decrypt(key, iv, ciphertext, additionalData) {
47
+ const algorithm = { name: ALGORITHM, iv };
48
+ if (additionalData) {
49
+ algorithm.additionalData = additionalData;
50
+ }
51
+ const decrypted = await getSubtleCrypto().decrypt(algorithm, key, ciphertext);
44
52
  return new Uint8Array(decrypted);
45
53
  }
46
54
  function toBase64Url(bytes) {
@@ -124,13 +132,14 @@ function attachSession(sessionName, cols, rows) {
124
132
  }
125
133
 
126
134
  // src/store.ts
127
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
135
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
128
136
  import { join } from "node:path";
129
137
  import { homedir } from "node:os";
130
138
  var STORE_DIR = join(homedir(), ".fied");
131
139
  var STORE_FILE = join(STORE_DIR, "sessions.json");
132
140
  function ensureDir() {
133
- mkdirSync(STORE_DIR, { recursive: true });
141
+ mkdirSync(STORE_DIR, { recursive: true, mode: 448 });
142
+ chmodSync(STORE_DIR, 448);
134
143
  }
135
144
  function isAlive(pid) {
136
145
  try {
@@ -144,8 +153,29 @@ function loadSessions() {
144
153
  try {
145
154
  const raw = readFileSync(STORE_FILE, "utf-8");
146
155
  const entries = JSON.parse(raw);
147
- const alive = entries.filter((e) => isAlive(e.pid));
148
- if (alive.length !== entries.length) {
156
+ const normalized = [];
157
+ let needsRewrite = false;
158
+ for (const entry of entries) {
159
+ if (!entry || typeof entry !== "object") continue;
160
+ if (typeof entry.pid !== "number") continue;
161
+ if (typeof entry.tmuxSession !== "string") continue;
162
+ if (typeof entry.relay !== "string") continue;
163
+ if (typeof entry.startedAt !== "string") continue;
164
+ const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : extractSessionIdFromUrl(entry.url);
165
+ if (typeof entry.sessionId !== "string" || typeof entry.url === "string") {
166
+ needsRewrite = true;
167
+ }
168
+ if (!sessionId) continue;
169
+ normalized.push({
170
+ pid: entry.pid,
171
+ tmuxSession: entry.tmuxSession,
172
+ sessionId,
173
+ relay: entry.relay,
174
+ startedAt: entry.startedAt
175
+ });
176
+ }
177
+ const alive = normalized.filter((e) => isAlive(e.pid));
178
+ if (alive.length !== entries.length || needsRewrite) {
149
179
  saveSessions(alive);
150
180
  }
151
181
  return alive;
@@ -155,7 +185,8 @@ function loadSessions() {
155
185
  }
156
186
  function saveSessions(entries) {
157
187
  ensureDir();
158
- writeFileSync(STORE_FILE, JSON.stringify(entries, null, 2));
188
+ writeFileSync(STORE_FILE, JSON.stringify(entries, null, 2), { mode: 384 });
189
+ chmodSync(STORE_FILE, 384);
159
190
  }
160
191
  function addSession(entry) {
161
192
  const entries = loadSessions();
@@ -176,6 +207,16 @@ function stopSession(pid) {
176
207
  return false;
177
208
  }
178
209
  }
210
+ function extractSessionIdFromUrl(url) {
211
+ if (typeof url !== "string") return null;
212
+ try {
213
+ const parsed = new URL(url);
214
+ const match = parsed.pathname.match(/^\/s\/([A-Za-z0-9_-]{8,64})$/);
215
+ return match ? match[1] : null;
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
179
220
 
180
221
  // src/index.ts
181
222
  var DEFAULT_RELAY = "https://fied.app";
@@ -188,6 +229,8 @@ var RESIZE_MAX_COLS = 1e3;
188
229
  var RESIZE_MIN_ROWS = 5;
189
230
  var RESIZE_MAX_ROWS = 300;
190
231
  var MAX_INVALID_RESIZE_FRAMES = 5;
232
+ var MAX_INVALID_INPUT_FRAMES = 5;
233
+ var MAX_RECENT_INPUT_NONCES = 2048;
191
234
  var RECONNECT_BASE_MS = 1e3;
192
235
  var RECONNECT_MAX_MS = 3e4;
193
236
  async function share(options) {
@@ -233,10 +276,14 @@ async function share(options) {
233
276
  const bridge = new RelayBridge(relayTarget, cryptoKey, keyFragment, pty, options.background, options.sessionId);
234
277
  const onUrl = (url) => {
235
278
  if (options.background) {
279
+ const sessionId = bridge.getSessionId();
280
+ if (!sessionId) {
281
+ throw new Error("missing session id for background mode");
282
+ }
236
283
  addSession({
237
284
  pid: process.pid,
238
285
  tmuxSession: targetSession,
239
- url,
286
+ sessionId,
240
287
  relay: relayTarget.httpBase.toString(),
241
288
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
242
289
  });
@@ -307,6 +354,9 @@ async function createSession(relayHttpBase) {
307
354
  return data.sessionId;
308
355
  }
309
356
  var WS_CONNECT_TIMEOUT_MS = 1e4;
357
+ function typeAAD(type) {
358
+ return new Uint8Array([type & 255]);
359
+ }
310
360
  var RelayBridge = class {
311
361
  constructor(relayTarget, key, keyFragment, pty, silent = false, sessionId) {
312
362
  this.relayTarget = relayTarget;
@@ -331,6 +381,12 @@ var RelayBridge = class {
331
381
  sessionId = null;
332
382
  onUrl = null;
333
383
  invalidResizeFrames = 0;
384
+ invalidInputFrames = 0;
385
+ seenInputNonces = /* @__PURE__ */ new Set();
386
+ inputNonceOrder = [];
387
+ getSessionId() {
388
+ return this.sessionId;
389
+ }
334
390
  async connect(onUrl) {
335
391
  if (this.destroyed) return;
336
392
  if (onUrl) {
@@ -387,10 +443,20 @@ var RelayBridge = class {
387
443
  const data = new Uint8Array(raw);
388
444
  const frame = parseFrame(data);
389
445
  if (frame.type === MSG_TERMINAL_INPUT) {
390
- const plaintext = await decrypt(this.key, frame.iv, frame.ciphertext);
391
- this.pty.write(this.decoder.decode(plaintext));
446
+ const plaintext = await decrypt(this.key, frame.iv, frame.ciphertext, typeAAD(frame.type));
447
+ const input = parseInputPayload(this.decoder.decode(plaintext));
448
+ if (!input || this.isReplayNonce(input.nonce)) {
449
+ this.invalidInputFrames += 1;
450
+ if (this.invalidInputFrames >= MAX_INVALID_INPUT_FRAMES) {
451
+ ws.close(1008, "invalid input frames");
452
+ }
453
+ return;
454
+ }
455
+ this.invalidInputFrames = 0;
456
+ this.rememberInputNonce(input.nonce);
457
+ this.pty.write(input.data);
392
458
  } else if (frame.type === MSG_RESIZE) {
393
- const plaintext = await decrypt(this.key, frame.iv, frame.ciphertext);
459
+ const plaintext = await decrypt(this.key, frame.iv, frame.ciphertext, typeAAD(frame.type));
394
460
  const resize = parseResizePayload(this.decoder.decode(plaintext));
395
461
  if (!resize) {
396
462
  this.invalidResizeFrames += 1;
@@ -449,7 +515,7 @@ var RelayBridge = class {
449
515
  async sendEncrypted(type, plaintext) {
450
516
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
451
517
  try {
452
- const { iv, ciphertext } = await encrypt(this.key, plaintext);
518
+ const { iv, ciphertext } = await encrypt(this.key, plaintext, typeAAD(type));
453
519
  const frame = frameMessage(type, iv, ciphertext);
454
520
  this.ws.send(frame);
455
521
  } catch (err) {
@@ -459,7 +525,34 @@ var RelayBridge = class {
459
525
  }
460
526
  }
461
527
  }
528
+ isReplayNonce(nonce) {
529
+ return this.seenInputNonces.has(nonce);
530
+ }
531
+ rememberInputNonce(nonce) {
532
+ this.seenInputNonces.add(nonce);
533
+ this.inputNonceOrder.push(nonce);
534
+ while (this.inputNonceOrder.length > MAX_RECENT_INPUT_NONCES) {
535
+ const dropped = this.inputNonceOrder.shift();
536
+ if (!dropped) break;
537
+ this.seenInputNonces.delete(dropped);
538
+ }
539
+ }
462
540
  };
541
+ function parseInputPayload(payload) {
542
+ let parsed;
543
+ try {
544
+ parsed = JSON.parse(payload);
545
+ } catch {
546
+ return null;
547
+ }
548
+ if (!parsed || typeof parsed !== "object") return null;
549
+ const typed = parsed;
550
+ if (typeof typed.nonce !== "string" || typed.nonce.length < 8 || typed.nonce.length > 64) {
551
+ return null;
552
+ }
553
+ if (typeof typed.data !== "string") return null;
554
+ return { nonce: typed.nonce, data: typed.data };
555
+ }
463
556
  function parseResizePayload(payload) {
464
557
  let parsed;
465
558
  try {
@@ -469,6 +562,7 @@ function parseResizePayload(payload) {
469
562
  }
470
563
  if (!parsed || typeof parsed !== "object") return null;
471
564
  const typed = parsed;
565
+ if (typeof typed.nonce !== "string" || typed.nonce.length < 8 || typed.nonce.length > 64) return null;
472
566
  if (!Number.isInteger(typed.cols) || !Number.isInteger(typed.rows)) return null;
473
567
  const cols = typed.cols;
474
568
  const rows = typed.rows;
@@ -604,7 +698,8 @@ async function main() {
604
698
  const s = active[i];
605
699
  const age = timeSince(new Date(s.startedAt));
606
700
  console.error(` \x1B[36m${i + 1}\x1B[0m) \x1B[1m${s.tmuxSession}\x1B[0m ${age} ago`);
607
- console.error(` \x1B[4m\x1B[36m${s.url}\x1B[0m`);
701
+ console.error(` relay: ${s.relay}`);
702
+ console.error(` session: ${s.sessionId}`);
608
703
  }
609
704
  const action = await pickManageAction(active.length);
610
705
  if (action === "q") {
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "fied",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Share your tmux session in the browser with end-to-end encryption",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "fied": "./dist/bin.js"
8
8
  },
9
9
  "files": [
10
- "dist/bin.js"
10
+ "dist/bin.js",
11
+ "README.md"
11
12
  ],
12
13
  "scripts": {
13
14
  "build": "node build.mjs",