realtimeclipboard 0.3.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 +21 -0
- package/README.md +243 -0
- package/cli/CLAUDE.md +28 -0
- package/cli/README.md +24 -0
- package/cli/realtimeclipboard.mjs +303 -0
- package/package.json +67 -0
- package/src/README.md +24 -0
- package/src/core/CLAUDE.md +31 -0
- package/src/core/README.md +18 -0
- package/src/core/bus.js +84 -0
- package/src/core/config.js +414 -0
- package/src/core/crypto.js +215 -0
- package/src/core/device.js +55 -0
- package/src/core/history.js +208 -0
- package/src/core/keys.js +150 -0
- package/src/core/paths.js +68 -0
- package/src/core/state.js +193 -0
- package/src/core/storage.js +182 -0
- package/src/transport/CLAUDE.md +30 -0
- package/src/transport/README.md +16 -0
- package/src/transport/protocol.js +68 -0
- package/src/transport/relay.js +423 -0
- package/src/transport/sse.js +244 -0
- package/src/transport/ws.js +76 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end encryption. All of it. No libraries.
|
|
3
|
+
*
|
|
4
|
+
* OPEN SESSION — the key the user types serves two purposes without the server
|
|
5
|
+
* learning it:
|
|
6
|
+
*
|
|
7
|
+
* roomHash = SHA-256("realtimeclipboard:" + KEY)[0..16] -> sent, routes the room
|
|
8
|
+
* aesKey = PBKDF2(KEY, salt, 250k) -> never leaves this browser
|
|
9
|
+
*
|
|
10
|
+
* LOCKED SESSION — a PIN that is never in the link, never on disk and never
|
|
11
|
+
* sent anywhere. It is folded into ONE PBKDF2 run whose output is expanded by
|
|
12
|
+
* HKDF into three independent values:
|
|
13
|
+
*
|
|
14
|
+
* prk = PBKDF2(PIN, salt = "realtimeclipboard-lock-v1:" + KEY, 600k)
|
|
15
|
+
* aesKey = HKDF(prk, "…/aes") -> AES-GCM-256
|
|
16
|
+
* roomHash = HKDF(prk, "…/room") -> sent, routes the room
|
|
17
|
+
* authToken = HKDF(prk, "…/auth") -> sent, proves PIN knowledge to the relay
|
|
18
|
+
*
|
|
19
|
+
* The room hash requiring the PIN is the load-bearing part: it is what turns
|
|
20
|
+
* "you cannot read it" into "you cannot find it". Someone holding the link but
|
|
21
|
+
* not the PIN computes a different room hash, lands in a different room, and
|
|
22
|
+
* never appears in the real one at all — no peer slot, no roster entry, no
|
|
23
|
+
* traffic metadata. Locked and unlocked rooms with the same key are likewise
|
|
24
|
+
* disjoint, so the two kinds of client can never meet and fail to decrypt each
|
|
25
|
+
* other.
|
|
26
|
+
*
|
|
27
|
+
* The relay cannot derive the key from the hash, so it cannot decrypt. It sees
|
|
28
|
+
* a room name and ciphertext, and nothing else. See PRD §7.3.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { CRYPTO } from "./config.js";
|
|
32
|
+
|
|
33
|
+
const enc = new TextEncoder();
|
|
34
|
+
const dec = new TextDecoder();
|
|
35
|
+
|
|
36
|
+
/** Derivation is expensive (OI-8), so cache per key for the session. */
|
|
37
|
+
let cached = { id: null, aesKey: null };
|
|
38
|
+
|
|
39
|
+
export async function roomHash(key) {
|
|
40
|
+
const digest = await crypto.subtle.digest("SHA-256", enc.encode("realtimeclipboard:" + key));
|
|
41
|
+
return hex(new Uint8Array(digest).slice(0, CRYPTO.ROOM_HASH_BYTES));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Derive the AES key. Several hundred ms on a low-end Android, so call once
|
|
46
|
+
* per session and show an "unlocking" state — never per message.
|
|
47
|
+
*/
|
|
48
|
+
export async function deriveKey(key) {
|
|
49
|
+
if (cached.id === `open:${key}` && cached.aesKey) return cached.aesKey;
|
|
50
|
+
|
|
51
|
+
const material = await crypto.subtle.importKey(
|
|
52
|
+
"raw", enc.encode(key), "PBKDF2", false, ["deriveKey"]
|
|
53
|
+
);
|
|
54
|
+
const aesKey = await crypto.subtle.deriveKey(
|
|
55
|
+
{ name: "PBKDF2", salt: enc.encode(CRYPTO.SALT),
|
|
56
|
+
iterations: CRYPTO.ITERATIONS, hash: "SHA-256" },
|
|
57
|
+
material,
|
|
58
|
+
{ name: "AES-GCM", length: 256 },
|
|
59
|
+
false,
|
|
60
|
+
["encrypt", "decrypt"]
|
|
61
|
+
);
|
|
62
|
+
cached = { id: `open:${key}`, aesKey };
|
|
63
|
+
return aesKey;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ------------------------------------------------------------------
|
|
67
|
+
Locked sessions
|
|
68
|
+
------------------------------------------------------------------- */
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalise a PIN — the exact OPPOSITE of what normalise() does to a key, and
|
|
72
|
+
* the reasoning is worth keeping because getting it backwards is silent.
|
|
73
|
+
*
|
|
74
|
+
* A key is uppercased and stripped to [A-Z0-9] so that two people typing "the
|
|
75
|
+
* same key" land in the same room. A PIN is user-chosen prose, so:
|
|
76
|
+
*
|
|
77
|
+
* - NFC, mandatory. "é" can be typed as one code point or as "e" plus a
|
|
78
|
+
* combining accent. They are different byte strings, they derive different
|
|
79
|
+
* rooms, and NOTHING would report it — the second device would simply be
|
|
80
|
+
* alone in a room of its own, looking like a wrong PIN.
|
|
81
|
+
* - Trim the ends. A trailing space from a paste is invisible on screen and
|
|
82
|
+
* the user has no way to see why their correct PIN is rejected.
|
|
83
|
+
* - Keep case and everything in the middle. Uppercasing would throw away
|
|
84
|
+
* about a bit per letter to buy nothing: unlike a key, a PIN is never read
|
|
85
|
+
* aloud off a screen and retyped from memory.
|
|
86
|
+
*/
|
|
87
|
+
export function normalisePin(raw) {
|
|
88
|
+
return String(raw ?? "").normalize("NFC").trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The whole locked-session derivation: one PBKDF2, three outputs.
|
|
93
|
+
*
|
|
94
|
+
* PBKDF2 reruns its full iteration count for every 32-byte block of output, so
|
|
95
|
+
* asking it for the 64 bytes we need would cost twice what it should — and OI-8
|
|
96
|
+
* already flags this as hundreds of milliseconds on a low-end Android. Instead
|
|
97
|
+
* we stretch once and expand with HKDF, which is a couple of HMACs.
|
|
98
|
+
*
|
|
99
|
+
* The share key is the salt, and it is worth being precise about what that does
|
|
100
|
+
* and does not buy. It stops ONE table from covering every session — the
|
|
101
|
+
* open-session path, with its single global CRYPTO.SALT, has exactly that
|
|
102
|
+
* weakness today. It buys nothing at all against the attacker this feature is
|
|
103
|
+
* actually for: someone holding the link holds the key, so they can compute the
|
|
104
|
+
* salt themselves. Against them the only defence is the length of the PIN and
|
|
105
|
+
* the iteration count, which is why the dialog reports entropy in bits instead
|
|
106
|
+
* of calling a short PIN "secure".
|
|
107
|
+
*
|
|
108
|
+
* Returns `prk` as hex alongside the derived values so a page refresh can skip
|
|
109
|
+
* the 600k iterations. See storage.saveLock for why the PIN itself is never
|
|
110
|
+
* what gets stored.
|
|
111
|
+
*/
|
|
112
|
+
export async function deriveLocked(key, pin) {
|
|
113
|
+
const prk = await stretch(key, normalisePin(pin));
|
|
114
|
+
return { ...(await expand(prk)), prk: hex(new Uint8Array(prk)) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Same outputs, from a remembered prk. No PBKDF2, so this is instant. */
|
|
118
|
+
export async function deriveLockedFromPrk(prkHex) {
|
|
119
|
+
return { ...(await expand(unhex(prkHex))), prk: prkHex };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function stretch(key, pin) {
|
|
123
|
+
const material = await crypto.subtle.importKey(
|
|
124
|
+
"raw", enc.encode(pin), "PBKDF2", false, ["deriveBits"]
|
|
125
|
+
);
|
|
126
|
+
return crypto.subtle.deriveBits(
|
|
127
|
+
{ name: "PBKDF2", salt: enc.encode(CRYPTO.LOCK_SALT + key),
|
|
128
|
+
iterations: CRYPTO.LOCK_ITERATIONS, hash: "SHA-256" },
|
|
129
|
+
material,
|
|
130
|
+
256
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function expand(prk) {
|
|
135
|
+
const ikm = await crypto.subtle.importKey("raw", prk, "HKDF", false, ["deriveBits", "deriveKey"]);
|
|
136
|
+
const info = i => ({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: enc.encode(i) });
|
|
137
|
+
|
|
138
|
+
const [aesKey, room, auth] = await Promise.all([
|
|
139
|
+
crypto.subtle.deriveKey(
|
|
140
|
+
info(CRYPTO.LOCK_INFO.AES), ikm,
|
|
141
|
+
{ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
|
|
142
|
+
),
|
|
143
|
+
crypto.subtle.deriveBits(info(CRYPTO.LOCK_INFO.ROOM), ikm, CRYPTO.ROOM_HASH_BYTES * 8),
|
|
144
|
+
crypto.subtle.deriveBits(info(CRYPTO.LOCK_INFO.AUTH), ikm, CRYPTO.ROOM_HASH_BYTES * 8),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
aesKey,
|
|
149
|
+
roomHash: hex(new Uint8Array(room)),
|
|
150
|
+
authToken: hex(new Uint8Array(auth)),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* No cache entry for locked sessions, deliberately.
|
|
156
|
+
*
|
|
157
|
+
* The open-session cache exists because deriveKey() is called with the same key
|
|
158
|
+
* repeatedly. The locked path is not: it runs once per session, a reconnect
|
|
159
|
+
* reuses the room hash without re-deriving, and the only things that DO call it
|
|
160
|
+
* again — a corrected PIN, a rotated key — are the cases where the previous
|
|
161
|
+
* answer is exactly the one you must not reuse. The refresh path is covered by
|
|
162
|
+
* the stored prk instead, which is cheaper than a cache and survives the tab
|
|
163
|
+
* being reloaded.
|
|
164
|
+
*/
|
|
165
|
+
|
|
166
|
+
/** -> {payload, iv} both base64. A fresh IV per message is mandatory for GCM. */
|
|
167
|
+
export async function encrypt(aesKey, plaintext) {
|
|
168
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
169
|
+
const buf = await crypto.subtle.encrypt(
|
|
170
|
+
{ name: "AES-GCM", iv }, aesKey, enc.encode(plaintext)
|
|
171
|
+
);
|
|
172
|
+
return { payload: toB64(buf), iv: toB64(iv) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function decrypt(aesKey, payloadB64, ivB64) {
|
|
176
|
+
const buf = await crypto.subtle.decrypt(
|
|
177
|
+
{ name: "AES-GCM", iv: fromB64(ivB64) }, aesKey, fromB64(payloadB64)
|
|
178
|
+
);
|
|
179
|
+
return dec.decode(buf);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Forget the derived key.
|
|
184
|
+
*
|
|
185
|
+
* Called on leaving a session and on rotating the key. For years this had no
|
|
186
|
+
* callers at all, which meant the AES key of a room you had deliberately walked
|
|
187
|
+
* out of stayed live in this module until you happened to open another one.
|
|
188
|
+
*/
|
|
189
|
+
export function clearCache() { cached = { id: null, aesKey: null }; }
|
|
190
|
+
|
|
191
|
+
/* ---- hex helpers ---- */
|
|
192
|
+
function hex(bytes) {
|
|
193
|
+
return [...bytes].map(b => b.toString(16).padStart(2, "0")).join("");
|
|
194
|
+
}
|
|
195
|
+
function unhex(s) {
|
|
196
|
+
const out = new Uint8Array(s.length / 2);
|
|
197
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ---- base64 helpers (binary-safe) ---- */
|
|
202
|
+
function toB64(buf) {
|
|
203
|
+
const bytes = new Uint8Array(buf);
|
|
204
|
+
let s = "";
|
|
205
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
206
|
+
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); // chunked: avoids arg limit
|
|
207
|
+
}
|
|
208
|
+
return btoa(s);
|
|
209
|
+
}
|
|
210
|
+
function fromB64(b64) {
|
|
211
|
+
const bin = atob(b64);
|
|
212
|
+
const out = new Uint8Array(bin.length);
|
|
213
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A human-readable name for this device, shown in the peer list.
|
|
3
|
+
*
|
|
4
|
+
* Derived from the user agent, which is unreliable by design — this is a label
|
|
5
|
+
* to help someone recognise their own laptop in a list of three, not an
|
|
6
|
+
* identity.
|
|
7
|
+
*
|
|
8
|
+
* IT IS SENT IN THE CLEAR. This comment used to claim the opposite — "inside
|
|
9
|
+
* the encrypted envelope, never to the relay in the clear" — and the wire has
|
|
10
|
+
* never agreed with it: `protocol.hello()` puts `name` in a plaintext field,
|
|
11
|
+
* and the relay stores it and rebroadcasts it in every roster
|
|
12
|
+
* (backend/main.py `_adopt_identity`, `_roster`).
|
|
13
|
+
*
|
|
14
|
+
* So keep it a label. "Chrome · Windows" is fine; a name is not the place for
|
|
15
|
+
* anything you would mind the relay operator reading. This is one of the things
|
|
16
|
+
* a locked session does NOT hide — see PRD §7.5 and OI-20.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { read, write } from "./storage.js";
|
|
20
|
+
|
|
21
|
+
function detect() {
|
|
22
|
+
const ua = navigator.userAgent;
|
|
23
|
+
|
|
24
|
+
const os =
|
|
25
|
+
/Windows/i.test(ua) ? "Windows" :
|
|
26
|
+
/Android/i.test(ua) ? "Android" :
|
|
27
|
+
/iPhone|iPad|iPod/i.test(ua) ? "iOS" :
|
|
28
|
+
/Mac OS X|Macintosh/i.test(ua) ? "macOS" :
|
|
29
|
+
/Linux/i.test(ua) ? "Linux" : "Unknown";
|
|
30
|
+
|
|
31
|
+
// Order matters: Edge and Opera both contain "Chrome", Chrome contains "Safari".
|
|
32
|
+
const browser =
|
|
33
|
+
/Edg\//i.test(ua) ? "Edge" :
|
|
34
|
+
/OPR\/|Opera/i.test(ua) ? "Opera" :
|
|
35
|
+
/Firefox\//i.test(ua) ? "Firefox" :
|
|
36
|
+
/Chrome\//i.test(ua) ? "Chrome" :
|
|
37
|
+
/Safari\//i.test(ua) ? "Safari" : "Browser";
|
|
38
|
+
|
|
39
|
+
return `${browser} · ${os}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Persisted so a device keeps its name across reloads, and stays renameable. */
|
|
43
|
+
export function name() {
|
|
44
|
+
const saved = read("deviceName");
|
|
45
|
+
if (saved) return saved;
|
|
46
|
+
const detected = detect();
|
|
47
|
+
write("deviceName", detected);
|
|
48
|
+
return detected;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function rename(newName) {
|
|
52
|
+
const clean = String(newName || "").trim().slice(0, 40);
|
|
53
|
+
if (clean) write("deviceName", clean);
|
|
54
|
+
return clean || name();
|
|
55
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-session clip history — PRD FR-2.9 (last 20 clips, one-click copy).
|
|
3
|
+
*
|
|
4
|
+
* ── PRIVACY INVARIANT ──────────────────────────────────────────────────────
|
|
5
|
+
* This module persists to **sessionStorage only. Never localStorage.**
|
|
6
|
+
*
|
|
7
|
+
* Clipboard content is not ordinary application data: in practice it is
|
|
8
|
+
* passwords, API tokens, 2FA codes and private URLs. Those must not survive the
|
|
9
|
+
* browser session, must not be readable by the next person to open the laptop,
|
|
10
|
+
* and must not leak between rooms. sessionStorage is scoped to the tab and dies
|
|
11
|
+
* with it, which is exactly the lifetime we want.
|
|
12
|
+
*
|
|
13
|
+
* core/storage.js is the localStorage wrapper and is explicitly documented as
|
|
14
|
+
* "clipboard *content* never comes near this — only preferences". So the
|
|
15
|
+
* sessionStorage twin lives here rather than being bolted onto that module: the
|
|
16
|
+
* two stores have different lifetimes for a reason, and keeping them in separate
|
|
17
|
+
* files is what stops a future edit from quietly moving clips onto disk.
|
|
18
|
+
*
|
|
19
|
+
* The same reasoning drives the key-change behaviour: a different share key is a
|
|
20
|
+
* different room and a different set of people. History never crosses that line.
|
|
21
|
+
*
|
|
22
|
+
* Node-testable on purpose — this file imports only core/bus.js, and every
|
|
23
|
+
* sessionStorage call is wrapped, so it degrades to memory-only where the API is
|
|
24
|
+
* missing (node, private mode, storage disabled).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { on, emit, EV } from "./bus.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* PRD FR-2.9 caps history at 20. This belongs in core/config.js with the other
|
|
31
|
+
* limits; it lives here only because config.js is owned elsewhere. Move it when
|
|
32
|
+
* the two land together.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_CLIPS = 20;
|
|
35
|
+
|
|
36
|
+
/** Event names this module owns. Use the constants — a typo'd literal is a silent no-op. */
|
|
37
|
+
export const EVENTS = {
|
|
38
|
+
/** {clips, reason} — the list changed (add / clear / hydrate). */
|
|
39
|
+
CHANGED: "history:changed",
|
|
40
|
+
/** {text} — a clip was picked from history and should be loaded into the editor. */
|
|
41
|
+
RESTORE: "history:restore",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const STORE_KEY = "realtimeclipboard.history";
|
|
45
|
+
|
|
46
|
+
let clips = [];
|
|
47
|
+
let roomKey = null; // share key the current list belongs to; null until first KEY_CHANGED
|
|
48
|
+
let started = false;
|
|
49
|
+
let seq = 0;
|
|
50
|
+
|
|
51
|
+
/* ------------------------------------------------------------------ storage */
|
|
52
|
+
/*
|
|
53
|
+
* Mirrors the shape of core/storage.js (read / write / remove, wrapped so a
|
|
54
|
+
* disabled-storage browser degrades instead of throwing) but targets
|
|
55
|
+
* sessionStorage. try/catch also swallows the ReferenceError under node, which
|
|
56
|
+
* is what makes this module testable outside a browser.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
function readStore() {
|
|
60
|
+
try {
|
|
61
|
+
const raw = sessionStorage.getItem(STORE_KEY);
|
|
62
|
+
return raw === null ? null : JSON.parse(raw);
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeStore(value) {
|
|
67
|
+
try { sessionStorage.setItem(STORE_KEY, JSON.stringify(value)); return true; }
|
|
68
|
+
catch { return false; } // quota, private mode, or no sessionStorage at all
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function removeStore() {
|
|
72
|
+
try { sessionStorage.removeItem(STORE_KEY); } catch { /* nothing to do */ }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A clip can be 50k characters and we keep 20 of them, so a full list can push
|
|
77
|
+
* a megabyte. If the write is refused we keep going in memory rather than
|
|
78
|
+
* dropping the clip — losing persistence across a reload is a smaller failure
|
|
79
|
+
* than losing the user's clipboard.
|
|
80
|
+
*/
|
|
81
|
+
function persist() {
|
|
82
|
+
if (!clips.length && roomKey === null) return removeStore();
|
|
83
|
+
writeStore({ key: roomKey, clips });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ------------------------------------------------------------------- model */
|
|
87
|
+
|
|
88
|
+
const nextId = () => `h${Date.now().toString(36)}${(++seq).toString(36)}`;
|
|
89
|
+
|
|
90
|
+
/** Local normalisation — deliberately not importing core/keys.js, which pulls in
|
|
91
|
+
* config.js and its top-level `location` read (breaks node testability). */
|
|
92
|
+
const normKey = k => String(k ?? "").trim().toUpperCase();
|
|
93
|
+
|
|
94
|
+
function announce(reason) {
|
|
95
|
+
emit(EVENTS.CHANGED, { clips: all(), reason });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Newest first. Returns a shallow copy — callers must not mutate the list. */
|
|
99
|
+
export function all() {
|
|
100
|
+
return clips.slice();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function get(id) {
|
|
104
|
+
return clips.find(c => c.id === id) ?? null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const size = () => clips.length;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Record a clip. Returns the new entry, or null if it was ignored.
|
|
111
|
+
*
|
|
112
|
+
* Ignored when: the text is empty/whitespace, or it is identical to the most
|
|
113
|
+
* recent entry. That last one matters more than it looks — capture tiers can
|
|
114
|
+
* fire twice for one copy (paste event + poll), and a peer echoing our own clip
|
|
115
|
+
* back arrives with the same text under a different direction. Consecutive
|
|
116
|
+
* duplicates are noise in every one of those cases.
|
|
117
|
+
*/
|
|
118
|
+
export function add({ text, direction }) {
|
|
119
|
+
const value = String(text ?? "");
|
|
120
|
+
if (!value.trim()) return null;
|
|
121
|
+
|
|
122
|
+
const dir = direction === "sent" ? "sent" : "received";
|
|
123
|
+
if (clips.length && clips[0].text === value) return null; // consecutive dedupe
|
|
124
|
+
|
|
125
|
+
const entry = { id: nextId(), text: value, direction: dir, at: new Date(), chars: value.length };
|
|
126
|
+
clips.unshift(entry);
|
|
127
|
+
if (clips.length > MAX_CLIPS) clips.length = MAX_CLIPS; // FR-2.9: last 20
|
|
128
|
+
|
|
129
|
+
persist();
|
|
130
|
+
announce("add");
|
|
131
|
+
return entry;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Drop everything, in memory and in sessionStorage. */
|
|
135
|
+
export function clear(reason = "clear") {
|
|
136
|
+
const had = clips.length;
|
|
137
|
+
clips = [];
|
|
138
|
+
removeStore();
|
|
139
|
+
if (roomKey !== null) persist();
|
|
140
|
+
if (had) announce(reason);
|
|
141
|
+
return had;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* -------------------------------------------------------------------- boot */
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Rehydrate whatever this tab had before a reload. The stored key is not known
|
|
148
|
+
* to be the current room yet — the first KEY_CHANGED decides whether to keep or
|
|
149
|
+
* discard this (see onKeyChanged).
|
|
150
|
+
*/
|
|
151
|
+
function hydrate() {
|
|
152
|
+
const saved = readStore();
|
|
153
|
+
if (!saved || !Array.isArray(saved.clips)) return;
|
|
154
|
+
|
|
155
|
+
roomKey = saved.key ?? null;
|
|
156
|
+
clips = saved.clips
|
|
157
|
+
.filter(c => c && typeof c.text === "string")
|
|
158
|
+
.slice(0, MAX_CLIPS)
|
|
159
|
+
.map(c => ({
|
|
160
|
+
id: typeof c.id === "string" ? c.id : nextId(),
|
|
161
|
+
text: c.text,
|
|
162
|
+
direction: c.direction === "sent" ? "sent" : "received",
|
|
163
|
+
at: new Date(c.at ?? Date.now()), // JSON round-trips Date to a string
|
|
164
|
+
chars: typeof c.chars === "number" ? c.chars : c.text.length,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A new share key is a new room, new peers, and a new privacy context. Clips
|
|
170
|
+
* from the old room must not be sitting in the panel when someone else joins.
|
|
171
|
+
*
|
|
172
|
+
* The first KEY_CHANGED after boot is not a rotation, though — main.js emits one
|
|
173
|
+
* during startup for the key we already had. Comparing against the key stored
|
|
174
|
+
* alongside the clips is what tells the two apart, and is why the key is
|
|
175
|
+
* persisted with the list rather than held only in memory.
|
|
176
|
+
*/
|
|
177
|
+
function onKeyChanged({ key }) {
|
|
178
|
+
const next = normKey(key);
|
|
179
|
+
if (!next) return;
|
|
180
|
+
|
|
181
|
+
if (roomKey !== null && normKey(roomKey) !== next) {
|
|
182
|
+
roomKey = next;
|
|
183
|
+
clear("key-changed");
|
|
184
|
+
persist();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
roomKey = next;
|
|
189
|
+
persist();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Subscribe to the bus. Idempotent — ui/historyPanel.js calls this so the
|
|
194
|
+
* feature is one init() line in main.js, but calling it from main.js directly is
|
|
195
|
+
* equally fine.
|
|
196
|
+
*/
|
|
197
|
+
export function init() {
|
|
198
|
+
if (started) return;
|
|
199
|
+
started = true;
|
|
200
|
+
|
|
201
|
+
hydrate();
|
|
202
|
+
|
|
203
|
+
on(EV.TEXT_CAPTURED, ({ text }) => add({ text, direction: "sent" }));
|
|
204
|
+
on(EV.TEXT_RECEIVED, ({ text }) => add({ text, direction: "received" }));
|
|
205
|
+
on(EV.KEY_CHANGED, onKeyChanged);
|
|
206
|
+
|
|
207
|
+
if (clips.length) announce("hydrate");
|
|
208
|
+
}
|
package/src/core/keys.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** Share-key generation and normalisation. */
|
|
2
|
+
|
|
3
|
+
import { KEY, LOCK } from "./config.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cryptographically random key from the unambiguous alphabet.
|
|
7
|
+
*
|
|
8
|
+
* Note the modulo bias: 256 does not divide 30, so the first 16 letters of the
|
|
9
|
+
* alphabet are very slightly likelier than the last 14. The effect is about
|
|
10
|
+
* 0.03 bits over a 6-character key — irrelevant next to the 30-bit total, and
|
|
11
|
+
* called out here so nobody has to rediscover it.
|
|
12
|
+
*/
|
|
13
|
+
export function generate(length = KEY.LENGTH) {
|
|
14
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
|
15
|
+
return Array.from(bytes, b => KEY.ALPHABET[b % KEY.ALPHABET.length]).join("");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Bits of entropy in a key of this length, given the 30-letter alphabet.
|
|
20
|
+
*
|
|
21
|
+
* 6 chars ≈ 29.4 bits — the default. Convenient, and brute-forceable
|
|
22
|
+
* offline by anyone who captured ciphertext.
|
|
23
|
+
* 10 chars ≈ 49.1 bits — ~1.6 million times harder, still typeable.
|
|
24
|
+
*
|
|
25
|
+
* PBKDF2 at 250k iterations multiplies the cost of each guess, but it does not
|
|
26
|
+
* change the shape of the problem: short keys are a convenience decision, and
|
|
27
|
+
* this function exists so the UI can say so in numbers rather than adjectives.
|
|
28
|
+
*/
|
|
29
|
+
export function entropyBits(length) {
|
|
30
|
+
return Math.log2(KEY.ALPHABET.length) * length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const LENGTHS = { NORMAL: KEY.LENGTH, LONG: KEY.LONG_LENGTH };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalise before ANY use — hashing, comparison, display.
|
|
37
|
+
*
|
|
38
|
+
* This matters more than it looks: the room name is a hash of the key, and
|
|
39
|
+
* "D75LV" and "d75lv" hash differently. Skipping this silently drops two users
|
|
40
|
+
* into different rooms while both believe they typed the same key.
|
|
41
|
+
* Verified in docs/M0-RESULTS.md §6.
|
|
42
|
+
*/
|
|
43
|
+
export function normalise(raw) {
|
|
44
|
+
return String(raw || "").trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Deliberately more permissive than the generation alphabet.
|
|
49
|
+
*
|
|
50
|
+
* KEY.ALPHABET exists so generated keys are unambiguous when read aloud or
|
|
51
|
+
* retyped — it is a constraint on what we PRODUCE. Validation must accept
|
|
52
|
+
* anything a peer might legitimately hand us, because:
|
|
53
|
+
*
|
|
54
|
+
* - a key shared from another build (or a future alphabet) is still valid;
|
|
55
|
+
* the room name is a hash, and a hash accepts any input
|
|
56
|
+
* - rejecting an in-use key strands the user with no way to join
|
|
57
|
+
*
|
|
58
|
+
* "D75LV" is the worked example throughout the docs and contains an L, which
|
|
59
|
+
* the generator will never emit. It still has to work.
|
|
60
|
+
*/
|
|
61
|
+
export function isValid(raw) {
|
|
62
|
+
const k = normalise(raw);
|
|
63
|
+
return k.length >= 4 && k.length <= 32;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Bits of entropy in a PIN, estimated from the character classes it actually
|
|
68
|
+
* uses rather than from its length alone.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately pessimistic — it assumes an attacker who knows the alphabet you
|
|
71
|
+
* drew from, which is the only assumption worth making about someone running an
|
|
72
|
+
* offline attack. "123456" is counted as six digits, not six printable ASCII
|
|
73
|
+
* characters, so the dialog reports ~20 bits and not a flattering ~39.
|
|
74
|
+
*
|
|
75
|
+
* The number matters more here than it does for a key. A key is guessed from
|
|
76
|
+
* nothing; a PIN is guessed by someone who may already hold the link, and at
|
|
77
|
+
* that point it is the entire remaining secret.
|
|
78
|
+
*/
|
|
79
|
+
export function pinEntropyBits(pin) {
|
|
80
|
+
const p = String(pin ?? "");
|
|
81
|
+
if (!p) return 0;
|
|
82
|
+
let alphabet = 0;
|
|
83
|
+
if (/[a-z]/.test(p)) alphabet += 26;
|
|
84
|
+
if (/[A-Z]/.test(p)) alphabet += 26;
|
|
85
|
+
if (/[0-9]/.test(p)) alphabet += 10;
|
|
86
|
+
if (/[^a-zA-Z0-9]/.test(p)) alphabet += 33; // printable ASCII punctuation
|
|
87
|
+
return Math.log2(alphabet) * p.length;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ------------------------------------------------------------------
|
|
91
|
+
The fragment
|
|
92
|
+
|
|
93
|
+
A locked session's link carries a marker but never the PIN: `#!ABCDEF`.
|
|
94
|
+
That a session is locked is not a secret — the PIN is — and the app has to
|
|
95
|
+
know before it derives anything, because the marker is what decides which of
|
|
96
|
+
two completely different derivations to run.
|
|
97
|
+
|
|
98
|
+
Parsing happens BEFORE normalise(), and that ordering is load-bearing:
|
|
99
|
+
normalise() strips everything outside [A-Z0-9], so running it first turns
|
|
100
|
+
"#!ABCDEF" into the perfectly valid, completely different key "ABCDEF".
|
|
101
|
+
|
|
102
|
+
An older build has no idea about any of this. It normalises the fragment,
|
|
103
|
+
drops the "!", and joins the UNLOCKED room named ABCDEF — a different room
|
|
104
|
+
from the locked one, which it cannot address. It finds an empty room and
|
|
105
|
+
learns nothing, which is the correct way for this to fail.
|
|
106
|
+
------------------------------------------------------------------- */
|
|
107
|
+
|
|
108
|
+
export const LOCK_SIGIL = LOCK.SIGIL;
|
|
109
|
+
|
|
110
|
+
/** Split a raw fragment into its key and its lock flag. */
|
|
111
|
+
export function parseFragment(raw) {
|
|
112
|
+
const s = String(raw ?? "").trim();
|
|
113
|
+
const locked = s.startsWith(LOCK.SIGIL);
|
|
114
|
+
return { key: normalise(locked ? s.slice(LOCK.SIGIL.length) : s), locked };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Build one. The inverse of parseFragment, and tested as such. */
|
|
118
|
+
export function fragment(key, locked = false) {
|
|
119
|
+
return (locked ? LOCK.SIGIL : "") + normalise(key);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Read the key from the URL fragment. The fragment is never sent to a server. */
|
|
123
|
+
export function fromUrl() {
|
|
124
|
+
return parseFragment(location.hash.slice(1));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function toUrl(key, locked = false) {
|
|
128
|
+
location.hash = fragment(key, locked);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Drop the key out of the address bar without navigating.
|
|
133
|
+
*
|
|
134
|
+
* For the one case where the session in the URL is not merely over but closed
|
|
135
|
+
* to this device: it was locked by somebody else and we were removed from it
|
|
136
|
+
* (main.js onEvicted). The fragment is what boot() reads first, so leaving it
|
|
137
|
+
* in place means every reload rejoins a room we have been ejected from and
|
|
138
|
+
* gets ejected again.
|
|
139
|
+
*
|
|
140
|
+
* replaceState rather than `location.hash = ""`, which leaves a bare "#" on the
|
|
141
|
+
* URL and pushes a history entry — so Back would put the dead key straight
|
|
142
|
+
* back.
|
|
143
|
+
*/
|
|
144
|
+
export function clearUrl() {
|
|
145
|
+
history.replaceState(null, "", location.pathname + location.search);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function shareLink(key, locked = false) {
|
|
149
|
+
return `${location.origin}${location.pathname}#${fragment(key, locked)}`;
|
|
150
|
+
}
|