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.
@@ -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
+ }
@@ -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
+ }