realtimeclipboard 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Akshay Nikhare
3
+ Copyright (c) 2026 CadNative
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -16,9 +16,9 @@ touch the server.
16
16
  (never touch the server)
17
17
  ```
18
18
 
19
- **Status: pre-alpha, and it works end to end.** The relay is deployed and live,
19
+ **Status: beta, and it works end to end.** The relay is deployed and live,
20
20
  the frontend is wired to it over a WebSocket with an SSE fallback, and the
21
- 17-check end-to-end suite runs two real peers with real crypto against the
21
+ 32-check end-to-end suite runs two real peers with real crypto against the
22
22
  production relay. See [docs/M0-RESULTS.md](docs/M0-RESULTS.md) for exactly what
23
23
  is proven, and [Known limitations](#known-limitations) for what is not.
24
24
 
@@ -29,8 +29,8 @@ is proven, and [Known limitations](#known-limitations) for what is not.
29
29
  - **Sync clipboard text between devices** — Windows, macOS, Android, ChromeOS and Linux
30
30
  - **Works across different networks**, not just the same Wi-Fi, and not just the same LAN
31
31
  - **No account, no sign-up, no email** — a short key is the whole identity of a session
32
- (six characters on the web, ten in the installed apps, and either works on both)
33
- - **End-to-end encrypted** in the browser with AES-GCM; `PBKDF2` derives the key, `SHA-256` routes the room
32
+ (ten characters on the web, sixteen in the installed apps, and either works on both)
33
+ - **End-to-end encrypted** in the browser with AES-GCM; one `PBKDF2` derivation produces both the key and the room address
34
34
  - **Peer-to-peer file transfer** over a WebRTC data channel, 5 MB per file
35
35
  - **Copy and paste images** — a screenshot copied on one machine previews on the other
36
36
  - **Installable progressive web app** — own window, own icon, works offline
@@ -71,7 +71,7 @@ welcome — open an issue.
71
71
  | Text | WebSocket through the relay, AES-GCM encrypted in the browser |
72
72
  | Blocked networks | If a proxy eats the WebSocket, the client moves itself to SSE + POST on the same host and says so |
73
73
  | Files | WebRTC data channel, direct between peers, 5 MB cap |
74
- | Key | `SHA-256(key)` routes the room; `PBKDF2(key)` encrypts. The key itself is never transmitted |
74
+ | Key | `PBKDF2(key)` is expanded with HKDF into the AES key and the room address. Neither the key nor anything reversible to it is transmitted |
75
75
 
76
76
  The relay only ever sees a room hash and ciphertext. It cannot decrypt anything,
77
77
  and it stores nothing beyond the last message in RAM.
@@ -208,13 +208,14 @@ message format the changelog is generated from are in
208
208
  ```bash
209
209
  npm run verify # what the pre-commit hook runs
210
210
  npm test # everything, needs a relay
211
- npm run release -- minor # verify, changelog, tag, push, deploy
211
+ npm run release -- minor # verify, changelog, release PR, tag, deploy
212
212
  ```
213
213
 
214
214
  ## Docs
215
215
 
216
216
  | Doc | What it covers |
217
217
  |---|---|
218
+ | [THREAT-MODEL.md](docs/THREAT-MODEL.md) | **What is protected and what is not, with the arithmetic.** Read this before trusting the encryption claim |
218
219
  | [PRD.md](docs/PRD.md) | Requirements, architecture, security model, open issues |
219
220
  | [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Running it locally, the test suite, the landing-page grid and globe, and the traps |
220
221
  | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Module layout, boundaries, and how to add a feature |
@@ -236,6 +237,11 @@ npm run release -- minor # verify, changelog, tag, push, deploy
236
237
  - **P2P file transfer may fail on corporate networks**, which block the UDP that
237
238
  WebRTC needs. Falls back to relay-chunked transfer, labelled visibly.
238
239
  - **The share key is a bearer credential.** Anyone holding it can read the session.
240
+ - **Share links created before v0.5.0 no longer work.** The room hash is now
241
+ derived through the same 250k PBKDF2 as the encryption key, and generated keys
242
+ went from 6 characters to 10. Both were needed to stop the relay operator being
243
+ able to reverse a room hash back to a share key — the reasoning and the
244
+ arithmetic are in [THREAT-MODEL.md §4](docs/THREAT-MODEL.md).
239
245
  - Chromium-first. Firefox and Safari can receive and can send via paste, but
240
246
  cannot silently read the clipboard.
241
247
 
@@ -33,6 +33,7 @@ import * as relay from "../src/transport/relay.js";
33
33
  import * as proto from "../src/transport/protocol.js";
34
34
  import { on, EV } from "../src/core/bus.js";
35
35
  import { DEFAULT_RELAY_URL, normaliseRelay, TEXT, LOCK } from "../src/core/config.js";
36
+ import { INVISIBLE_SOURCE } from "../src/core/text.js";
36
37
 
37
38
  /**
38
39
  * Read from package.json rather than written here.
@@ -114,6 +115,49 @@ ever sees a room hash and ciphertext.`;
114
115
 
