realtimeclipboard 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,20 @@
1
1
  /**
2
2
  * End-to-end encryption. All of it. No libraries.
3
3
  *
4
- * OPEN SESSION — the key the user types serves two purposes without the server
5
- * learning it:
4
+ * OPEN prk = PBKDF2(KEY, salt, 250k)
5
+ * aesKey = HKDF(prk, "…/aes")
6
+ * roomHash = HKDF(prk, "…/room") -> sent
6
7
  *
7
- * roomHash = SHA-256("realtimeclipboard:" + KEY)[0..16] -> sent, routes the room
8
- * aesKey = PBKDF2(KEY, salt, 250k) -> never leaves this browser
8
+ * LOCKED prk = PBKDF2(PIN, "realtimeclipboard-lock-v1:" + KEY, 600k)
9
+ * aesKey = HKDF(prk, "…/aes")
10
+ * roomHash = HKDF(prk, "…/room") -> sent
11
+ * authToken = HKDF(prk, "…/auth") -> sent, proves PIN knowledge
9
12
  *
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.
13
+ * The room hash requiring the PIN is the load-bearing part: it turns "you cannot
14
+ * read it" into "you cannot find it". Someone holding the link but not the PIN
15
+ * computes a different room hash and never appears in the real room at all — no
16
+ * peer slot, no roster entry, no traffic metadata. Locked and unlocked rooms of
17
+ * the same key are disjoint for the same reason. See PRD §7.3.
29
18
  */
30
19
 
31
20
  import { CRYPTO } from "./config.js";
@@ -34,89 +23,76 @@ const enc = new TextEncoder();
34
23
  const dec = new TextDecoder();
35
24
 
36
25
  /** 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
- }
26
+ let cached = { id: null, open: null };
43
27
 
44
28
  /**
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.
29
+ * Both outputs of an open session, from one PBKDF2.
30
+ *
31
+ * Several hundred ms on a low-end Android, so call once per session and show an
32
+ * "unlocking" state — never per message. It is not a new cost: the room hash was
33
+ * always awaited alongside this derivation before the connection opened, so the
34
+ * slower of the two already set the pace.
35
+ *
36
+ * Returning them together is deliberate. Two exported functions is what let the
37
+ * room hash be computed by a route that never ran the PBKDF2, which is exactly
38
+ * how it ended up being a bare SHA-256 of the key.
47
39
  */
