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 +1 -1
- package/README.md +12 -6
- package/cli/realtimeclipboard.mjs +47 -8
- package/package.json +5 -2
- package/src/core/CLAUDE.md +9 -2
- package/src/core/bus.js +15 -38
- package/src/core/config.js +252 -241
- package/src/core/crypto.js +73 -101
- package/src/core/device.js +11 -19
- package/src/core/history.js +28 -65
- package/src/core/keys.js +48 -80
- package/src/core/native.js +12 -16
- package/src/core/paths.js +11 -48
- package/src/core/state.js +53 -91
- package/src/core/storage.js +89 -70
- package/src/core/text.js +51 -0
- package/src/transport/protocol.js +15 -27
- package/src/transport/relay.js +61 -110
- package/src/transport/sse.js +41 -72
- package/src/transport/ws.js +4 -8
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -16,9 +16,9 @@ touch the server.
|
|
|
16
16
|
(never touch the server)
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
**Status:
|
|
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
|
-
|
|
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
|
-
(
|
|
33
|
-
- **End-to-end encrypted** in the browser with AES-GCM; `PBKDF2`
|
|
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 | `
|
|
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,
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
: (
|
|
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.
|
|
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",
|
package/src/core/CLAUDE.md
CHANGED
|
@@ -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
|
|
31
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|