115
116
  /* ---------------------------------------------------------------- output -- */
116
117
 
118
+ /**
119
+ * Escape sequences, stripped from clip text before it reaches a terminal.
120
+ *
121
+ * A clip is written by whoever else is in the room, and printing it raw hands
122
+ * them the terminal's own control channel. That is not theoretical: OSC 52 sets
123
+ * the TERMINAL's clipboard, so `realtimeclipboard watch` printing an untrusted
124
+ * clip is a pastejacking primitive against the machine running it — the exact
125
+ * attack src/clipboard/guard.js exists to stop in the browser. Others retitle
126
+ * the window, or use a bare CR to redraw the tail of a line over its head so
127
+ * that what is displayed is not what was sent.
128
+ *
129
+ * Built from strings so this file contains no literal control characters — a
130
+ * literal one is invisible in a diff, which is the property being defended
131
+ * against.
132
+ *
133
+ * CSI ESC [ … final byte colours, cursor movement
134
+ * OSC ESC ] … BEL or ST window title, and clipboard writes
135
+ * two-character escapes, then any ESC left over
136
+ * INVISIBLE_SOURCE the single characters, shared with the browser
137
+ * halves via core/text.js. A terminal needs the
138
+ * sequences above ON TOP of that class, not instead
139
+ * of it, which is why this composes rather than
140
+ * redefining the ranges.
141
+ */
142
+ const TERMINAL_UNSAFE = new RegExp([
143
+ "\\u001B\\[[0-9;?]*[ -/]*[@-~]",
144
+ "\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)",
145
+ "\\u001B[@-Z\\\\-_]",
146
+ "\\u001B",
147
+ INVISIBLE_SOURCE,
148
+ ].join("|"), "g");
149
+
150
+ /**
151
+ * Only when stdout is a terminal.
152
+ *
153
+ * A pipe is not a terminal and has no control channel to hijack, and the
154
+ * documented uses of this tool are pipes — `watch KEY > clip.txt`,
155
+ * `--json | jq`. Rewriting bytes there would corrupt the payload to defend
156
+ * against a threat that is not present. `--json` is additionally safe by
157
+ * construction: JSON.stringify escapes every one of these.
158
+ */
159
+ const forTerminal = text => (stdout.isTTY ? text.replace(TERMINAL_UNSAFE, "") : text);
160
+
117
161
  const note = (msg, opts) => { if (!opts.quiet) stderr.write(`${msg}\n`); };
118
162
 
119
163
  function die(msg, code = 1) {
@@ -138,13 +182,8 @@ async function derive(rawKey, pin) {
138
182
  const d = await cryptoBox.deriveLocked(key, clean);
139
183
  return { key, roomHash: d.roomHash, aesKey: d.aesKey, auth: d.authToken, locked: true };
140
184
  }
141
- return {
142
- key,
143
- roomHash: await cryptoBox.roomHash(key),
144
- aesKey: await cryptoBox.deriveKey(key),
145
- auth: null,
146
- locked: false,
147
- };
185
+ const open = await cryptoBox.deriveOpen(key);
186
+ return { key, roomHash: open.roomHash, aesKey: open.aesKey, auth: null, locked: false };
148
187
  }
149
188
 
