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.
- package/README.md +10 -10
- package/cli/CLAUDE.md +7 -0
- package/cli/realtimeclipboard.mjs +44 -9
- package/cli/session.mjs +108 -4
- package/package.json +5 -2
- package/src/core/CLAUDE.md +4 -0
- package/src/core/bus.js +2 -2
- package/src/core/config.js +116 -6
- package/src/core/frames.js +117 -0
- package/src/core/history.js +51 -21
- package/src/core/i18n.js +92 -0
- package/src/core/keys.js +22 -3
- package/src/core/native.js +1 -1
- package/src/core/state.js +37 -4
- package/src/core/storage.js +14 -0
- package/src/core/verify.js +152 -0
- package/src/transport/protocol.js +30 -0
- package/src/transport/relay.js +58 -8
- package/src/transport/sse.js +10 -4
- package/src/transport/ws.js +28 -5
|
@@ -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);
|
package/src/core/history.js
CHANGED
|
@@ -31,7 +31,8 @@ export const EVENTS = {
|
|
|
31
31
|
const STORE_KEY = "realtimeclipboard.history";
|
|
32
32
|
|
|
33
33
|
let clips = [];
|
|
34
|
-
let
|
|
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 &&
|
|
63
|
-
writeStore({
|
|
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
|
-
/**
|
|
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 (
|
|
122
|
+
if (roomId !== null) persist();
|
|
115
123
|
if (had) announce(reason);
|
|
116
124
|
return had;
|
|
117
125
|
}
|
|
118
126
|
|
|
119
|
-
// The stored
|
|
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
|
-
|
|
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
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
-
|
|
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 (
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/i18n.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
191
|
+
const relay = RELAY_IS_CUSTOM ? `?relay=${encodeURIComponent(RELAY_URL)}` : "";
|
|
192
|
+
return `${here}${relay}#${fragment(key, locked)}`;
|
|
174
193
|
}
|
|
175
194
|
|
|
176
195
|
/**
|
package/src/core/native.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
147
|
-
state.aesKey = 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
|
-
|
|
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 });
|
package/src/core/storage.js
CHANGED
|
@@ -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
|
+
}
|