realtimeclipboard 0.3.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
@@ -1,7 +1,7 @@
1
1
  # RealtimeClipboard — an end-to-end encrypted online clipboard that syncs text between devices
2
2
 
3
3
  RealtimeClipboard is a free, open-source online clipboard: open it on two devices, type the
4
- same five-character key, and whatever you copy on one is ready to paste on the
4
+ same short key, and whatever you copy on one is ready to paste on the
5
5
  other. No account, no install, no database. Files travel peer-to-peer and never
6
6
  touch the server.
7
7
 
@@ -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
 
@@ -28,8 +28,9 @@ is proven, and [Known limitations](#known-limitations) for what is not.
28
28
 
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
- - **No account, no sign-up, no email** — a five-character key is the whole identity of a session
32
- - **End-to-end encrypted** in the browser with AES-GCM; `PBKDF2` derives the key, `SHA-256` routes the room
31
+ - **No account, no sign-up, no email** — a short key is the whole identity of a session
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
33
34
  - **Peer-to-peer file transfer** over a WebRTC data channel, 5 MB per file
34
35
  - **Copy and paste images** — a screenshot copied on one machine previews on the other
35
36
  - **Installable progressive web app** — own window, own icon, works offline
@@ -40,7 +41,7 @@ is proven, and [Known limitations](#known-limitations) for what is not.
40
41
 
41
42
  Moving a snippet between a work laptop, a desktop and a phone is
42
43
  disproportionately annoying. The alternatives want an account, an install with
43
- admin rights, or an email to yourself. This wants a five-character key.
44
+ admin rights, or an email to yourself. This wants a short key.
44
45
 
45
46
  ## How RealtimeClipboard compares to Snapdrop, PairDrop, LocalSend and AirDrop
46
47
 
@@ -70,7 +71,7 @@ welcome — open an issue.
70
71
  | Text | WebSocket through the relay, AES-GCM encrypted in the browser |
71
72
  | Blocked networks | If a proxy eats the WebSocket, the client moves itself to SSE + POST on the same host and says so |
72
73
  | Files | WebRTC data channel, direct between peers, 5 MB cap |
73
- | 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 |
74
75
 
75
76
  The relay only ever sees a room hash and ciphertext. It cannot decrypt anything,
76
77
  and it stores nothing beyond the last message in RAM.
@@ -85,7 +86,7 @@ with a short key, and share a single clipboard between them.
85
86
 
86
87
  ### How do I sync my clipboard between my phone and my PC?
87
88
 
88
- Open RealtimeClipboard on both, type the same five-character key on each, and copy
89
+ Open RealtimeClipboard on both, type the same short key on each, and copy
89
90
  something. It arrives on the other device ready to paste. Nothing to install, so
90
91
  it works on a machine where you do not have admin rights.
91
92
 
@@ -207,13 +208,14 @@ message format the changelog is generated from are in
207
208
  ```bash
208
209
  npm run verify # what the pre-commit hook runs
209
210
  npm test # everything, needs a relay
210
- npm run release -- minor # verify, changelog, tag, push, deploy
211
+ npm run release -- minor # verify, changelog, release PR, tag, deploy
211
212
  ```
212
213
 
213
214
  ## Docs
214
215
 
215
216
  | Doc | What it covers |
216
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 |
217
219
  | [PRD.md](docs/PRD.md) | Requirements, architecture, security model, open issues |
218
220
  | [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Running it locally, the test suite, the landing-page grid and globe, and the traps |
219
221
  | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Module layout, boundaries, and how to add a feature |
@@ -235,6 +237,11 @@ npm run release -- minor # verify, changelog, tag, push, deploy
235
237
  - **P2P file transfer may fail on corporate networks**, which block the UDP that
236
238
  WebRTC needs. Falls back to relay-chunked transfer, labelled visibly.
237
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).
238
245
  - Chromium-first. Firefox and Safari can receive and can send via paste, but
239
246
  cannot silently read the clipboard.
240
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.
@@ -55,7 +56,14 @@ const VERSION = JSON.parse(
55
56
  /* ------------------------------------------------------------------ args -- */
56
57
 
57
58
  function parse(args) {
58
- const opts = { relay: null, pin: null, once: false, json: false, timeout: 0, quiet: false };
59
+ const opts = {
60
+ relay: null, pin: null, once: false, json: false, timeout: 0, quiet: false,
61
+ // A CLI key gets pasted into shells, scripts and CI configs, where length is
62
+ // friction with no QR code to fall back on — so unlike the installed desktop
63
+ // app this stays short unless asked. REALTIMECLIPBOARD_LONG is the way to
64
+ // ask once, for a machine that generates a lot of them.
65
+ long: !!process.env.REALTIMECLIPBOARD_LONG,
66
+ };
59
67
  const rest = [];
60
68
  for (let i = 0; i < args.length; i++) {
61
69
  const a = args[i];
@@ -86,6 +94,8 @@ Options
86
94
  --relay <url> relay to use (default ${DEFAULT_RELAY_URL})
87
95
  or set REALTIMECLIPBOARD_RELAY
88
96
  --pin <pin> join a locked session (or set REALTIMECLIPBOARD_PIN)
97
+ --long new: a 10-character key (~49 bits) instead of 6 (~29 bits)
98
+ or set REALTIMECLIPBOARD_LONG
89
99
  --once watch: exit after the first clip
90
100
  --json one JSON object per line instead of raw text
91
101
  --timeout <s> give up after this many seconds
@@ -105,6 +115,49 @@ ever sees a room hash and ciphertext.`;
105
115
 
106
116
  /* ---------------------------------------------------------------- output -- */
107
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
+
108
161
  const note = (msg, opts) => { if (!opts.quiet) stderr.write(`${msg}\n`); };
109
162
 
110
163
  function die(msg, code = 1) {
@@ -129,13 +182,8 @@ async function derive(rawKey, pin) {
129
182
  const d = await cryptoBox.deriveLocked(key, clean);
130
183
  return { key, roomHash: d.roomHash, aesKey: d.aesKey, auth: d.authToken, locked: true };
131
184
  }
132
- return {
133
- key,
134
- roomHash: await cryptoBox.roomHash(key),
135
- aesKey: await cryptoBox.deriveKey(key),
136
- auth: null,
137
- locked: false,
138
- };
185
+ const open = await cryptoBox.deriveOpen(key);
186
+ return { key, roomHash: open.roomHash, aesKey: open.aesKey, auth: null, locked: false };
139
187
  }
140
188
 
141
189
  /**
@@ -223,7 +271,7 @@ const readStdin = () => new Promise(resolve => {
223
271
  const emitClip = (text, msg, opts) => stdout.write(
224
272
  opts.json
225
273
  ? `${JSON.stringify({ text, seq: msg.seq ?? null, origin: msg.originId ?? null, receivedAt: new Date().toISOString() })}\n`
226
- : (text.endsWith("\n") ? text : `${text}\n`),
274
+ : (t => t.endsWith("\n") ? t : `${t}\n`)(forTerminal(text)),
227
275
  );
228
276
 
229
277
  /* ------------------------------------------------------------------ main -- */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimeclipboard",
3
- "version": "0.3.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/dom/dialog.mjs && node tests/dom/whatsnew.mjs && node tests/dom/tiles.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();
@@ -31,8 +29,13 @@ export function emit(event, payload) {
31
29
  /** Canonical event names. Typos here are silent bugs, so use the constants. */
32
30
  export const EV = {
33
31
  // clipboard
34
- TEXT_CAPTURED: "text:captured", // {text, how} — local capture, needs sending
35
- TEXT_RECEIVED: "text:received", // {text, from} — arrived from a peer
32
+ TEXT_CAPTURED: "text:captured", // {text, how} — a clip settled here, needs sending
33
+ TEXT_RECEIVED: "text:received", // {text, from} — a clip arrived from a peer
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
36
39
  TIER_CHANGED: "clipboard:tier", // {tier, note}
37
40
  PENDING_CLIP: "clipboard:pending",// {pending, text} — arrived while unfocused
38
41
  CLIP_OFFERED: "clipboard:offered",// {text} — arrived, but the editor has unsent work
@@ -53,23 +56,14 @@ export const EV = {
53
56
 
54
57
  // session
55
58
  KEY_CHANGED: "session:key", // {key, locked}
56
- /**
57
- * {locked, verified} — see core/crypto.js.
58
- *
59
- * "lockstate", not "lock", and the extra syllable is load-bearing. The name
60
- * used to be "session:lock", which is ALSO the imperative the UI emits to
61
- * mean "lock this session" (main.js). One name, two opposite meanings: a
62
- * report and an order. So every state.setKey() — one per session, on every
63
- * single boot — delivered a report to the handler that acts on the order,
64
- * and the app opened the "Lock this session" PIN dialog by itself the moment
65
- * it started. Nothing failed, nothing was logged, and the dialog looked
66
- * exactly like a feature.
67
- *
68
- * Announcements are past tense here (`session:key`, `peers:changed`) and
69
- * commands are bare verbs (`session:lock`, `session:leave`). Keep it that
70
- * way; the bus does no namespacing of its own and a collision is silent.
71
- */
72
- LOCK_STATE: "session:lockstate",
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.
66
+ LOCK_REQUIRED: "session:lockrequired",
73
67
  FOUNDER: "session:founder", // {founder} — first into this room? null = not yet known
74
68
 
75
69
  // files