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 +1 -1
- package/README.md +16 -9
- package/cli/realtimeclipboard.mjs +57 -9
- package/package.json +5 -2
- package/src/core/CLAUDE.md +9 -2
- package/src/core/bus.js +18 -24
- package/src/core/config.js +286 -197
- package/src/core/crypto.js +73 -101
- package/src/core/device.js +23 -22
- package/src/core/history.js +28 -65
- package/src/core/keys.js +52 -72
- package/src/core/native.js +55 -0
- package/src/core/paths.js +11 -48
- package/src/core/state.js +105 -60
- package/src/core/storage.js +89 -70
- package/src/core/text.js +51 -0
- package/src/transport/protocol.js +25 -9
- 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
|
@@ -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
|
|
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:
|
|
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
|
|
|
@@ -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
|
|
32
|
-
|
|
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
|
|
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 | `
|
|
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
|
|
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,
|
|
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 = {
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
: (
|
|
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
|
+
"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",
|
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();
|
|
@@ -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} —
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|