realtimeclipboard 0.7.0 → 1.0.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,117 @@
1
+ /**
2
+ * Sealing and opening signalling frames.
3
+ *
4
+ * The relay is a blind pipe (docs/P2P-FILES.md §4). Signalling in the clear
5
+ * would hand it every SDP, every ICE candidate — including peer IPs — and, on
6
+ * the fallback path, every byte of every file. Routing fields stay readable
7
+ * because the relay must deliver the frame; everything else is sealed with the
8
+ * session key.
9
+ *
10
+ * Its own module rather than a pair of private functions in main.js because
11
+ * both halves of it are security rules — what may travel unsealed, and what a
12
+ * payload may not claim — and a rule with no test is a rule until someone
13
+ * edits it.
14
+ */
15
+
16
+ import * as cryptoBox from "./crypto.js";
17
+ import * as state from "./state.js";
18
+
19
+ /**
20
+ * Readable to the relay, because it has to route on them. `from` is the one the
21
+ * relay STAMPS: it is the connection a frame arrived on, and the only field a
22
+ * client cannot choose.
23
+ */
24
+ export const ROUTING_FIELDS = new Set([
25
+ "t", "to", "from", "originId", "id", "seq", "total", "crc",
26
+ ]);
27
+
28
+ /**
29
+ * Frame types allowed to arrive with no envelope, because they carry nothing
30
+ * but routing. Set by the composition root from the files layer's own list —
31
+ * inferring it would mean guessing, and guessing wrong in the permissive
32
+ * direction is the bug this exists to prevent.
33
+ */
34
+ let plaintext = new Set();
35
+
36
+ export function setPlaintextFrames(types) {
37
+ plaintext = new Set(types ?? []);
38
+ }
39
+
40
+ /**
41
+ * The key-taking forms. `encryptFrame`/`decryptFrame` are these with the
42
+ * session's key filled in, and they exist separately because `cli/session.mjs`
43
+ * — the half `cli/`, `mcp/`, `vscode/` and `browser/` all share — holds its key
44
+ * in its own object and never touches `core/state.js`. Without these it would
45
+ * have to hand-roll the split-seal-strip dance to answer one frame, which is
46
+ * the reimplementation those surfaces are forbidden.
47
+ */
48
+ export async function sealWith(aesKey, frame) {
49
+ if (!aesKey) return frame;
50
+
51
+ const routing = {}, secret = {};
52
+ for (const [k, v] of Object.entries(frame)) {
53
+ (ROUTING_FIELDS.has(k) ? routing : secret)[k] = v;
54
+ }
55
+ if (!Object.keys(secret).length) return frame;
56
+
57
+ const { payload, iv } = await cryptoBox.encrypt(aesKey, JSON.stringify(secret));
58
+ return { ...routing, payload, iv };
59
+ }
60
+
61
+ export const encryptFrame = (frame) => sealWith(state.get().aesKey, frame);
62
+
63
+ /**
64
+ * Open a sealed frame, or refuse it. Two things this must not do, both of which
65
+ * it used to.
66
+ *
67
+ * It must not pass an UNSEALED frame through. A frame arriving with no envelope
68
+ * was returned as it came, so anything that could reach the relay — the relay
69
+ * itself included — could inject editor text, fake file announcements with
70
+ * their thumbnails, and RTC negotiation into a session whose key it does not
71
+ * hold. Only frames carrying nothing BUT routing may legitimately arrive in the
72
+ * clear, and they are named rather than inferred.
73
+ *
74
+ * And it must not let decrypted content overwrite the routing. The merge ran
75
+ * secret-last, so a session member could seal `from: someoneElsesPeerId` inside
76
+ * a payload and displace the relay's stamp — the field file-retraction
77
+ * ownership is checked against.
78
+ *
79
+ * It deliberately does NOT latch state.setVerified(). Opening a frame is proof
80
+ * the PIN is right for the room the frame came from, and this returns to a
81
+ * caller that has yet to check whether that is still the room we are in — so
82
+ * latching here could mark a session verified on the strength of a frame from
83
+ * the one before it. The caller does it, after that check. See openFrame() in
84
+ * main.js.
85
+ */
86
+ export async function openWith(aesKey, frame) {
87
+ if (!aesKey) return null; // no session: nothing is trusted
88
+
89
+ if (!frame.payload || !frame.iv) {
90
+ if (plaintext.has(frame.t)) return frame;
91
+ console.warn("[realtimeclipboard] dropped an unsealed frame", frame.t);
92
+ return null;
93
+ }
94
+
95
+ try {
96
+ const secret = JSON.parse(await cryptoBox.decrypt(aesKey, frame.payload, frame.iv));
97
+ const { payload, iv, ...routing } = frame;
98
+
99
+ // A sealed payload has no business carrying routing: encryptFrame() splits
100
+ // them out before sealing, so one that arrives inside is a forgery or a bug.
101
+ for (const field of ROUTING_FIELDS) {
102
+ if (!(field in secret)) continue;
103
+ console.warn(`[realtimeclipboard] ignored sealed routing field "${field}"`, frame.t);
104
+ delete secret[field];
105
+ }
106
+
107
+ // Routing spread LAST as well, so the relay's stamp wins even if the strip
108
+ // above is ever loosened.
109
+ return { ...secret, ...routing };
110
+ } catch {
111
+ // Should be unreachable — drop rather than hand files a half-frame.
112
+ console.warn("[realtimeclipboard] undecryptable signalling frame", frame.t);
113
+ return null;
114
+ }
115
+ }
116
+
117
+ export const decryptFrame = (frame) => openWith(state.get().aesKey, frame);
@@ -31,7 +31,8 @@ export const EVENTS = {
31
31
  const STORE_KEY = "realtimeclipboard.history";
32
32
 
33
33
  let clips = [];
34
- let roomKey = null; // share key the current list belongs to; null until first KEY_CHANGED
34
+ let roomId = null; // ROOM the current list belongs to; null until first KEY_CHANGED
35
+ let sealed = false; // hydrated from storage, not yet confirmed to be this room
35
36
  let started = false;
36
37
  let seq = 0;
37
38
 
@@ -59,8 +60,8 @@ function removeStore() {
59
60
  * is a smaller failure than losing the user's clipboard.
60
61
  */
61
62
  function persist() {
62
- if (!clips.length && roomKey === null) return removeStore();
63
- writeStore({ key: roomKey, clips });
63
+ if (!clips.length && roomId === null) return removeStore();
64
+ writeStore({ room: roomId, clips });
64
65
  }
65
66
 
66
67
  const nextId = () => `h${Date.now().toString(36)}${(++seq).toString(36)}`;
@@ -73,16 +74,22 @@ function announce(reason) {
73
74
  emit(EVENTS.CHANGED, { clips: all(), reason });
74
75
  }
75
76
 
76
- /** Newest first. Returns a shallow copy — callers must not mutate the list. */
77
+ /**
78
+ * Newest first. Returns a shallow copy — callers must not mutate the list.
79
+ *
80
+ * Empty while `sealed`: clips restored from storage are not known to belong to
81
+ * the session being opened until the first KEY_CHANGED says so, and a locked
82
+ * link sitting on its PIN prompt has not opened one yet.
83
+ */
77
84
  export function all() {
78
- return clips.slice();
85
+ return sealed ? [] : clips.slice();
79
86
  }
80
87
 
81
88
  export function get(id) {
82
- return clips.find(c => c.id === id) ?? null;
89
+ return sealed ? null : clips.find(c => c.id === id) ?? null;
83
90
  }
84
91
 
85
- export const size = () => clips.length;
92
+ export const size = () => (sealed ? 0 : clips.length);
86
93
 
87
94
  /**
88
95
  * Record a clip. Returns the new entry, or null if it was ignored — empty text,
@@ -110,19 +117,27 @@ export function add({ text, direction }) {
110
117
  export function clear(reason = "clear") {
111
118
  const had = clips.length;
112
119
  clips = [];
120
+ sealed = false;
113
121
  removeStore();
114
- if (roomKey !== null) persist();
122
+ if (roomId !== null) persist();
115
123
  if (had) announce(reason);
116
124
  return had;
117
125
  }
118
126
 
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.
127
+ // The stored room is not known to be the current one yet — the first
128
+ // KEY_CHANGED decides whether to keep or discard this, and until it does the
129
+ // clips are `sealed`: in memory, out of every reader.
130
+ //
131
+ // Records written before history was room-scoped are keyed by share key, which
132
+ // cannot be mapped onto a room without the PIN. They are dropped rather than
133
+ // guessed at — the cost is one tab's history, once.
121
134
  function hydrate() {
122
135
  const saved = readStore();
123
136
  if (!saved || !Array.isArray(saved.clips)) return;
137
+ if (typeof saved.room !== "string" || !saved.room) return removeStore();
124
138
 
125
- roomKey = saved.key ?? null;
139
+ roomId = saved.room;
140
+ sealed = saved.clips.length > 0;
126
141
  clips = saved.clips
127
142
  .filter(c => c && typeof c.text === "string")
128
143
  .slice(0, MAX_CLIPS)
@@ -136,23 +151,35 @@ function hydrate() {
136
151
  }
137
152
 
138
153
  /**
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.
154
+ * A new ROOM is new peers and a new privacy context. Scoped to the room rather
155
+ * than the share key because they are not the same boundary: re-PINning a
156
+ * session keeps the key and moves rooms, and key-scoped history followed the
157
+ * user across that move — as did a wrong-PIN join, which lands in a different
158
+ * room under the very same key.
159
+ *
160
+ * The first KEY_CHANGED after boot is not a change, though — main.js emits one
161
+ * for the room we already had, and comparing against the room stored alongside
162
+ * the clips is what tells the two apart. That comparison is also what unseals
163
+ * hydrated clips: they are only this session's once it has identified itself.
143
164
  */
144
- function onKeyChanged({ key }) {
145
- const next = normKey(key);
165
+ function onKeyChanged({ key, roomHash }) {
166
+ // roomHash is the real identity; the key is the fallback for surfaces that
167
+ // set state before a room is derived, and for node tests with neither.
168
+ const next = roomHash || normKey(key);
146
169
  if (!next) return;
147
170
 
148
- if (roomKey !== null && normKey(roomKey) !== next) {
149
- roomKey = next;
171
+ if (roomId !== null && roomId !== next) {
172
+ roomId = next;
150
173
  clear("key-changed");
151
174
  persist();
152
175
  return;
153
176
  }
154
177
 
155
- roomKey = next;
178
+ roomId = next;
179
+ if (sealed) {
180
+ sealed = false;
181
+ if (clips.length) announce("hydrate");
182
+ }
156
183
  persist();
157
184
  }
158
185
 
@@ -167,5 +194,8 @@ export function init() {
167
194
  on(EV.TEXT_RECEIVED, ({ text }) => add({ text, direction: "received" }));
168
195
  on(EV.KEY_CHANGED, onKeyChanged);
169
196
 
170
- if (clips.length) announce("hydrate");
197
+ // Nothing is announced here on purpose: hydrated clips stay sealed until a
198
+ // KEY_CHANGED identifies the room they belong to. Announcing them at boot put
199
+ // the previous session's clips in the pane behind an unanswered PIN prompt.
200
+ if (!sealed && clips.length) announce("hydrate");
171
201
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * App translation, keyed by the English string itself.
3
+ *
4
+ * There is no key vocabulary to invent and keep in step: `t("Send")` looks up
5
+ * "Send" and falls back to "Send". A catalogue that is missing an entry — or a
6
+ * catalogue that never loaded — renders exactly what the source file already
7
+ * said, so partial coverage is a normal state rather than a broken one. That is
8
+ * the same property the translated pages have, and it is what lets this land a
9
+ * module at a time instead of all at once.
10
+ *
11
+ * The cost is that editing an English string orphans its translations. That is
12
+ * the right way round: the orphan renders English, which is correct but
13
+ * untranslated, whereas a stale key renders the old wording, which is wrong.
14
+ * `npm run i18n:app -- --check` reports orphans.
15
+ *
16
+ * English pays nothing. `pick()` returns "en" for an English reader and `load()`
17
+ * resolves without a fetch, so the only reader who waits on a catalogue is one
18
+ * who is getting a translation out of it.
19
+ */
20
+
21
+ const PLACEHOLDER = /\{(\w+)\}/g;
22
+
23
+ let catalogue = null;
24
+ let current = "en";
25
+
26
+ /** The language actually in force — "en" until a catalogue has loaded. */
27
+ export const lang = () => current;
28
+
29
+ /**
30
+ * Translate `en`, substituting `{name}` placeholders from `vars`.
31
+ *
32
+ * Placeholders rather than concatenation because word order moves: Chinese puts
33
+ * the count after the noun, and Russian inflects around it. A translator needs
34
+ * the whole sentence, not two halves and a variable.
35
+ */
36
+ export function t(en, vars) {
37
+ const s = (catalogue && catalogue[en]) || en;
38
+ return vars ? s.replace(PLACEHOLDER, (whole, k) => (k in vars ? vars[k] : whole)) : s;
39
+ }
40
+
41
+ /* Literal specifiers inside thunks. `import(variable)` is opaque to the
42
+ bundler, and passing a path string is what left two optional panels 404ing
43
+ in the deploy — see the trap in CLAUDE.md. */
44
+ const LOADERS = {
45
+ zh: () => import("./lang/zh.js"),
46
+ pt: () => import("./lang/pt.js"),
47
+ es: () => import("./lang/es.js"),
48
+ ru: () => import("./lang/ru.js"),
49
+ };
50
+
51
+ export const SUPPORTED = Object.keys(LOADERS);
52
+
53
+ /**
54
+ * The language to use, given a stored override and the browser's preferences.
55
+ *
56
+ * An explicit choice wins outright, including an explicit choice of English —
57
+ * a reader who picked English wants English, not their browser's guess.
58
+ */
59
+ export function pick(stored, preferred = []) {
60
+ if (stored === "en" || SUPPORTED.includes(stored)) return stored;
61
+ for (const tag of preferred) {
62
+ const code = String(tag).toLowerCase().split("-")[0];
63
+ if (SUPPORTED.includes(code)) return code;
64
+ }
65
+ return "en";
66
+ }
67
+
68
+ /**
69
+ * Load a catalogue. Resolves to the language actually in force.
70
+ *
71
+ * Never throws: a catalogue that fails to fetch leaves English in place, which
72
+ * is a fully working app. The session must not fail to open because a
73
+ * translation did not arrive.
74
+ */
75
+ export async function load(code) {
76
+ const get = LOADERS[code];
77
+ if (!get) return "en";
78
+ try {
79
+ const mod = await get();
80
+ catalogue = mod.default;
81
+ current = code;
82
+ } catch (err) {
83
+ console.warn("[realtimeclipboard] translation unavailable, staying in English:", err.message);
84
+ }
85
+ return current;
86
+ }
87
+
88
+ /** Test seam: install a catalogue without a fetch. */
89
+ export function useCatalogue(code, entries) {
90
+ catalogue = entries;
91
+ current = code;
92
+ }
package/src/core/keys.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Share-key generation and normalisation. */
2
2
 
3
- import { KEY, LOCK, SITE } from "./config.js";
3
+ import { KEY, LOCK, SITE, RELAY_URL, RELAY_IS_CUSTOM, safeSearch } from "./config.js";
4
4
  import { IS_WEB } from "./native.js";
5
5
  import * as state from "./state.js";
6
6
 
@@ -158,19 +158,38 @@ export function fromUrl() {
158
158
  */
159
159
  export function clearUrl() {
160
160
  if (typeof history === "undefined" || typeof location === "undefined") return;
161
- history.replaceState(null, "", location.pathname + location.search);
161
+ // The query is rewritten too, not just the fragment: `?org=` is a deployment
162
+ // credential and it was left in the address bar for the life of the session —
163
+ // in every screenshot, and readable by the ad tag, which reports the page URL
164
+ // itself rather than taking pageLocation(). It has been read into storage by
165
+ // the time this runs.
166
+ history.replaceState(null, "", location.pathname + safeSearch());
162
167
  }
163
168
 
164
169
  /**
165
170
  * A link for the OTHER device to open, which is why it is not always this one's
166
171
  * URL. Only a browser can hand out its own address — see SITE in config.js for
167
172
  * the hosts that cannot.
173
+ *
174
+ * A custom relay travels WITH the link. Dropping it was silent and total: the
175
+ * recipient resolved to the default relay, both ends reported "connected", and
176
+ * nothing ever arrived — with no error at either end to say why. `?relay=` is
177
+ * the mechanism docs/SELF-HOSTING.md tells operators to hand out, and this is
178
+ * the one place a link is built, so it is the one place that can carry it.
179
+ *
180
+ * On a self-hosted app the link is complete: that build's CSP names its own
181
+ * relay, so following it works. From a native host the base is the public app,
182
+ * whose CSP names only the public relay — the parameter cannot make that
183
+ * connection succeed, but it does turn a session that silently syncs nothing
184
+ * into a connection that visibly refuses, which is the difference between a bug
185
+ * report and a mystery.
168
186
  */
169
187
  export function shareLink(key, locked = false) {
170
188
  const here = IS_WEB && typeof location !== "undefined"
171
189
  ? location.origin + location.pathname
172
190
  : SITE.APP_URL;
173
- return `${here}#${fragment(key, locked)}`;
191
+ const relay = RELAY_IS_CUSTOM ? `?relay=${encodeURIComponent(RELAY_URL)}` : "";
192
+ return `${here}${relay}#${fragment(key, locked)}`;
174
193
  }
175
194
 
176
195
  /**
@@ -48,7 +48,7 @@ export const IS_CLI = SURFACE === "cli";
48
48
  * key length: an installed surface keeps its session across restarts and is worth
49
49
  * the longer key, a visited one is usually typing it to somebody.
50
50
  */
51
- export const IS_INSTALLED = IS_DESKTOP || SURFACE === "vscode";
51
+ export const IS_INSTALLED = IS_DESKTOP || SURFACE === "vscode" || SURFACE === "browser";
52
52
 
53
53
  let host = null;
54
54
 
package/src/core/state.js CHANGED
@@ -31,6 +31,12 @@ const state = {
31
31
  peerId: null, // assigned by the relay in `welcome.you`
32
32
  connection: "idle", // idle | connecting | connected | reconnecting | offline
33
33
  instance: null, // relay instance id — a change means split-brain (OI-3)
34
+ /**
35
+ * Frame types this relay understands, from `welcome.caps`. Empty on a relay
36
+ * older than the field, which is the point: a client that guesses gets
37
+ * UNKNOWN_TYPE back and cannot tell it from silence. See core/verify.js.
38
+ */
39
+ relayCaps: [],
34
40
  peers: 1,
35
41
  tier: "T1", // clipboard capture tier, see clipboard/capture.js
36
42
  lastSent: "", // dedupe guard (FR-2.7)
@@ -56,6 +62,10 @@ const state = {
56
62
  // system | light | dark. "system" is not a third colour scheme, it is the
57
63
  // absence of a choice — see config.js THEMES and ui/features/theme.js.
58
64
  theme: DEFAULT_THEME,
65
+ // "" is the absence of a choice, and means "follow the browser" — the same
66
+ // shape as theme's "system". An explicit "en" is a real choice and beats a
67
+ // browser that asks for Chinese. See core/i18n.js pick().
68
+ language: "",
59
69
  },
60
70
  };
61
71
 
@@ -141,21 +151,40 @@ function watchSettings() {
141
151
  });
142
152
  }
143
153
 
144
- export function setKey({ key, roomHash, aesKey, locked = false, authToken = null }) {
154
+ /**
155
+ * `roomHash` and `aesKey` keep their old value when the property is ABSENT, not
156
+ * when it is null. `??` could not tell those apart, so the one caller that says
157
+ * `aesKey: null` to wipe a session — VS Code's leave — kept the key it was
158
+ * clearing. Omission still means "leave it alone"; an explicit null now clears.
159
+ */
160
+ export function setKey(next) {
161
+ const { key, roomHash, aesKey, locked = false, authToken = null } = next;
145
162
  state.key = key;
146
- state.roomHash = roomHash ?? state.roomHash;
147
- state.aesKey = aesKey ?? state.aesKey;
163
+ state.roomHash = "roomHash" in next ? roomHash : state.roomHash;
164
+ state.aesKey = "aesKey" in next ? aesKey : state.aesKey;
148
165
  state.locked = locked;
149
166
  state.authToken = authToken;
150
167
  // A new key is a new room, so both of these are re-asked. Left standing, the
151
168
  // founder of one session would carry the right to lock into the next.
152
169
  state.verified = false;
153
170
  state.founder = null;
154
- emit(EV.KEY_CHANGED, { key, locked });
171
+ // roomHash rides along because the ROOM is the privacy boundary, not the
172
+ // share key: re-PINning keeps the key and changes the room, and a listener
173
+ // scoping anything to the key alone would carry it across that change.
174
+ emit(EV.KEY_CHANGED, { key, locked, roomHash: state.roomHash });
155
175
  emit(EV.LOCK_STATE, { locked, verified: false });
156
176
  emit(EV.FOUNDER, { founder: null });
157
177
  }
158
178
 
179
+ /**
180
+ * Wipe every credential this module holds. The teardown paths used to spell
181
+ * this out field by field and each one forgot a different field — the shape of
182
+ * "no session" belongs here, with the shape of a session.
183
+ */
184
+ export function clearKey() {
185
+ setKey({ key: "", roomHash: "", aesKey: null, locked: false, authToken: null });
186
+ }
187
+
159
188
  /**
160
189
  * Fed from `welcome.existing`. Re-answered on every welcome, so a relay restart
161
190
  * — which empties every room (OI-13) — hands the title to whoever reconnects
@@ -197,6 +226,10 @@ export function setVerified() {
197
226
  emit(EV.LOCK_STATE, { locked: true, verified: true });
198
227
  }
199
228
 
229
+ export function setRelayCaps(caps) {
230
+ state.relayCaps = Array.isArray(caps) ? caps : [];
231
+ }
232
+
200
233
  export function setConnection(connection, detail = "") {
201
234
  state.connection = connection;
202
235
  emit(EV.CONN_STATE, { state: connection, detail });
@@ -60,6 +60,20 @@ export function saveRelayUrl(url) {
60
60
  return clean;
61
61
  }
62
62
 
63
+ /**
64
+ * The organisation token for a relay that demands one. Per DEVICE, not per
65
+ * session: it admits this machine to a deployment, and it is deliberately kept
66
+ * out of share links — see ORG_TOKEN in config.js.
67
+ */
68
+ export const loadOrgToken = () => read("orgToken", null);
69
+
70
+ export function saveOrgToken(token) {
71
+ const clean = typeof token === "string" && token.trim() ? token.trim() : null;
72
+ if (!clean) { remove("orgToken"); return null; }
73
+ write("orgToken", clean);
74
+ return clean;
75
+ }
76
+
63
77
  /**
64
78
  * The last room, so a relaunch can offer it back (FR-1.7, OI-10). Stores
65
79
  * `{key, locked}` and still reads the bare string it used to be, because an
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Proving a locked session, without spending the room's replay slot.
3
+ *
4
+ * A wrong PIN is silent by design. The PIN is stretched into the room hash, so
5
+ * mistyping it does not get you turned away from the right room — it puts you
6
+ * in a different, empty one, which on screen is indistinguishable from being
7
+ * the first to arrive. Something in the room has to be readable before the app
8
+ * can tell those apart.
9
+ *
10
+ * That used to be a sentinel clip planted on arrival, and the cost was the
11
+ * room's single retained clip (FR-3.3): proving the PIN either overwrote the
12
+ * last thing the user copied, or — once the plant started honouring the sync
13
+ * rung — was skipped in a way that left correctly-joined devices being warned
14
+ * their PIN might be wrong. The two guards could not both hold, because the
15
+ * proof and the user's data were competing for one slot.
16
+ *
17
+ * So the proof is its own frame now. It is forwarded and not retained, which
18
+ * removes the competition rather than arbitrating it.
19
+ *
20
+ * Everything here is dependency-injected rather than imported, and not for
21
+ * purity: `build` is a frame shape (rank 10) and `send` is the transport (also
22
+ * rank 10), neither of which core/ may reach. It buys the thing that matters
23
+ * anyway — every rule below is reachable from a test with four fakes, instead
24
+ * of being four lines in the composition root that only a source-reading check
25
+ * can see.
26
+ */
27
+
28
+ import { LOCK, sharesSession } from "./config.js";
29
+ import * as state from "./state.js";
30
+
31
+ /**
32
+ * @param build proto.verify — the frame shape
33
+ * @param seal frames.encryptFrame
34
+ * @param send relay.send
35
+ * @param gen () => the session generation, so a seal that outlived its room is dropped
36
+ */
37
+ export function createVerifier({ build, seal, send, gen, now = () => Date.now() }) {
38
+ /**
39
+ * "Never asked", which is not the same as "asked at time zero". Spelled 0, it
40
+ * is a real timestamp that happens to be in 1970, and the first probe of a
41
+ * session is refused for being too soon after it. Invisible against
42
+ * Date.now(), and a dead session under any injected clock.
43
+ */
44
+ let lastAsk = -Infinity;
45
+
46
+ /**
47
+ * Bumped by cancel(). `gen` covers the same ground in the app — leaveRoom()
48
+ * moves it — but this module must be able to abandon in-flight work on its
49
+ * own, or "cancel" would mean "ask the caller nicely".
50
+ */
51
+ let epoch = 0;
52
+
53
+ /**
54
+ * The relay has to know the frame type. An older one answers UNKNOWN_TYPE,
55
+ * which arrives looking exactly like a probe nobody chose to answer — and the
56
+ * difference between those two is "your PIN is wrong" and "this relay needs
57
+ * redeploying". Asked rather than guessed, and there is deliberately no
58
+ * fallback to the old retained beacon: silently spending the replay slot
59
+ * again is the behaviour being removed, not a degraded mode to keep.
60
+ */
61
+ const relaySupports = () => (state.get().relayCaps ?? []).includes("verify");
62
+
63
+ /**
64
+ * Off means nothing leaves. Both directions: a device on Off does not ask,
65
+ * and does not answer either. Answering is a frame put on the wire for
66
+ * somebody else's benefit, which is exactly what an Off device has said it
67
+ * will not do — and a rung that leaks for control traffic is a rung that does
68
+ * not mean what the UI says.
69
+ */
70
+ function eligible() {
71
+ const s = state.get();
72
+ return !!s.aesKey
73
+ && s.locked
74
+ && sharesSession(s.settings.syncMode)
75
+ && relaySupports();
76
+ }
77
+
78
+ /**
79
+ * Checked before sealing and again immediately before sending, because the
80
+ * seal is an await: a room change, a rung change or a cancel can all land
81
+ * inside it, and a frame sealed for the room we left is either undecryptable
82
+ * noise in the room we arrived at or an Off device transmitting after the
83
+ * fact.
84
+ */
85
+ async function transmit(probe) {
86
+ if (!eligible()) return false;
87
+ const g = gen(), e = epoch;
88
+ const frame = await seal(build({ probe, originId: state.get().originId }));
89
+ if (g !== gen() || e !== epoch) return false;
90
+ if (!eligible()) return false;
91
+ send(frame);
92
+ return true;
93
+ }
94
+
95
+ return {
96
+ /**
97
+ * Ask the room to prove itself. Only worth doing unverified and in company:
98
+ * a probe into an empty room is answered by nobody, and once anything has
99
+ * decrypted the question is settled for the life of the session.
100
+ */
101
+ async request() {
102
+ const s = state.get();
103
+ if (s.verified || s.peers <= 1) return false;
104
+ // Before the interval stamp, so an ineligible attempt does not consume
105
+ // the window. It is the Off device that most needs the next call to get
106
+ // through — enabling sharing is a trigger, and swallowing it would strand
107
+ // the device that just asked to be included.
108
+ if (!eligible()) return false;
109
+ if (now() - lastAsk < LOCK.VERIFY_MIN_INTERVAL_MS) return false;
110
+ lastAsk = now();
111
+ return transmit(true);
112
+ },
113
+
114
+ /**
115
+ * Answer one, if it is a question. `probe !== true` is the whole loop guard
116
+ * and it is one-directional on purpose: an answer can never produce another
117
+ * frame, so two unverified devices converge in one exchange instead of
118
+ * trading proof until the rate limiter stops them.
119
+ *
120
+ * Answered even when already verified — this is a service to the asker, not
121
+ * to us — and answered by an unverified device too, which by then has
122
+ * decrypted the probe and so is about to be verified itself.
123
+ */
124
+ async answer(frame) {
125
+ if (!frame || frame.probe !== true) return false;
126
+ if (frame.originId && frame.originId === state.get().originId) return false;
127
+ return transmit(false);
128
+ },
129
+
130
+ /**
131
+ * Proof nobody asked for, sent when this device becomes able to give it.
132
+ *
133
+ * Without it the rung is a one-way trapdoor. A device that was on Off while
134
+ * a peer probed did not answer — correctly — and the peer has no reason to
135
+ * ask again: its trigger was the arrival, and nobody new is going to
136
+ * arrive. So coming off Off has to push, not wait to be pulled.
137
+ *
138
+ * Safe to fire unconditionally because an answer is not a probe: it ends
139
+ * there, however many devices send one at once.
140
+ */
141
+ async announce() {
142
+ if (state.get().peers <= 1) return false;
143
+ return transmit(false);
144
+ },
145
+
146
+ /** Abandon anything in flight. Called from the session teardown. */
147
+ cancel() {
148
+ epoch++;
149
+ lastAsk = -Infinity; // a new room may ask at once
150
+ },
151
+ };
152
+ }