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/src/core/config.js
CHANGED
|
@@ -5,41 +5,31 @@
|
|
|
5
5
|
|
|
6
6
|
import { IS_DESKTOP } from "./native.js";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*
|
|
11
|
-
* Must be wss:// in production: the site is served over HTTPS from GitHub
|
|
12
|
-
* Pages, and a browser refuses a ws:// connection from an https:// page as
|
|
13
|
-
* mixed content. Localhost is exempt from that rule, which is why the dev
|
|
14
|
-
* branch can stay ws://.
|
|
15
|
-
*/
|
|
16
|
-
// Guarded: this module is imported by node-based tests where `location` does
|
|
17
|
-
// not exist, and a bare reference would throw at import time and take the whole
|
|
18
|
-
// graph down.
|
|
8
|
+
// Guarded: imported by node tests where `location` does not exist, and a bare
|
|
9
|
+
// reference would throw at import time and take the whole graph down.
|
|
19
10
|
const IS_LOCAL = typeof location !== "undefined" &&
|
|
20
11
|
["localhost", "127.0.0.1", "[::1]"].includes(location.hostname);
|
|
21
12
|
|
|
22
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Must be wss:// in production — a browser refuses ws:// from an https:// page
|
|
15
|
+
* as mixed content. Localhost is exempt, which is why dev can stay ws://.
|
|
16
|
+
* Self-hosters change this one line.
|
|
17
|
+
*/
|
|
23
18
|
export const DEFAULT_RELAY_URL = "wss://realtimeclipboard.fastapicloud.dev";
|
|
24
19
|
const LOCAL_RELAY_URL = "ws://127.0.0.1:8000";
|
|
25
20
|
|
|
26
21
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* one, and a cycle between "every constant" and "the thing that persists them"
|
|
32
|
-
* is the sort that works until someone moves a line to module scope.
|
|
22
|
+
* The storage prefix lives here rather than in core/storage.js so this module
|
|
23
|
+
* can read a setting without importing storage.js — which imports this one, and
|
|
24
|
+
* a cycle between "every constant" and "the thing that persists them" works
|
|
25
|
+
* until someone moves a line to module scope.
|
|
33
26
|
*/
|
|
34
27
|
export const STORAGE_PREFIX = "realtimeclipboard.";
|
|
35
28
|
const RELAY_KEY = "relayUrl";
|
|
36
29
|
|
|
37
30
|
/**
|
|
38
|
-
* Accept only something that can actually be a relay, and normalise it.
|
|
39
|
-
*
|
|
40
31
|
* `http(s)://` is accepted and converted, because that is what a person copies
|
|
41
|
-
* out of a browser bar when
|
|
42
|
-
* rejecting it would be pedantry with a support ticket attached.
|
|
32
|
+
* out of a browser bar when IT gives them an address.
|
|
43
33
|
*/
|
|
44
34
|
export function normaliseRelay(raw) {
|
|
45
35
|
if (!raw || typeof raw !== "string") return null;
|
|
@@ -59,20 +49,14 @@ const stored = () => {
|
|
|
59
49
|
};
|
|
60
50
|
|
|
61
51
|
/**
|
|
62
|
-
* `?relay=`
|
|
63
|
-
* a
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* It wins over the stored setting on purpose — the deployment is more current
|
|
67
|
-
* than whatever the machine remembers — and main.js persists it, so the switch
|
|
68
|
-
* survives the next launch without the flag.
|
|
52
|
+
* `?relay=` for builds that are deployed rather than visited: an MSI transform,
|
|
53
|
+
* a config profile, a desktop shell launching the webview at its own relay. It
|
|
54
|
+
* wins over the stored setting because the deployment is more current, and
|
|
55
|
+
* main.js persists it so the switch survives the next launch.
|
|
69
56
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* a
|
|
73
|
-
* The override only does anything on a build whose operator also edited that
|
|
74
|
-
* meta tag — which is precisely the self-hosting case it exists for. The CSP is
|
|
75
|
-
* the enforcement; this is only the plumbing. !!
|
|
57
|
+
* NOT an attack surface on the hosted site: the CSP pins `connect-src` to this
|
|
58
|
+
* origin and the default relay, so a link carrying `?relay=` elsewhere cannot
|
|
59
|
+
* open a connection. The CSP is the enforcement; this is only the plumbing.
|
|
76
60
|
*/
|
|
77
61
|
const fromQuery = () => {
|
|
78
62
|
try { return new URLSearchParams(location.search).get("relay"); }
|
|
@@ -88,21 +72,17 @@ export const RELAY_URL =
|
|
|
88
72
|
export const RELAY_IS_CUSTOM = RELAY_URL !== DEFAULT_RELAY_URL && RELAY_URL !== LOCAL_RELAY_URL;
|
|
89
73
|
|
|
90
74
|
/**
|
|
91
|
-
* The same relay over plain HTTP, for the SSE+POST fallback and /stats.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* IT allowlists a single domain (PRD §5.4) and both transports are covered by
|
|
95
|
-
* it. Derived rather than written twice so the two can never drift.
|
|
75
|
+
* The same relay over plain HTTP, for the SSE+POST fallback and /stats. One
|
|
76
|
+
* hostname, two ways in, so IT allowlists a single domain (PRD §5.4) and both
|
|
77
|
+
* transports are covered. Derived so the two can never drift.
|
|
96
78
|
*/
|
|
97
79
|
export const RELAY_HTTP_URL = RELAY_URL.replace(/^ws/i, "http");
|
|
98
80
|
|
|
99
81
|
/**
|
|
100
|
-
* How we talk to the relay.
|
|
101
|
-
*
|
|
102
82
|
* ws — WebSocket. Lower latency, one connection, the default.
|
|
103
|
-
* sse — Server-Sent Events
|
|
104
|
-
*
|
|
105
|
-
*
|
|
83
|
+
* sse — Server-Sent Events down + fetch POST up (PRD §4.3 R3). Plain HTTP
|
|
84
|
+
* with no Upgrade, so it survives the TLS-inspecting proxies that eat
|
|
85
|
+
* WebSockets on corporate networks (§5.4).
|
|
106
86
|
*
|
|
107
87
|
* Both carry the identical envelopes from §6 — see transport/protocol.js.
|
|
108
88
|
*/
|
|
@@ -111,29 +91,15 @@ export const TRANSPORT = { WS: "ws", SSE: "sse" };
|
|
|
111
91
|
/**
|
|
112
92
|
* Clip size, derived — not chosen.
|
|
113
93
|
*
|
|
114
|
-
* A clip
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* (backend/main.py, 32 KB). Base64 alone inflates by a third, so the real
|
|
118
|
-
* limit is a BYTE budget on the wire and a character count is only an
|
|
119
|
-
* approximation of it.
|
|
94
|
+
* A clip is UTF-8 encoded, sealed with AES-GCM (16-byte tag), base64'd and put
|
|
95
|
+
* in a JSON envelope, and the relay rejects any frame over MAX_FRAME_BYTES. So
|
|
96
|
+
* the real limit is a BYTE budget and a character count only approximates it.
|
|
120
97
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* the constant cited FR-2.8 while contradicting it, because "max payload 32 KB"
|
|
127
|
-
* had been transcribed into a character count once and never recomputed. Every
|
|
128
|
-
* clip over ~24 KB was accepted by the editor, encrypted, sent, and dropped by
|
|
129
|
-
* the relay, with the rejection arriving as an async `error` frame long after
|
|
130
|
-
* the UI had said it went. Deriving the number is what stops that recurring;
|
|
131
|
-
* tests/unit/clipsize.mjs is what proves the derivation.
|
|
132
|
-
*
|
|
133
|
-
* MAX_CHARS is what the counter shows and what the editor guards on, because
|
|
134
|
-
* users think in characters and one ASCII character is one byte. MAX_BYTES is
|
|
135
|
-
* the truth, and is what the send path enforces: 24,000 CJK characters are
|
|
136
|
-
* 72,000 bytes and will not fit, whatever the counter says.
|
|
98
|
+
* MAX_CHARS was once 50,000 — 66 KB on the wire against a 32 KB frame — because
|
|
99
|
+
* "max payload 32 KB" had been transcribed into a character count and never
|
|
100
|
+
* recomputed. Every clip over ~24 KB was accepted, encrypted, sent, and dropped
|
|
101
|
+
* by the relay, the rejection arriving long after the UI said it went.
|
|
102
|
+
* tests/unit/clipsize.mjs proves the derivation.
|
|
137
103
|
*/
|
|
138
104
|
const RELAY_FRAME_BYTES = 32 * 1024; // backend/main.py MAX_FRAME_BYTES
|
|
139
105
|
const CLIP_ENVELOPE_BYTES = 512; // {"t","originId","iv","payload"} + slack
|
|
@@ -145,27 +111,24 @@ const CLIP_MAX_BYTES = Math.floor(
|
|
|
145
111
|
);
|
|
146
112
|
|
|
147
113
|
/**
|
|
148
|
-
* Wire size of a string
|
|
149
|
-
*
|
|
150
|
-
*
|
|
114
|
+
* Wire size of a string, exported beside the limit it is measured against so the
|
|
115
|
+
* editor and the send path cannot disagree — `String.length` counts UTF-16
|
|
116
|
+
* units, which is not what the relay counts.
|
|
151
117
|
*/
|
|
152
118
|
export const textBytes = (s) => new TextEncoder().encode(s).length;
|
|
153
119
|
|
|
154
120
|
export const TEXT = {
|
|
121
|
+
// MAX_CHARS is what the counter shows, because users think in characters.
|
|
122
|
+
// MAX_BYTES is the truth: 24,000 CJK characters are 72,000 bytes.
|
|
155
123
|
MAX_BYTES: CLIP_MAX_BYTES, // the wire limit — authoritative
|
|
156
124
|
MAX_CHARS: Math.floor(CLIP_MAX_BYTES / 1000) * 1000, // the friendly one, for the counter
|
|
157
125
|
SUPPRESS_MS: 1500, // loop-suppression window after applying a remote clip (FR-2.6)
|
|
158
126
|
|
|
159
127
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* COMMIT_IDLE_MS is generous because the commit is invisible: the text is
|
|
166
|
-
* already on the far screen via the stream, so this only decides when it
|
|
167
|
-
* becomes a clip. A mid-sentence thinking pause is 300-500 ms, and anything
|
|
168
|
-
* under that would fragment one sentence into six history entries.
|
|
128
|
+
* Generous because the commit is invisible — the text is already on the far
|
|
129
|
+
* screen via the stream, so this only decides when it becomes a clip. A
|
|
130
|
+
* mid-sentence pause is 300-500 ms, and less would fragment one sentence into
|
|
131
|
+
* six history entries.
|
|
169
132
|
*/
|
|
170
133
|
COMMIT_IDLE_MS: 800,
|
|
171
134
|
|
|
@@ -173,72 +136,87 @@ export const TEXT = {
|
|
|
173
136
|
STREAM_THROTTLE_MS: 100,
|
|
174
137
|
|
|
175
138
|
/**
|
|
176
|
-
* Above this, typing is not streamed
|
|
139
|
+
* Above this, typing is not streamed and the text syncs on commit only.
|
|
177
140
|
*
|
|
178
141
|
* A stream frame carries the WHOLE text, which is what lets this feature exist
|
|
179
|
-
* without diffs
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* The degradation is invisible because big text is pasted, never typed —
|
|
184
|
-
* nobody collaboratively types a stack trace — so the case streaming is for
|
|
185
|
-
* (a URL, a token, a sentence) is entirely inside this bound.
|
|
142
|
+
* without diffs or operational transform — a trade that only holds while the
|
|
143
|
+
* text is small. The degradation is invisible because big text is pasted,
|
|
144
|
+
* never typed.
|
|
186
145
|
*/
|
|
187
146
|
STREAM_MAX_BYTES: 4096,
|
|
188
147
|
|
|
189
148
|
/**
|
|
190
|
-
* How long a local copy outranks an arriving clip.
|
|
191
|
-
*
|
|
192
|
-
* Two devices in Clipboard mode both being used by a human means every copy on
|
|
193
|
-
* one overwrites the other's system clipboard, and the moment that actually
|
|
194
|
-
* hurts is "I copied something, went to paste it, and it was gone". Inside
|
|
149
|
+
* How long a local copy outranks an arriving clip. The moment that actually
|
|
150
|
+
* hurts is "I copied something, went to paste it, and it was gone", so inside
|
|
195
151
|
* this window the arriving clip is queued and offered instead of written.
|
|
196
152
|
*/
|
|
197
153
|
LOCAL_COPY_GRACE_MS: 10_000,
|
|
198
154
|
};
|
|
199
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Pastejacking — see clipboard/guard.js for what is actually checked.
|
|
158
|
+
*
|
|
159
|
+
* The room is joinable by anyone holding the key, and an arriving clip is
|
|
160
|
+
* written to the OS clipboard with no gesture from the person receiving it. So
|
|
161
|
+
* a peer chooses what you paste into your next terminal.
|
|
162
|
+
*
|
|
163
|
+
* `ENABLED` governs only the heuristic that demands a click. Stripping the
|
|
164
|
+
* trailing newline and the invisible characters is unconditional and has no
|
|
165
|
+
* switch: it is not a policy, it is the difference between a clip that pastes
|
|
166
|
+
* and a clip that runs.
|
|
167
|
+
*/
|
|
168
|
+
export const PASTE_GUARD = {
|
|
169
|
+
ENABLED: true,
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Past this, do not scan. The patterns are anchored per line, so a pasted
|
|
173
|
+
* logfile is a lot of backtracking for a case that is not what this defends
|
|
174
|
+
* against — nobody is tricked into pasting 200 KB into a shell.
|
|
175
|
+
*/
|
|
176
|
+
MAX_SCAN_CHARS: 8_192,
|
|
177
|
+
};
|
|
178
|
+
|
|
200
179
|
export const FILES = {
|
|
201
180
|
MAX_BYTES: 5 * 1024 * 1024, // 5 MB per file (FR-7.1)
|
|
202
181
|
MAX_COUNT: 20, // per session, memory only (FR-7.7)
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A filename is chosen by whoever sent the file, so it is bounded like every
|
|
185
|
+
* other piece of peer-supplied metadata. Long enough that no real name is
|
|
186
|
+
* truncated; short enough that a hostile one is a tile, not a layout.
|
|
187
|
+
* files/registry.js `safeName` applies it, along with the character rules.
|
|
188
|
+
*/
|
|
189
|
+
MAX_NAME_CHARS: 120,
|
|
203
190
|
THUMB_PX: 160, // longest edge (FR-7.2)
|
|
204
191
|
THUMB_QUALITY: 0.7,
|
|
192
|
+
|
|
205
193
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* ~44 KB frame and is rejected by the relay's own 32 KB cap. The relay chunk
|
|
212
|
-
* size is therefore DERIVED from this, in files/chunker.js as
|
|
213
|
-
* RELAY_CHUNK_BYTES, rather than being a second hand-tuned number that could
|
|
214
|
-
* drift out of sync with it.
|
|
194
|
+
* For the P2P data channel, which carries raw binary. The relay fallback
|
|
195
|
+
* cannot use this directly — base64 plus the tag and envelope inflate a 32 KB
|
|
196
|
+
* chunk into a ~44 KB frame, past the relay's own 32 KB cap — so
|
|
197
|
+
* RELAY_CHUNK_BYTES is DERIVED from this in files/chunker.js rather than being
|
|
198
|
+
* a second hand-tuned number.
|
|
215
199
|
*/
|
|
216
200
|
CHUNK_BYTES: 32 * 1024,
|
|
217
201
|
|
|
218
202
|
/**
|
|
219
|
-
* How long a file request lives before
|
|
203
|
+
* How long a file request lives before BOTH ends give up, on the same number,
|
|
204
|
+
* so neither is left believing in a transfer the other abandoned. Approving
|
|
205
|
+
* into a peer that gave up thirty seconds ago sends 5 MB nowhere.
|
|
220
206
|
*
|
|
221
|
-
*
|
|
222
|
-
* the
|
|
223
|
-
*
|
|
224
|
-
* peer that gave up thirty seconds ago sends 5 MB nowhere.
|
|
225
|
-
*
|
|
226
|
-
* Its own constant rather than a multiple of the ICE timeout, which is what
|
|
227
|
-
* it used to be. That coupling meant shortening the ICE race in a test also
|
|
228
|
-
* shortened how long a human had to answer a dialog, and tuning the network
|
|
229
|
-
* silently retuned the UI.
|
|
207
|
+
* Its own constant rather than a multiple of the ICE timeout: that coupling
|
|
208
|
+
* meant shortening the ICE race in a test also shortened how long a human had
|
|
209
|
+
* to answer a dialog.
|
|
230
210
|
*/
|
|
231
211
|
REQUEST_TIMEOUT_MS: 15_000,
|
|
232
212
|
|
|
233
213
|
/**
|
|
234
|
-
* How long
|
|
235
|
-
* close, before dropping the peer connection out from under it.
|
|
214
|
+
* How long a closed data channel gets to confirm the close.
|
|
236
215
|
*
|
|
237
216
|
* bufferedAmount reaching zero only means SCTP accepted the bytes, not that
|
|
238
217
|
* the peer has them. The stream reset dc.close() sends is ordered behind the
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* because a wedged association must not hold a finished transfer open.
|
|
218
|
+
* queued data, so the close event is the nearest thing to a delivery signal.
|
|
219
|
+
* Bounded, because a wedged association must not hold a transfer open.
|
|
242
220
|
*/
|
|
243
221
|
CHANNEL_CLOSE_MS: 1_000,
|
|
244
222
|
};
|
|
@@ -246,17 +224,32 @@ export const FILES = {
|
|
|
246
224
|
export const KEY = {
|
|
247
225
|
// Crockford-ish: no 0/O, no 1/I/L. Ambiguity here becomes a support ticket.
|
|
248
226
|
ALPHABET: "23456789ABCDEFGHJKMNPQRSTVWXYZ",
|
|
249
|
-
LENGTH: 6, // PRD D3 — the web default
|
|
250
|
-
LONG_LENGTH: 10, // "high security" option, PRD §7.3
|
|
251
|
-
|
|
252
227
|
/**
|
|
253
|
-
*
|
|
228
|
+
* Ten, not six. PRD D3 chose six for the phone keyboard and that argument was
|
|
229
|
+
* sound about typing and wrong about arithmetic.
|
|
230
|
+
*
|
|
231
|
+
* The open room hash is derived through the same 250k PBKDF2 as the AES key
|
|
232
|
+
* (crypto.js `deriveOpen`), so a guess costs a full derivation rather than one
|
|
233
|
+
* SHA-256. But `SALT` is a single global constant, so the table an attacker
|
|
234
|
+
* builds is built ONCE and then opens every unlocked session of that length,
|
|
235
|
+
* for every user, for as long as the salt stands. That is the number that
|
|
236
|
+
* matters, and at six characters it is ~12 minutes on 100 rented GPUs:
|
|
254
237
|
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
238
|
+
* 6 chars 7.3e8 keys 12 min on 100 GPUs 29.4 bits
|
|
239
|
+
* 10 chars 5.9e14 keys 19 years on 100 GPUs 49.1 bits
|
|
240
|
+
* 16 chars 5.3e23 keys out of reach 78.5 bits
|
|
241
|
+
*
|
|
242
|
+
* Six is therefore not a preference, it is a defect, and no iteration count
|
|
243
|
+
* fixes a keyspace. Typing cost is paid once per session and only when someone
|
|
244
|
+
* declines the QR code and the copied link, both of which are in front of it.
|
|
245
|
+
*/
|
|
246
|
+
LENGTH: 10,
|
|
247
|
+
LONG_LENGTH: 16, // "high security" option, PRD §7.3
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* An installed app links a device by QR or copied link, so it does not pay the
|
|
251
|
+
* typing cost at all — and it is the surface running all day reading every
|
|
252
|
+
* copy, which is the one that should take the longer option.
|
|
260
253
|
*/
|
|
261
254
|
DEFAULT_LONG: IS_DESKTOP,
|
|
262
255
|
};
|
|
@@ -267,29 +260,34 @@ export const CRYPTO = {
|
|
|
267
260
|
ROOM_HASH_BYTES: 16,
|
|
268
261
|
|
|
269
262
|
/**
|
|
270
|
-
*
|
|
263
|
+
* One PBKDF2 run, two outputs — see crypto.js `deriveOpen`.
|
|
271
264
|
*
|
|
272
|
-
* The
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* secret is a 29-bit key an attacker has to guess; a locked session's secret
|
|
279
|
-
* is a PIN held by someone who may ALREADY have the link, so the PIN is the
|
|
280
|
-
* whole defence and every doubling of the iteration count is a doubling of
|
|
281
|
-
* their cost. It is paid once per session, behind the "unlocking" state.
|
|
265
|
+
* The room hash used to be `SHA-256("realtimeclipboard:" + KEY)`: unsalted,
|
|
266
|
+
* unstretched, and the one value the relay holds by construction. Anyone with
|
|
267
|
+
* it could sweep the whole 6-character keyspace in 0.07s and read back the
|
|
268
|
+
* key, which made the 250k iterations on the AES key worth exactly one
|
|
269
|
+
* derivation to an attacker. Deriving both from the same PRK costs nothing —
|
|
270
|
+
* that PBKDF2 was already being awaited before the connection opened.
|
|
282
271
|
*/
|
|
283
|
-
|
|
284
|
-
|
|
272
|
+
OPEN_INFO: {
|
|
273
|
+
AES: "realtimeclipboard/aes",
|
|
274
|
+
ROOM: "realtimeclipboard/room",
|
|
275
|
+
},
|
|
285
276
|
|
|
286
277
|
/**
|
|
287
|
-
*
|
|
278
|
+
* The lock salt is a PREFIX, completed with the share key — random per
|
|
279
|
+
* session, which is what a salt is for. The open-session salt above is one
|
|
280
|
+
* global constant, so a single precomputed table covers every user on earth;
|
|
281
|
+
* KEY.LENGTH is what has to make that table too large to build.
|
|
288
282
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
283
|
+
* 600k rather than 250k because the threat differs: a locked session's secret
|
|
284
|
+
* is a PIN held by someone who may already have the link, so the PIN is the
|
|
285
|
+
* whole defence and every doubling doubles their cost. Paid once per session.
|
|
292
286
|
*/
|
|
287
|
+
LOCK_SALT: "realtimeclipboard-lock-v1:",
|
|
288
|
+
LOCK_ITERATIONS: 600_000,
|
|
289
|
+
|
|
290
|
+
/** One PBKDF2 run, three independent outputs — see crypto.js `deriveLocked`. */
|
|
293
291
|
LOCK_INFO: {
|
|
294
292
|
AES: "realtimeclipboard-lock/aes",
|
|
295
293
|
ROOM: "realtimeclipboard-lock/room",
|
|
@@ -300,66 +298,46 @@ export const CRYPTO = {
|
|
|
300
298
|
/**
|
|
301
299
|
* Locked sessions: the share key in the link, a PIN that never travels with it.
|
|
302
300
|
*
|
|
303
|
-
* The key is a bearer credential (PRD §7.1) and
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
* MIN_PIN is 6 and the PIN is free-form rather than 4-6 digits, because against
|
|
309
|
-
* someone who already has the link the key contributes nothing and the PIN is
|
|
310
|
-
* the entire secret: a 4-digit PIN is ~13 bits, which is minutes of offline
|
|
311
|
-
* guessing. The dialog states the number rather than an adjective.
|
|
301
|
+
* The key is a bearer credential (PRD §7.1) and links get forwarded,
|
|
302
|
+
* screenshotted and pasted into group chats. MIN_PIN is 6 and free-form rather
|
|
303
|
+
* than 4-6 digits because against someone who already has the link the key
|
|
304
|
+
* contributes nothing: a 4-digit PIN is ~13 bits, minutes of offline guessing.
|
|
312
305
|
*/
|
|
313
306
|
export const LOCK = {
|
|
314
307
|
MIN_PIN: 6,
|
|
315
308
|
|
|
316
309
|
/**
|
|
317
|
-
* Fragment marker: `#!ABCDEF`.
|
|
318
|
-
*
|
|
319
|
-
* the link. Leading rather than trailing: chat clients that trim punctuation
|
|
320
|
-
* off a pasted URL trim the END of it.
|
|
310
|
+
* Fragment marker: `#!ABCDEF`. Leading rather than trailing — chat clients
|
|
311
|
+
* that trim punctuation off a pasted URL trim the END of it.
|
|
321
312
|
*/
|
|
322
313
|
SIGIL: "!",
|
|
323
314
|
|
|
324
315
|
/**
|
|
325
|
-
* Sent once on creating a locked room
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* rendering it. See core/crypto.js and the beacon note in main.js.
|
|
316
|
+
* Sent once on creating a locked room and replayed to every joiner, so a
|
|
317
|
+
* joiner can tell "wrong PIN" from "first one here" by whether it decrypts.
|
|
318
|
+
* Receivers drop it instead of rendering it.
|
|
329
319
|
*/
|
|
330
320
|
BEACON: String.fromCharCode(0) + "realtimeclipboard-lock-v1",
|
|
331
321
|
|
|
332
322
|
/**
|
|
333
|
-
* Sent into the room being ABANDONED when
|
|
334
|
-
*
|
|
335
|
-
* Locking is a room change (see main.js): the lock flag is part of the room
|
|
336
|
-
* name, so the locking device leaves for a new, locked room and everyone
|
|
337
|
-
* else is simply left behind in the old one — connected, in sync with
|
|
338
|
-
* nobody, with no way to tell that from a quiet afternoon. This sentinel is
|
|
339
|
-
* the goodbye. A device that decrypts it closes its connection and says what
|
|
340
|
-
* happened, which is the difference between being removed and being
|
|
341
|
-
* mysteriously alone.
|
|
323
|
+
* Sent into the room being ABANDONED when a session is locked.
|
|
342
324
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
325
|
+
* Locking is a room change: the locking device leaves for a new room and
|
|
326
|
+
* everyone else is left behind, connected, in sync with nobody, unable to tell
|
|
327
|
+
* that from a quiet afternoon. This sentinel is the goodbye.
|
|
346
328
|
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
329
|
+
* A clip rather than a new frame type, like BEACON — it needs no relay change
|
|
330
|
+
* and is sealed with the room key, so the relay forwards what it cannot read.
|
|
331
|
+
* It overwrites the room's retained last clip deliberately, so a late joiner
|
|
332
|
+
* to the dead room is told the session moved.
|
|
351
333
|
*/
|
|
352
334
|
EVICT: String.fromCharCode(0) + "realtimeclipboard-lock-evict-v1",
|
|
353
335
|
|
|
354
336
|
/**
|
|
355
|
-
* How long the goodbye gets to leave
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
* frames into a POST and its close() drops whatever is still queued
|
|
360
|
-
* (transport/sse.js), so closing in the same tick would send the sentinel to
|
|
361
|
-
* nobody on exactly the network where the fallback is in use. A quarter of a
|
|
362
|
-
* second, once, in a flow that is already opening a dialog.
|
|
337
|
+
* How long the goodbye gets to leave before the socket is torn down. Not
|
|
338
|
+
* paranoia about buffering: the SSE fallback batches upstream frames into a
|
|
339
|
+
* POST and its close() drops whatever is queued, so closing in the same tick
|
|
340
|
+
* would send the sentinel to nobody on exactly the network using the fallback.
|
|
363
341
|
*/
|
|
364
342
|
EVICT_FLUSH_MS: 250,
|
|
365
343
|
};
|
|
@@ -371,25 +349,18 @@ export const NET = {
|
|
|
371
349
|
ICE_TIMEOUT_MS: 5_000, // then fall back to relay chunks (FR-7.6)
|
|
372
350
|
|
|
373
351
|
/**
|
|
374
|
-
* How long a transport gets to become usable before we give up on it.
|
|
375
|
-
*
|
|
376
352
|
* A blocked WebSocket frequently does NOT fail: an intercepting proxy accepts
|
|
377
|
-
* the TCP connection, swallows the Upgrade, and leaves the socket hanging
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* the exact failure this whole path exists for.
|
|
353
|
+
* the TCP connection, swallows the Upgrade, and leaves the socket hanging with
|
|
354
|
+
* no open, no close and no error. Without this timer the app sits on
|
|
355
|
+
* "Connecting…" forever and never tries the fallback.
|
|
381
356
|
*
|
|
382
|
-
* Generous enough to survive a scale-to-zero cold start (PRD R2)
|
|
383
|
-
* seconds rather than milliseconds.
|
|
357
|
+
* Generous enough to survive a scale-to-zero cold start (PRD R2).
|
|
384
358
|
*/
|
|
385
359
|
PROBE_MS: 8_000,
|
|
386
360
|
|
|
387
361
|
/**
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* Two, not one: a single failure is far more often a cold start or a flaky
|
|
391
|
-
* moment than a policy, and switching on it would put users on the slower
|
|
392
|
-
* path for no reason. Two failures in a row is a proxy.
|
|
362
|
+
* Two, not one: a single failure is more often a cold start than a policy, and
|
|
363
|
+
* switching on it would put users on the slower path for no reason.
|
|
393
364
|
*/
|
|
394
365
|
SWITCH_AFTER: 2,
|
|
395
366
|
|
|
@@ -405,23 +376,18 @@ export const POLL_OPTIONS = { "Off": 0, "500ms": 500, "1s": 1000, "2s": 2000 };
|
|
|
405
376
|
|
|
406
377
|
/**
|
|
407
378
|
* How far the sync reaches on THIS device. One ladder, three rungs, each adding
|
|
408
|
-
* exactly one thing to the one below
|
|
379
|
+
* exactly one thing to the one below:
|
|
409
380
|
*
|
|
410
381
|
* off — nothing leaves and nothing arrives. The editor is private again.
|
|
411
|
-
* manual — the session syncs in the app window
|
|
412
|
-
* read nor written
|
|
413
|
-
* live — the OS clipboard is wired to the room in both directions.
|
|
414
|
-
* copy anywhere goes out; what arrives is copied here.
|
|
382
|
+
* manual — the session syncs in the app window; the OS clipboard is neither
|
|
383
|
+
* read nor written.
|
|
384
|
+
* live — the OS clipboard is wired to the room in both directions.
|
|
415
385
|
*
|
|
416
|
-
* The
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
* The stored strings are a compatibility surface. "live" and "manual" are on
|
|
422
|
-
* disk for every existing user under STORAGE_PREFIX, and relabelling the UI is
|
|
423
|
-
* free while renaming these silently resets the one preference where being
|
|
424
|
-
* wrong matters most. Change the labels in ui/features/syncMode.js instead.
|
|
386
|
+
* The stored strings are a compatibility surface: "live" and "manual" are on
|
|
387
|
+
* disk for every existing user, and relabelling the UI is free while renaming
|
|
388
|
+
* these silently resets the one preference where being wrong matters most.
|
|
389
|
+
* Change the labels in ui/features/syncMode.js instead — they read
|
|
390
|
+
* Off / App / Clipboard, naming the destination rather than a manner of working.
|
|
425
391
|
*/
|
|
426
392
|
export const SYNC_MODES = {
|
|
427
393
|
OFF: "off",
|
|
@@ -437,18 +403,12 @@ export const bindsClipboard = (mode) => mode === SYNC_MODES.LIVE;
|
|
|
437
403
|
export const sharesSession = (mode) => mode !== SYNC_MODES.OFF;
|
|
438
404
|
|
|
439
405
|
/**
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
* Derived from OWNER/NAME rather than written out four times, for the same
|
|
443
|
-
* reason RELAY_HTTP_URL is derived from RELAY_URL — a fork, a rename, or a
|
|
444
|
-
* moved account is then one edit, and the four links cannot drift into pointing
|
|
445
|
-
* at three different projects.
|
|
406
|
+
* Derived from OWNER/NAME rather than written out four times, so a fork, rename
|
|
407
|
+
* or moved account is one edit and the links cannot drift apart.
|
|
446
408
|
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
* than no donate link. The .github/FUNDING.yml button is the same destination
|
|
451
|
-
* and has to move with it.
|
|
409
|
+
* Re-check the sponsors page if the account ever moves — a donate link that 404s
|
|
410
|
+
* costs more goodwill than no donate link, and .github/FUNDING.yml is the same
|
|
411
|
+
* destination and has to move with it.
|
|
452
412
|
*/
|
|
453
413
|
export const REPO = {
|
|
454
414
|
OWNER: "akshaynikhare",
|
|
@@ -459,34 +419,85 @@ export const LINKS = {
|
|
|
459
419
|
REPO: `https://github.com/${REPO.OWNER}/${REPO.NAME}`,
|
|
460
420
|
ISSUES: `https://github.com/${REPO.OWNER}/${REPO.NAME}/issues`,
|
|
461
421
|
/**
|
|
462
|
-
* The chooser, not a blank issue:
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
* `/new/choose` picks between the bug and feature forms in
|
|
467
|
-
* .github/ISSUE_TEMPLATE/ — which is also where the "do not paste your share
|
|
468
|
-
* key" warning lives, and that warning is the single most valuable thing on
|
|
469
|
-
* the page for this product.
|
|
422
|
+
* The chooser, not a blank issue: `/new/choose` picks between the forms in
|
|
423
|
+
* .github/ISSUE_TEMPLATE/, which is where the "do not paste your share key"
|
|
424
|
+
* warning lives — the single most valuable thing on the page for this product.
|
|
470
425
|
*/
|
|
471
426
|
NEW_ISSUE: `https://github.com/${REPO.OWNER}/${REPO.NAME}/issues/new/choose`,
|
|
472
427
|
SPONSOR: `https://github.com/sponsors/${REPO.OWNER}`,
|
|
473
428
|
};
|
|
474
429
|
|
|
475
|
-
/**
|
|
476
|
-
* The app offer in the header — the desktop build and the PWA install, sharing
|
|
477
|
-
* one quiet row (ui/features/install.js).
|
|
478
|
-
*
|
|
479
|
-
* A minute is long enough to read a sentence you did not ask for, finish the
|
|
480
|
-
* paste you came here for, and come back to it; short enough that the header is
|
|
481
|
-
* the header again by the time you next look up. Only the × is remembered — a
|
|
482
|
-
* row that timed out has not been declined, so the offer returns on a later
|
|
483
|
-
* visit.
|
|
484
|
-
*/
|
|
485
|
-
export const OFFER = { DISMISS_MS: 60_000 };
|
|
486
|
-
|
|
487
430
|
export const IMAGES = {
|
|
488
431
|
/** Clipboard image types we will read and share. */
|
|
489
432
|
TYPES: ["image/png", "image/jpeg", "image/webp", "image/gif"],
|
|
490
433
|
/** Named so a received screenshot does not land as "blob" on disk. */
|
|
491
434
|
NAME_PREFIX: "clipboard-image",
|
|
492
435
|
};
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Google Analytics and AdSense.
|
|
439
|
+
*
|
|
440
|
+
* Empty by default and every consumer no-ops on empty, so a fork, a local
|
|
441
|
+
* checkout and a self-hosted deploy load no third-party script at all. Filling
|
|
442
|
+
* these in is the switch that turns tracking and ads on for a build.
|
|
443
|
+
*
|
|
444
|
+
* !! These IDs put third-party script in EVERY document, app.html included.
|
|
445
|
+
* That was a deliberate call and it costs the app a security property it used
|
|
446
|
+
* to have — docs/ARCHITECTURE.md §5 and src/ui/features/ads.js record what.
|
|
447
|
+
* Anything running in app.html can read location.hash and the decrypted
|
|
448
|
+
* clipboard in the DOM; contextual ad targeting reads page content by design.
|
|
449
|
+
*
|
|
450
|
+
* Adding an origin here means adding it to the CSP in _headers AND in every
|
|
451
|
+
* page's meta tag, which tools/check/site-check.mjs asserts agree.
|
|
452
|
+
*/
|
|
453
|
+
export const GOOGLE = {
|
|
454
|
+
/** GA4 measurement ID, "G-XXXXXXXXXX". Admin → Data streams → your stream. */
|
|
455
|
+
GA4_ID: "G-259V6H3K5M",
|
|
456
|
+
/** AdSense publisher ID, "ca-pub-################". Account → Settings. */
|
|
457
|
+
ADSENSE_CLIENT: "ca-pub-6053041142492498",
|
|
458
|
+
/**
|
|
459
|
+
* Per-unit slot IDs, the 10-digit number AdSense prints as `data-ad-slot`
|
|
460
|
+
* when you create a unit. One per placement, and a unit renders nothing
|
|
461
|
+
* until its own ID is filled in.
|
|
462
|
+
*/
|
|
463
|
+
ADSENSE_SLOTS: {
|
|
464
|
+
/** index.html, the 728×90 after "how it works". */
|
|
465
|
+
LEADERBOARD: "7038244690",
|
|
466
|
+
/** index.html, the 300×600 rail beside the FAQ. */
|
|
467
|
+
RAIL: "8299355473",
|
|
468
|
+
/** app.html, below the editor. */
|
|
469
|
+
APP: "6948680552",
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
/** Nothing loads unless the ID that drives it is present. */
|
|
474
|
+
export const analyticsEnabled = () => Boolean(GOOGLE.GA4_ID);
|
|
475
|
+
export const adsEnabled = () => Boolean(GOOGLE.ADSENSE_CLIENT);
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Consent Mode v2 starts denied in these jurisdictions and stays denied until
|
|
479
|
+
* the CMP says otherwise; everywhere else the tags behave normally. EEA, plus
|
|
480
|
+
* the UK and Switzerland.
|
|
481
|
+
*/
|
|
482
|
+
export const CONSENT_REGIONS = [
|
|
483
|
+
"AT", "BE", "BG", "CH", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GB",
|
|
484
|
+
"GR", "HR", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MT", "NL", "NO",
|
|
485
|
+
"PL", "PT", "RO", "SE", "SI", "SK",
|
|
486
|
+
];
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The page URL with the fragment removed, for `page_location`.
|
|
490
|
+
*
|
|
491
|
+
* !! app.html carries the share key in `location.hash`, and gtag's default
|
|
492
|
+
* `page_location` is `location.href`. The unmodified tag would therefore send
|
|
493
|
+
* the key to Google on the first page_view — the one thing this project
|
|
494
|
+
* promises never leaves the browser. Every gtag config passes this instead. !!
|
|
495
|
+
*/
|
|
496
|
+
export const pageLocation = () =>
|
|
497
|
+
typeof location === "undefined" ? "" : location.origin + location.pathname + location.search;
|
|
498
|
+
|
|
499
|
+
export const GOOGLE_SRC = {
|
|
500
|
+
gtag: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,
|
|
501
|
+
adsense: (client) =>
|
|
502
|
+
`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(client)}`,
|
|
503
|
+
};
|