48
- export async function deriveKey(key) {
49
- if (cached.id === `open:${key}` && cached.aesKey) return cached.aesKey;
40
+ export async function deriveOpen(key) {
41
+ if (cached.id === `open:${key}` && cached.open) return cached.open;
50
42
 
51
43
  const material = await crypto.subtle.importKey(
52
- "raw", enc.encode(key), "PBKDF2", false, ["deriveKey"]
44
+ "raw", enc.encode(key), "PBKDF2", false, ["deriveBits"]
53
45
  );
54
- const aesKey = await crypto.subtle.deriveKey(
46
+ const prk = await crypto.subtle.deriveBits(
55
47
  { name: "PBKDF2", salt: enc.encode(CRYPTO.SALT),
56
48
  iterations: CRYPTO.ITERATIONS, hash: "SHA-256" },
57
49
  material,
58
- { name: "AES-GCM", length: 256 },
59
- false,
60
- ["encrypt", "decrypt"]
50
+ 256
61
51
  );
62
- cached = { id: `open:${key}`, aesKey };
63
- return aesKey;
52
+
53
+ const open = await expand(prk, CRYPTO.OPEN_INFO);
54
+ cached = { id: `open:${key}`, open };
55
+ return open;
64
56
  }
65
57
 
66
- /* ------------------------------------------------------------------
67
- Locked sessions
68
- ------------------------------------------------------------------- */
58
+ /* ---- locked sessions ---- */
69
59
 
70
60
  /**
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:
61
+ * The exact opposite of what normalise() does to a key, and getting it backwards
62
+ * is silent.
76
63
  *
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.
64
+ * - NFC is mandatory. "é" as one code point and as "e" + combining accent are
65
+ * different byte strings deriving different rooms, and nothing reports it —
66
+ * the second device is simply alone, looking like a wrong PIN.
67
+ * - Trim the ends: a pasted trailing space is invisible on screen.
68
+ * - Keep case and the middle. A PIN is never read aloud and retyped, so
69
+ * uppercasing would throw away a bit per letter to buy nothing.
86
70
  */
87
71
  export function normalisePin(raw) {
88
72
  return String(raw ?? "").normalize("NFC").trim();
89
73
  }
90
74
 
91
75
  /**
92
- * The whole locked-session derivation: one PBKDF2, three outputs.
76
+ * One PBKDF2, three outputs. PBKDF2 reruns its full iteration count per 32-byte
77
+ * block, so asking it for 64 bytes would cost double; stretch once and expand
78
+ * with HKDF, which is a couple of HMACs.
93
79
  *
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.
80
+ * The share key as salt stops ONE table covering every session — the open path,
81
+ * with its single global salt, has exactly that weakness. It buys nothing
82
+ * against the attacker this feature is for: someone holding the link can compute
83
+ * the salt. Only PIN length and iteration count defend there, which is why the
84
+ * dialog reports entropy in bits rather than calling a short PIN "secure".
98
85
  *
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.
86
+ * Returns `prk` as hex so a refresh can skip the 600k iterations.
111
87
  */
112
88
  export async function deriveLocked(key, pin) {
113
89
  const prk = await stretch(key, normalisePin(pin));
114
- return { ...(await expand(prk)), prk: hex(new Uint8Array(prk)) };
90
+ return { ...(await expand(prk, CRYPTO.LOCK_INFO)), prk: hex(new Uint8Array(prk)) };
115
91
  }
116
92
 
117
93
  /** Same outputs, from a remembered prk. No PBKDF2, so this is instant. */
118
94
  export async function deriveLockedFromPrk(prkHex) {
119
- return { ...(await expand(unhex(prkHex))), prk: prkHex };
95
+ return { ...(await expand(unhex(prkHex), CRYPTO.LOCK_INFO)), prk: prkHex };
120
96
  }
121
97
 
122
98
  async function stretch(key, pin) {
@@ -131,37 +107,37 @@ async function stretch(key, pin) {
131
107
  );
132
108
  }
133
109
 
134
- async function expand(prk) {
110
+ /**
111
+ * Shared by both paths, which is the point: the open and locked sessions differ
112
+ * only in what they stretch and under which domain-separation strings, never in
113
+ * how the outputs are pulled off the PRK. `AUTH` is absent from OPEN_INFO — an
114
+ * unlocked room has no PIN to prove knowledge of — and its absence is what makes
115
+ * the token optional rather than a flag.
116
+ */
117
+ async function expand(prk, labels) {
135
118
  const ikm = await crypto.subtle.importKey("raw", prk, "HKDF", false, ["deriveBits", "deriveKey"]);
136
119
  const info = i => ({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: enc.encode(i) });
120
+ const bits = i => crypto.subtle.deriveBits(info(i), ikm, CRYPTO.ROOM_HASH_BYTES * 8);
137
121
 
138
122
  const [aesKey, room, auth] = await Promise.all([
139
123
  crypto.subtle.deriveKey(
140
- info(CRYPTO.LOCK_INFO.AES), ikm,
124
+ info(labels.AES), ikm,
141
125
  { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
142
126
  ),
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),
127
+ bits(labels.ROOM),
128
+ labels.AUTH ? bits(labels.AUTH) : null,
145
129
  ]);
146
130
 
147
131
  return {
148
132
  aesKey,
149
- roomHash: hex(new Uint8Array(room)),
150
- authToken: hex(new Uint8Array(auth)),
133
+ roomHash: hex(new Uint8Array(room)),
134
+ ...(auth ? { authToken: hex(new Uint8Array(auth)) } : {}),
151
135
  };
152
136
  }
153
137
 
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
- */
138
+ // No locked-session cache on purpose: the path runs once per session, and the
139
+ // things that DO call it again — a corrected PIN, a rotated key — are exactly
140
+ // where the previous answer must not be reused. The stored prk covers refresh.
165
141
 
166
142
  /** -> {payload, iv} both base64. A fresh IV per message is mandatory for GCM. */
167
143
  export async function encrypt(aesKey, plaintext) {
@@ -179,14 +155,10 @@ export async function decrypt(aesKey, payloadB64, ivB64) {
179
155
  return dec.decode(buf);
180
156
  }
181
157
 
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 }; }
158
+ // Called on leaving a session and on rotating the key. This had no callers for
159
+ // years, so the AES key of a room you walked out of stayed live until you opened
160
+ // another one.
161
+ export function clearCache() { cached = { id: null, open: null }; }
190
162
 
191
163
  /* ---- hex helpers ---- */
192
164
  function hex(bytes) {
@@ -1,33 +1,34 @@
1
1
  /**
2
- * A human-readable name for this device, shown in the peer list.
2
+ * A human-readable name for this device, shown in the peer list. Derived from
3
+ * the user agent, which is unreliable by design — a label to help someone
4
+ * recognise their own laptop in a list of three, not an identity.
3
5
  *
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.
6
+ * IT IS SENT IN THE CLEAR: `protocol.hello()` puts `name` in a plaintext field,
7
+ * and the relay stores and rebroadcasts it in every roster. So keep it a label —
8
+ * "Chrome · Windows" is fine, and a name is not the place for anything you would
9
+ * mind the relay operator reading. One of the things a locked session does NOT
10
+ * hide, see PRD §7.5 and OI-20.
17
11
  */
18
12
 
19
13
  import { read, write } from "./storage.js";
20
14
 
15
+ /**
16
+ * Windows | Android | iOS | macOS | Linux | Unknown. Exported because the
17
+ * desktop guide describes the tray, which behaves differently enough per
18
+ * platform that one paragraph covering all three would be wrong on two.
19
+ */
20
+ export function os() {
21
+ const ua = globalThis.navigator?.userAgent ?? "";
22
+ return /Windows/i.test(ua) ? "Windows" :
23
+ /Android/i.test(ua) ? "Android" :
24
+ /iPhone|iPad|iPod/i.test(ua) ? "iOS" :
25
+ /Mac OS X|Macintosh/i.test(ua) ? "macOS" :
26
+ /Linux/i.test(ua) ? "Linux" : "Unknown";
27
+ }
28
+
21
29
  function detect() {
22
30
  const ua = navigator.userAgent;
23
31
 
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
32
  // Order matters: Edge and Opera both contain "Chrome", Chrome contains "Safari".
32
33
  const browser =
33
34
  /Edg\//i.test(ua) ? "Edge" :
@@ -36,7 +37,7 @@ function detect() {
36
37
  /Chrome\//i.test(ua) ? "Chrome" :
37
38
  /Safari\//i.test(ua) ? "Safari" : "Browser";
38
39
 
39
- return `${browser} · ${os}`;
40
+ return `${browser} · ${os()}`;
40
41
  }
41
42
 
42
43
  /** Persisted so a device keeps its name across reloads, and stays renameable. */
@@ -1,35 +1,22 @@
1
1
  /**
2
2
  * In-session clip history — PRD FR-2.9 (last 20 clips, one-click copy).
3
3
  *
4
- * ── PRIVACY INVARIANT ──────────────────────────────────────────────────────
5
- * This module persists to **sessionStorage only. Never localStorage.**
4
+ * PRIVACY INVARIANT: persists to **sessionStorage only, never localStorage**.
5
+ * Clipboard content is passwords, tokens, 2FA codes and private URLs; it must
6
+ * not survive the browser session, be readable by the next person to open the
7
+ * laptop, or leak between rooms. The sessionStorage twin lives here rather than
8
+ * in core/storage.js precisely so a future edit cannot quietly move clips onto
9
+ * disk — that module is the localStorage one, for preferences.
6
10
  *
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).
11
+ * Node-testable on purpose: imports only core/bus.js, and every sessionStorage
12
+ * call is wrapped, so it degrades to memory-only where the API is missing.
25
13
  */
26
14
 
27
15
  import { on, emit, EV } from "./bus.js";
28
16
 
29
17
  /**
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.
18
+ * PRD FR-2.9 caps history at 20. Belongs in core/config.js with the other
19
+ * limits; it is here because importing config.js would cost node testability.
33
20
  */
34
21
  export const MAX_CLIPS = 20;
35
22
 
@@ -48,14 +35,8 @@ let roomKey = null; // share key the current list belongs to; null until fi
48
35
  let started = false;
49
36
  let seq = 0;
50
37
 
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
-
38
+ // Mirrors core/storage.js against sessionStorage. try/catch also swallows the
39
+ // ReferenceError under node, which is what makes this module testable.
59
40
  function readStore() {
60
41
  try {
61
42
  const raw = sessionStorage.getItem(STORE_KEY);
@@ -73,22 +54,19 @@ function removeStore() {
73
54
  }
74
55
 
75
56
  /**
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.
57
+ * A refused write keeps going in memory rather than dropping the clip: 20 clips
58
+ * of 50k characters can push a megabyte, and losing persistence across a reload
59
+ * is a smaller failure than losing the user's clipboard.
80
60
  */
81
61
  function persist() {
82
62
  if (!clips.length && roomKey === null) return removeStore();
83
63
  writeStore({ key: roomKey, clips });
84
64
  }
85
65
 
86
- /* ------------------------------------------------------------------- model */
87
-
88
66
  const nextId = () => `h${Date.now().toString(36)}${(++seq).toString(36)}`;
89
67
 
90
- /** Local normalisation — deliberately not importing core/keys.js, which pulls in
91
- * config.js and its top-level `location` read (breaks node testability). */
68
+ // Local, deliberately not core/keys.js — that pulls in config.js and its
69
+ // top-level `location` read, which breaks node testability.
92
70
  const normKey = k => String(k ?? "").trim().toUpperCase();
93
71
 
94
72
  function announce(reason) {
@@ -107,13 +85,10 @@ export function get(id) {
107
85
  export const size = () => clips.length;
108
86
 
109
87
  /**
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.
88
+ * Record a clip. Returns the new entry, or null if it was ignored — empty text,
89
+ * or identical to the most recent entry. Consecutive dedupe matters more than it
90
+ * looks: capture tiers can fire twice for one copy (paste event + poll), and a
91
+ * peer echoing our own clip back arrives with the same text.
117
92
  */
118
93
  export function add({ text, direction }) {
119
94
  const value = String(text ?? "");
@@ -141,13 +116,8 @@ export function clear(reason = "clear") {
141
116
  return had;
142
117
  }
143
118
 
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
- */
119
+ // The stored key is not known to be the current room yet — the first
120
+ // KEY_CHANGED decides whether to keep or discard this.
151
121
  function hydrate() {
152
122
  const saved = readStore();
153
123
  if (!saved || !Array.isArray(saved.clips)) return;
@@ -166,13 +136,10 @@ function hydrate() {
166
136
  }
167
137
 
168
138
  /**
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.
139
+ * A new share key is a new room, new peers, and a new privacy context. The first
140
+ * KEY_CHANGED after boot is not a rotation, though — main.js emits one for the
141
+ * key we already had, and comparing against the key stored alongside the clips
142
+ * is what tells the two apart.
176
143
  */
177
144
  function onKeyChanged({ key }) {
178
145
  const next = normKey(key);
@@ -189,11 +156,7 @@ function onKeyChanged({ key }) {
189
156
  persist();
190
157
  }
191
158
 
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
- */
159
+ /** Subscribe to the bus. Idempotent. */
197
160
  export function init() {
198
161
  if (started) return;
199
162
  started = true;