150
189
  /**
@@ -232,7 +271,7 @@ const readStdin = () => new Promise(resolve => {
232
271
  const emitClip = (text, msg, opts) => stdout.write(
233
272
  opts.json
234
273
  ? `${JSON.stringify({ text, seq: msg.seq ?? null, origin: msg.originId ?? null, receivedAt: new Date().toISOString() })}\n`
235
- : (text.endsWith("\n") ? text : `${text}\n`),
274
+ : (t => t.endsWith("\n") ? t : `${t}\n`)(forTerminal(text)),
236
275
  );
237
276
 
238
277
  /* ------------------------------------------------------------------ main -- */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimeclipboard",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Live clipboard sharing. Static frontend of native ES modules — development needs no build, and `npm run build` exists only to assemble the deploy. Nothing here is needed to READ the app.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "node": ">=22"
35
35
  },
36
36
  "scripts": {
37
- "verify": "node tests/unit/static-check.mjs && node tests/unit/relay-url.mjs && node tests/unit/lock.mjs && node tests/unit/files.mjs && node tests/unit/transfer.mjs && node tests/unit/clipsize.mjs && node tests/unit/syncmode.mjs && node tests/dom/dialog.mjs && node tests/dom/whatsnew.mjs && node tests/dom/tiles.mjs && node tests/dom/guide.mjs && node tests/dom/editor.mjs && node tests/dom/offer.mjs",
37
+ "verify": "node tests/unit/static-check.mjs && node tests/unit/relay-url.mjs && node tests/unit/lock.mjs && node tests/unit/files.mjs && node tests/unit/transfer.mjs && node tests/unit/clipsize.mjs && node tests/unit/syncmode.mjs && node tests/unit/pasteguard.mjs && node tests/dom/dialog.mjs && node tests/dom/whatsnew.mjs && node tests/dom/tiles.mjs && node tests/dom/guide.mjs && node tests/dom/editor.mjs && node tests/dom/offer.mjs",
38
38
  "test": "npm run verify && node tests/dom/bundle.mjs && node tests/live/e2e.mjs && node tests/live/boot.mjs && node tests/live/boot.mjs --locked && node tests/live/fallback.mjs",
39
39
  "test:static": "node tests/unit/static-check.mjs",
40
40
  "test:lock": "node tests/unit/lock.mjs && node tests/dom/dialog.mjs",
@@ -49,7 +49,10 @@
49
49
  "build:desktop": "node tools/build/build.mjs _desktop --desktop",
50
50
  "build:og": "python tools/build/build-og-card.py",
51
51
  "check:og": "python tools/build/build-og-card.py --check",
52
+ "build:icons": "python tools/build/build-icons.py",
53
+ "check:icons": "python tools/build/build-icons.py --check",
52
54
  "check:csp": "node tools/build/build.mjs _site && node tools/check/csp-check.mjs",
55
+ "seo:indexnow": "node tools/seo/indexnow.mjs",
53
56
  "relay": "python -m uvicorn main:app --app-dir backend --port 8000",
54
57
  "relay:up": "node tools/release/relay-up.mjs",
55
58
  "changelog": "node tools/release/changelog.mjs",
@@ -27,5 +27,12 @@ runs these exact modules. Two consequences:
27
27
  - Event names are `EV.*` constants in `bus.js`. Adding an event means adding a constant.
28
28
  - A bus event that **reports** is never named the same as one that **commands** — `EV.LOCK_STATE`
29
29
  and `"session:lock"` once shared a name and every `setKey()` opened the PIN dialog by itself.
30
- - The share key and the session PIN are never logged, never stored, never transmitted. Only
31
- `SHA-256(key)` and PBKDF2/HKDF output leave this directory.
30
+ - The share key and the session PIN are never logged and never transmitted. Only `SHA-256(key)` and
31
+ PBKDF2/HKDF output leave this directory.
32
+ - **The PIN is never stored. The share key is, and that is a decision with a switch on it.** This
33
+ file used to say neither was, and it was wrong about the key for as long as `saveLastKey()` has
34
+ existed — it writes the key to `localStorage` in plain text so a relaunch can offer the room back
35
+ (FR-1.7). `rememberKey` in `state.js` governs it, and turning it off removes what is already
36
+ there. The active session lives in `sessionStorage` and dies with the tab.
37
+ - `text.js` is a security character class with three consumers that may not import each other. It
38
+ belongs here for that reason, and the ranges in it are not a style preference — see the comment.
package/src/core/bus.js CHANGED
@@ -1,9 +1,7 @@
1
1
  /**
2
- * Tiny pub/sub. The only way modules talk to each other.
3
- *
4
- * The rule that keeps this codebase modular: UI modules never import transport
5
- * or clipboard modules, and vice versa. They publish and subscribe to events
6
- * here. main.js is the only file that knows the full graph.
2
+ * Tiny pub/sub. The only way modules talk to each other — UI never imports
3
+ * transport or clipboard, and vice versa. main.js is the only file that knows
4
+ * the full graph.
7
5
  */
8
6
 
9
7
  const listeners = new Map();
@@ -33,16 +31,11 @@ export const EV = {
33
31
  // clipboard
34
32
  TEXT_CAPTURED: "text:captured", // {text, how} — a clip settled here, needs sending
35
33
  TEXT_RECEIVED: "text:received", // {text, from} — a clip arrived from a peer
36
- /**
37
- * Typing, not clips. {text, caret} out, {text, caret, name, from} in.
38
- *
39
- * The pair above is the COMMIT channel: a clip that settled, bound for
40
- * history, the OS clipboard and the size cap. This pair is the VIEW channel —
41
- * what the text looks like mid-keystroke. Keeping them apart is what stops a
42
- * streamed sentence becoming six history entries and six clipboard writes.
43
- */
44
- TEXT_TYPED: "text:typed",
45
- TEXT_STREAMED: "text:streamed",
34
+ // The VIEW channel — what the text looks like mid-keystroke. The pair above is
35
+ // the COMMIT channel. Keeping them apart is what stops a streamed sentence
36
+ // becoming six history entries and six clipboard writes.
37
+ TEXT_TYPED: "text:typed", // {text, caret} out
38
+ TEXT_STREAMED: "text:streamed", // {text, caret, name, from} in
46
39
  TIER_CHANGED: "clipboard:tier", // {tier, note}
47
40
  PENDING_CLIP: "clipboard:pending",// {pending, text} — arrived while unfocused
48
41
  CLIP_OFFERED: "clipboard:offered",// {text} — arrived, but the editor has unsent work
@@ -63,29 +56,13 @@ export const EV = {
63
56
 
64
57
  // session
65
58
  KEY_CHANGED: "session:key", // {key, locked}
66
- /**
67
- * {locked, verified} — see core/crypto.js.
68
- *
69
- * "lockstate", not "lock", and the extra syllable is load-bearing. The name
70
- * used to be "session:lock", which is ALSO the imperative the UI emits to
71
- * mean "lock this session" (main.js). One name, two opposite meanings: a
72
- * report and an order. So every state.setKey() — one per session, on every
73
- * single boot — delivered a report to the handler that acts on the order,
74
- * and the app opened the "Lock this session" PIN dialog by itself the moment
75
- * it started. Nothing failed, nothing was logged, and the dialog looked
76
- * exactly like a feature.
77
- *
78
- * Announcements are past tense here (`session:key`, `peers:changed`) and
79
- * commands are bare verbs (`session:lock`, `session:leave`). Keep it that
80
- * way; the bus does no namespacing of its own and a collision is silent.
81
- */
82
- LOCK_STATE: "session:lockstate",
83
- /**
84
- * {required} — a locked link is open and its PIN has not been given, so there
85
- * is no session and nothing behind the UI is connected to anything. Reported
86
- * separately from LOCK_STATE because `state.locked` is still false at that
87
- * point: no session was ever opened to be locked.
88
- */
59
+ // Announcements are past tense, commands are bare verbs, and the extra
60
+ // syllable here is load-bearing — see core/CLAUDE.md. The bus does no
61
+ // namespacing of its own, so a collision between the two is silent.
62
+ LOCK_STATE: "session:lockstate",// {locked, verified} see core/crypto.js
63
+ // {required} a locked link is open and its PIN has not been given. Separate
64
+ // from LOCK_STATE because `state.locked` is still false: no session was ever
65
+ // opened to be locked.
89
66
  LOCK_REQUIRED: "session:lockrequired",
90
67
  FOUNDER: "session:founder", // {founder} — first into this room? null = not yet known
91
68