replica-bridge 0.3.19 → 0.3.22
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/package.json +1 -1
- package/src/bridge/board.js +12 -5
- package/src/bridge/main.js +126 -5
- package/src/bridge/pair.js +2 -0
- package/src/bridge/ring.js +427 -0
- package/src/bridge/service.js +402 -25
- package/src/bridge/state.js +53 -1
- package/src/http.js +5 -2
- package/src/protocol.js +704 -3
- package/src/relay/config.js +8 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// The device ring on the computer's side (PROTOCOL.md "设备环:电脑一侧"). A bridge is a member, never a signer: it
|
|
2
|
+
// only keeps the log, checks every entry with RingState, and lets the ring's active signers connect once a person on
|
|
3
|
+
// this computer has confirmed which ring it belongs to (anchoring). Everything is kept in state.json.
|
|
4
|
+
import { RelayHTTPError } from "../http.js";
|
|
5
|
+
import { RING_ENTRIES_PER_FRAME, RingError, RingState, b64url, fromB64url, ringB64, ringFingerprint } from "../protocol.js";
|
|
6
|
+
|
|
7
|
+
import { loadState, updateState } from "./state.js";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/** Longest log the bridge keeps; far more than a person's devices ever write. */
|
|
11
|
+
export const RING_LOG_MAX_ENTRIES = 10_000;
|
|
12
|
+
/** Most bytes (base64url of entries and signatures) the anchored log may take in state.json: some 6,000 typical entries. */
|
|
13
|
+
export const RING_LOG_MAX_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
/** Most bytes a pending anchor's log may take; it lands in state.json before anyone has confirmed anything. */
|
|
15
|
+
export const PENDING_LOG_MAX_BYTES = 1024 * 1024;
|
|
16
|
+
/** Different rings waiting for confirmation at once; a newer request for one of them replaces its log. */
|
|
17
|
+
export const PENDING_ANCHORS_MAX = 4;
|
|
18
|
+
/** Largest relay answer the bridge reads for one page of the log. */
|
|
19
|
+
export const RELAY_PAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
/** base64url lengths: the longest an entry can be (RING_ENTRY_MAX_BYTES), and a 64-byte signature. */
|
|
22
|
+
const ENTRY_B64_MAX = Math.ceil((16 * 1024 * 4) / 3);
|
|
23
|
+
const SIGNATURE_B64 = 86;
|
|
24
|
+
|
|
25
|
+
export const logBytes = (entries ) => entries.reduce((sum, entry) => sum + entry.e.length + entry.s.length, 0);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Entries being added to a log, checked one at a time as they arrive: the first that does not apply ends it, and so
|
|
29
|
+
* does reaching `until` or the size limits. Nothing is kept unverified, whatever a peer or the relay sends.
|
|
30
|
+
*/
|
|
31
|
+
export class LogGrowth {
|
|
32
|
+
added = [];
|
|
33
|
+
/** Why it stopped short of `until`: an entry that did not apply, or the size limit (`full`). */
|
|
34
|
+
stopped = null;
|
|
35
|
+
full = false;
|
|
36
|
+
bytes ;
|
|
37
|
+
count ;
|
|
38
|
+
state ;
|
|
39
|
+
until ;
|
|
40
|
+
maxBytes ;
|
|
41
|
+
|
|
42
|
+
constructor(state , base , until , maxBytes = RING_LOG_MAX_BYTES) {
|
|
43
|
+
this.state = state; this.until = until; this.maxBytes = maxBytes;
|
|
44
|
+
this.bytes = logBytes(base); this.count = base.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get done() { return this.state.seq >= this.until || this.stopped !== null; }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Applies a page; false once nothing more should be asked for. Only an entry that verifies can make the log full:
|
|
51
|
+
* an oversized one that does not would otherwise let anyone stop the ring with one bogus entry.
|
|
52
|
+
*/
|
|
53
|
+
take(page , from ) {
|
|
54
|
+
for (const entry of page) {
|
|
55
|
+
if (this.done) return false;
|
|
56
|
+
const size = entry.e.length + entry.s.length;
|
|
57
|
+
const bad = (why ) => { this.stopped = `${from} sent an entry for seq ${this.state.seq + 1} that does not apply (${why})`; return false; };
|
|
58
|
+
// Longer than any entry can be: refused before any work (RingState would say too_large or bad_encoding).
|
|
59
|
+
if (entry.e.length > ENTRY_B64_MAX || entry.s.length !== SIGNATURE_B64) return bad("too_large: longer than any entry");
|
|
60
|
+
const seq = this.state.seq;
|
|
61
|
+
try { this.state.apply(entry); } catch (error) { return bad((error ).message); }
|
|
62
|
+
if (this.count + 1 > RING_LOG_MAX_ENTRIES || this.bytes + size > this.maxBytes) {
|
|
63
|
+
// Verified but past the limit: not kept (storeLog replays only `added`), and the log counts as full.
|
|
64
|
+
this.full = true;
|
|
65
|
+
this.stopped = `the log reached this bridge's limit at seq ${seq}`;
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
this.added.push(entry);
|
|
69
|
+
this.bytes += size;
|
|
70
|
+
this.count++;
|
|
71
|
+
}
|
|
72
|
+
return !this.done;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Replays a log as far as it is valid: the state of that prefix, and why it stopped (null when it did not). */
|
|
77
|
+
export function replayPrefix(entries , from ) {
|
|
78
|
+
const state = from?.state ?? new RingState();
|
|
79
|
+
let applied = 0;
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
try { state.apply(entry); } catch (error) { return { state, applied, error: error }; }
|
|
82
|
+
applied++;
|
|
83
|
+
}
|
|
84
|
+
return { state, applied, error: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A copy of a state by replaying its log again; RingState has no clone and the logs are small. */
|
|
88
|
+
export function replayAll(entries ) {
|
|
89
|
+
const { state, error } = replayPrefix(entries);
|
|
90
|
+
if (error) throw error;
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The anchored ring's state, replayed from state.json whenever the stored log differs from what was last replayed.
|
|
96
|
+
* A stored log that stops replaying (edited by hand) counts up to its last valid entry; one whose genesis is not the
|
|
97
|
+
* anchored ring counts as no ring at all, so it grants nothing.
|
|
98
|
+
*/
|
|
99
|
+
export class RingCache {
|
|
100
|
+
key = "";
|
|
101
|
+
state = null;
|
|
102
|
+
|
|
103
|
+
get(ring , log = () => {}) {
|
|
104
|
+
if (!ring) { this.key = ""; this.state = null; return null; }
|
|
105
|
+
const key = `${ring.id}|${ring.entries.length}|${ring.entries.at(-1)?.e ?? ""}`;
|
|
106
|
+
if (key === this.key) return this.state;
|
|
107
|
+
const { state, error } = replayPrefix(ring.entries);
|
|
108
|
+
if (error) log(`device ring: the stored log stops at seq ${state.seq}: ${error.message}`);
|
|
109
|
+
this.key = key;
|
|
110
|
+
this.state = state.ringId && b64url(state.ringId) === ring.id ? state : null;
|
|
111
|
+
if (!this.state) log("device ring: the stored log does not start with the anchored ring's genesis; the ring grants nothing");
|
|
112
|
+
return this.state;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Who may connect: the devices paired here by QR code, minus any key revoked in the anchored ring, plus the anchored
|
|
125
|
+
* ring's active signers. Members never connect to other members. A computer revoked from its own ring takes nobody in
|
|
126
|
+
* through the ring, though its paired devices still connect.
|
|
127
|
+
*/
|
|
128
|
+
export function admit(state , ring , hostKey , deviceId , offeredKey ) {
|
|
129
|
+
const paired = state.devices.find((d) => d.deviceId === deviceId);
|
|
130
|
+
// Removed here: a local no that the ring cannot overrule.
|
|
131
|
+
const denied = (key ) => state.denied?.some((d) => d.key === b64url(key));
|
|
132
|
+
if (offeredKey && !paired && denied(offeredKey)) return { refused: "removed from this computer" };
|
|
133
|
+
if (paired) {
|
|
134
|
+
const key = fromB64url(paired.publicKey);
|
|
135
|
+
if (denied(key)) return { refused: "removed from this computer" };
|
|
136
|
+
if (ring?.isRevokedKey("x25519", key)) return { refused: "removed from the device ring" };
|
|
137
|
+
return { via: "qr", key, name: paired.name };
|
|
138
|
+
}
|
|
139
|
+
if (!ring || !offeredKey) return { refused: "not paired with this computer" };
|
|
140
|
+
if (!ring.activeMemberWithKey("x25519", hostKey)) return { refused: "this computer is no longer in its device ring" };
|
|
141
|
+
// A log that stopped growing at the size limit may be missing revocations: the ring lets nobody in until a reset.
|
|
142
|
+
if (state.ring?.stalled) return { refused: "this computer's copy of its device ring is full" };
|
|
143
|
+
const signer = ring.signers.find((m) => m.key.equals(offeredKey));
|
|
144
|
+
if (signer) return { via: "ring", key: offeredKey, name: signer.name };
|
|
145
|
+
if (ring.activeMemberWithKey("x25519", offeredKey)) return { refused: "only the ring's signers connect to its members" };
|
|
146
|
+
return { refused: "not paired with this computer and not a signer of its device ring" };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The three conditions for anchoring (docs/design/DEVICE-RING.md): the requester is paired here by QR code, it is an
|
|
151
|
+
* active signer of the ring with the transport key of this very connection and the signing key it named, and this
|
|
152
|
+
* computer's own key is an active member. The log must replay from genesis to the head the requester claimed.
|
|
153
|
+
*/
|
|
154
|
+
export function checkAnchor(state , hostKey , request ) {
|
|
155
|
+
const paired = state.devices.find((d) => d.deviceId === request.deviceId);
|
|
156
|
+
if (!paired || !fromB64url(paired.publicKey).equals(request.key)) return { refused: "only a device paired here by QR code can ask" };
|
|
157
|
+
if (request.entries.length > RING_LOG_MAX_ENTRIES || logBytes(request.entries) > PENDING_LOG_MAX_BYTES) return { refused: "the log is too long" };
|
|
158
|
+
const { state: ring, error } = replayPrefix(request.entries);
|
|
159
|
+
if (error) return { refused: `the log does not verify: ${error.message}` };
|
|
160
|
+
if (!ring.ringId || b64url(ring.ringId) !== request.ring) return { refused: "the log is not this ring's" };
|
|
161
|
+
const signer = ring.signers.find((m) => m.key.equals(request.key) && m.signingKey .equals(request.signingKey));
|
|
162
|
+
if (!signer) return { refused: "the device is not an active signer of the ring with this key" };
|
|
163
|
+
if (ring.isRevokedKey("x25519", hostKey) || !ring.activeMemberWithKey("x25519", hostKey)) return { refused: "this computer is not an active member of the ring" };
|
|
164
|
+
return { ring, signer };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** What a person on this computer is asked to confirm. */
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
export function anchorPrompt(pending ) {
|
|
179
|
+
const ring = replayAll(pending.entries);
|
|
180
|
+
const key = fromB64url(pending.requester.key);
|
|
181
|
+
const inRing = ring.signers.find((m) => m.key.equals(key))?.name ?? pending.requester.name;
|
|
182
|
+
return {
|
|
183
|
+
ring: pending.ring, fingerprint: ringFingerprint(fromB64url(pending.ring)),
|
|
184
|
+
// Always both: the name it was paired under here is the one a stranger's ring cannot choose; the six digits
|
|
185
|
+
// alone can be matched by trying about a million genesis nonces.
|
|
186
|
+
requester: `${inRing} (paired here as "${pending.requester.name}")`,
|
|
187
|
+
signers: ring.signers.map((m) => m.name), members: ring.active.length, requestedAt: pending.requestedAt,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The question, as the terminal and `ring accept` print it. */
|
|
192
|
+
export function describeAnchor(prompt ) {
|
|
193
|
+
return [
|
|
194
|
+
"A device asks to anchor this computer to its device ring.",
|
|
195
|
+
` Asked by: ${prompt.requester}`,
|
|
196
|
+
` Signers: ${prompt.signers.join(", ")} (${prompt.members} devices in the ring)`,
|
|
197
|
+
` Fingerprint: ${prompt.fingerprint} (the app shows the same six digits)`,
|
|
198
|
+
"Once confirmed, every signer of this ring can use this computer, and the ring can only change with",
|
|
199
|
+
'"npx replica-bridge ring reset". Confirm only if this is your ring and the digits match.',
|
|
200
|
+
].join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Records (or refreshes) a pending anchor once checkAnchor passed. Nothing else changes until a person confirms.
|
|
205
|
+
* Returns false when this computer is already anchored or too many other rings are waiting.
|
|
206
|
+
*/
|
|
207
|
+
export function recordPending(request ) {
|
|
208
|
+
let recorded = false;
|
|
209
|
+
updateState((state) => {
|
|
210
|
+
if (state.ring) return;
|
|
211
|
+
const pending = state.ringPending ?? [];
|
|
212
|
+
const index = pending.findIndex((p) => p.ring === request.ring);
|
|
213
|
+
if (index >= 0) {
|
|
214
|
+
pending[index] = { ...request, requestedAt: pending[index].requestedAt };
|
|
215
|
+
} else {
|
|
216
|
+
if (pending.length >= PENDING_ANCHORS_MAX) return;
|
|
217
|
+
pending.push(request);
|
|
218
|
+
}
|
|
219
|
+
state.ringPending = pending;
|
|
220
|
+
recorded = true;
|
|
221
|
+
});
|
|
222
|
+
return recorded;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A pending anchor by its number in the list (1-based), its fingerprint, or the start of its ring id. */
|
|
226
|
+
export function findPending(state , which ) {
|
|
227
|
+
const pending = state.ringPending ?? [];
|
|
228
|
+
if (!pending.length) throw new Error("no device ring is waiting for confirmation");
|
|
229
|
+
const byNumber = /^\d{1,2}$/.test(which) ? pending[Number(which) - 1] : undefined;
|
|
230
|
+
const found = byNumber ?? pending.filter((p) => ringFingerprint(fromB64url(p.ring)) === which || (which.length >= 6 && p.ring.startsWith(which)));
|
|
231
|
+
if (!Array.isArray(found)) return found;
|
|
232
|
+
if (found.length !== 1) throw new Error(found.length ? `"${which}" matches more than one pending ring` : `no pending ring matches "${which}"`);
|
|
233
|
+
return found[0];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Removes a paired device on this computer and remembers its key as refused, so a ring that still lists it as a
|
|
238
|
+
* signer cannot bring it back; pairing it here by QR code again lifts that. Returns its name, or null when unknown.
|
|
239
|
+
*/
|
|
240
|
+
export function removeDevice(state , deviceId ) {
|
|
241
|
+
// Paired here by QR code, or only ever let in through the ring: either way its key is on file.
|
|
242
|
+
const device = state.devices.find((d) => d.deviceId === deviceId) ?? state.ringDevices?.find((d) => d.deviceId === deviceId);
|
|
243
|
+
state.devices = state.devices.filter((d) => d.deviceId !== deviceId);
|
|
244
|
+
if (state.ringDevices) state.ringDevices = state.ringDevices.filter((d) => d.deviceId !== deviceId);
|
|
245
|
+
if (!device) return null;
|
|
246
|
+
const publicKey = "publicKey" in device ? device.publicKey : device.key;
|
|
247
|
+
state.denied = [...(state.denied ?? []).filter((d) => d.key !== publicKey), { key: publicKey, name: device.name, at: new Date().toISOString() }];
|
|
248
|
+
return device.name;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Most ring-only devices remembered for `revoke`; a person has far fewer. */
|
|
252
|
+
export const RING_DEVICES_MAX = 64;
|
|
253
|
+
|
|
254
|
+
/** Remembers a device let in through the ring under its relay device id (for `revoke`); false when nothing changed. */
|
|
255
|
+
export function noteRingDevice(state , deviceId , key , name ) {
|
|
256
|
+
const known = state.ringDevices?.find((d) => d.deviceId === deviceId);
|
|
257
|
+
if (known && known.key === b64url(key) && known.name === name) return false;
|
|
258
|
+
const others = (state.ringDevices ?? []).filter((d) => d.deviceId !== deviceId);
|
|
259
|
+
state.ringDevices = [...others, { deviceId, key: b64url(key), name, seenAt: new Date().toISOString() }].slice(-RING_DEVICES_MAX);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
/** Drops paired devices whose key was revoked in the ring, for good: a reset does not bring them back. */
|
|
265
|
+
export function pruneRevoked(state , ring ) {
|
|
266
|
+
const removed = state.devices.filter((d) => ring.isRevokedKey("x25519", fromB64url(d.publicKey)));
|
|
267
|
+
state.devices = state.devices.filter((d) => !removed.includes(d));
|
|
268
|
+
return removed.map((d) => d.name);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Confirms a pending anchor: checks the three conditions again against the paired devices as they are now, then
|
|
273
|
+
* anchors for good and forgets every other pending ring.
|
|
274
|
+
*/
|
|
275
|
+
export function acceptAnchor(which , hostKey ) {
|
|
276
|
+
let result = null;
|
|
277
|
+
updateState((state) => {
|
|
278
|
+
if (state.ring) throw new Error(`this computer is already anchored to ring ${state.ring.id}; "replica-bridge ring reset" first`);
|
|
279
|
+
const pending = findPending(state, which);
|
|
280
|
+
const checked = checkAnchor(state, hostKey, {
|
|
281
|
+
deviceId: pending.requester.deviceId, key: fromB64url(pending.requester.key), signingKey: fromB64url(pending.requester.signingKey), ring: pending.ring, entries: pending.entries,
|
|
282
|
+
});
|
|
283
|
+
if ("refused" in checked) throw new Error(`cannot anchor to this ring any more: ${checked.refused}`);
|
|
284
|
+
const ring = { id: pending.ring, entries: pending.entries, anchoredAt: new Date().toISOString(), anchoredBy: { deviceId: pending.requester.deviceId, name: pending.requester.name } };
|
|
285
|
+
state.ring = ring;
|
|
286
|
+
delete state.ringPending;
|
|
287
|
+
result = { ring, removed: pruneRevoked(state, checked.ring) };
|
|
288
|
+
});
|
|
289
|
+
return result ;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function rejectAnchor(which ) {
|
|
293
|
+
let rejected = null;
|
|
294
|
+
updateState((state) => {
|
|
295
|
+
rejected = findPending(state, which);
|
|
296
|
+
state.ringPending = state.ringPending .filter((p) => p !== rejected);
|
|
297
|
+
if (!state.ringPending.length) delete state.ringPending;
|
|
298
|
+
});
|
|
299
|
+
return rejected ;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Leaves the ring: its signers can no longer connect unless paired here. Devices it revoked stay removed. */
|
|
303
|
+
export function resetRing() {
|
|
304
|
+
let was ;
|
|
305
|
+
updateState((state) => {
|
|
306
|
+
was = state.ring;
|
|
307
|
+
delete state.ring;
|
|
308
|
+
delete state.ringPending;
|
|
309
|
+
});
|
|
310
|
+
return was;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Adds verified entries to the anchored log. `added` extends `base` (the log as it was when the caller started
|
|
315
|
+
* fetching); when the stored log grew meanwhile, only the entries past it are applied again on top. All in one
|
|
316
|
+
* synchronous step, so this process never interleaves two writers and the stored log only ever grows.
|
|
317
|
+
*/
|
|
318
|
+
export function storeLog(ringId , base , added , stalled ) {
|
|
319
|
+
let result = null;
|
|
320
|
+
updateState((state) => {
|
|
321
|
+
if (state.ring?.id !== ringId) return;
|
|
322
|
+
const stored = state.ring.entries;
|
|
323
|
+
if (stored.length < base.length || stored[base.length - 1]?.e !== base.at(-1)?.e) return;
|
|
324
|
+
const growth = new LogGrowth(replayAll(stored), stored, Number.MAX_SAFE_INTEGER);
|
|
325
|
+
growth.take(added.slice(stored.length - base.length), "a peer");
|
|
326
|
+
if (stalled && !state.ring.stalled) state.ring.stalled = stalled;
|
|
327
|
+
if (!growth.added.length && !stalled) return;
|
|
328
|
+
state.ring.entries = [...stored, ...growth.added];
|
|
329
|
+
result = { seq: growth.state.seq, removed: pruneRevoked(state, growth.state) };
|
|
330
|
+
});
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The anchored ring's summary, for `replica-bridge ring` and status. */
|
|
335
|
+
export function ringSummary(state = loadState()) {
|
|
336
|
+
const lines = [];
|
|
337
|
+
if (state.ring) {
|
|
338
|
+
const { state: ring } = replayPrefix(state.ring.entries);
|
|
339
|
+
lines.push(`Device ring ${state.ring.id} (fingerprint ${ringFingerprint(fromB64url(state.ring.id))}), anchored ${state.ring.anchoredAt} at the request of ${state.ring.anchoredBy.name}`);
|
|
340
|
+
if (state.ring.stalled) lines.push(` Stopped growing (${state.ring.stalled}): the ring lets nobody in until "replica-bridge ring reset".`);
|
|
341
|
+
lines.push(` ${ring.seq + 1} entries; signers: ${ring.signers.map((m) => m.name).join(", ") || "none"}; members: ${ring.active.filter((m) => m.role === "member").map((m) => m.name).join(", ") || "none"}`);
|
|
342
|
+
} else lines.push("Not anchored to a device ring.");
|
|
343
|
+
for (const d of state.ringDevices ?? []) lines.push(` through the ring: ${d.name} (${d.deviceId}), last ${d.seenAt}; "replica-bridge revoke ${d.deviceId}" refuses it here`);
|
|
344
|
+
(state.ringPending ?? []).forEach((pending, i) => {
|
|
345
|
+
const prompt = anchorPrompt(pending);
|
|
346
|
+
lines.push(` waiting ${i + 1}: ring ${prompt.ring.slice(0, 12)}…, fingerprint ${prompt.fingerprint}, asked by ${prompt.requester} at ${prompt.requestedAt}`);
|
|
347
|
+
});
|
|
348
|
+
return lines;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Checks the shape of a ring frame's entries, so nothing below sees anything but {e, s} strings. */
|
|
352
|
+
export function wireEntries(value ) {
|
|
353
|
+
if (!Array.isArray(value) || value.length > RING_ENTRIES_PER_FRAME) return null;
|
|
354
|
+
for (const item of value) if (typeof item?.e !== "string" || typeof item?.s !== "string") return null;
|
|
355
|
+
return value.map((item) => ({ e: item.e, s: item.s }));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const withTimeout = (promise , ms ) => Promise.race([
|
|
359
|
+
promise, new Promise ((_, reject) => setTimeout(() => reject(new Error(`no answer in ${ms / 1000} s`)), ms).unref()),
|
|
360
|
+
]);
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The relay's copy of the log (docs/design/DEVICE-RING.md "中转服务器:存储和推送"), used only after it says
|
|
364
|
+
* `ring-v1` at /v1/ring/capabilities; an older relay answers 404 and the bridge stays with what the peers gave it.
|
|
365
|
+
* The relay cannot forge entries, so whatever it returns is checked like a peer's.
|
|
366
|
+
*/
|
|
367
|
+
export class RelayRingStore {
|
|
368
|
+
known = null;
|
|
369
|
+
relay ;
|
|
370
|
+
constructor(relay ) { this.relay = relay; }
|
|
371
|
+
|
|
372
|
+
/** Asks again next time: the relay socket reopened, perhaps onto a newer (or older) relay. */
|
|
373
|
+
forget() { this.known = null; }
|
|
374
|
+
|
|
375
|
+
async supported() {
|
|
376
|
+
if (this.known && Date.now() - this.known.at < 5 * 60_000) return this.known.supported;
|
|
377
|
+
const { relay } = this.relay();
|
|
378
|
+
let supported = false;
|
|
379
|
+
try {
|
|
380
|
+
const answer = await withTimeout(readJSON(new URL("v1/ring/capabilities", relay.endsWith("/") ? relay : `${relay}/`)), 10_000) ;
|
|
381
|
+
supported = answer.ring === "ring-v1";
|
|
382
|
+
} catch (error) {
|
|
383
|
+
// A relay that is down says nothing about what it supports; ask again next time.
|
|
384
|
+
if (!(error instanceof RelayHTTPError)) return false;
|
|
385
|
+
}
|
|
386
|
+
this.known = { supported, at: Date.now() };
|
|
387
|
+
return supported;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Pages of the log after the growth's head into `growth`, each checked as it arrives, until it is done. */
|
|
391
|
+
async fetchInto(ringId , growth ) {
|
|
392
|
+
if (!(await this.supported())) throw new Error("the relay has no ring endpoints");
|
|
393
|
+
const { relay, hostToken } = this.relay();
|
|
394
|
+
while (!growth.done) {
|
|
395
|
+
const after = growth.state.seq;
|
|
396
|
+
const url = new URL(`v1/ring/${encodeURIComponent(ringId)}${after >= 0 ? `?after=${after}` : ""}`, relay.endsWith("/") ? relay : `${relay}/`);
|
|
397
|
+
const page = await withTimeout(readJSON(url, hostToken), 15_000) ;
|
|
398
|
+
if (page.ring !== undefined && page.ring !== ringId) throw new Error("the relay answered for another ring");
|
|
399
|
+
const entries = Array.isArray(page.entries) ? page.entries.filter((e) => typeof e?.e === "string" && typeof e?.s === "string").map((e) => ({ e: e.e , s: e.s })) : [];
|
|
400
|
+
if (!growth.take(entries, "the relay") || !page.more || !entries.length) return;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** A GET (with the computer's token when given) whose answer may be at most RELAY_PAGE_MAX_BYTES: a relay cannot run the bridge out of memory. */
|
|
406
|
+
async function readJSON(url , token ) {
|
|
407
|
+
const response = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} });
|
|
408
|
+
const reader = response.body?.getReader();
|
|
409
|
+
const chunks = [];
|
|
410
|
+
let size = 0;
|
|
411
|
+
for (;;) {
|
|
412
|
+
const { done, value } = reader ? await reader.read() : { done: true, value: undefined };
|
|
413
|
+
if (done) break;
|
|
414
|
+
size += value.length;
|
|
415
|
+
if (size > RELAY_PAGE_MAX_BYTES) { await reader .cancel(); throw new Error("the relay's answer is too large"); }
|
|
416
|
+
chunks.push(value);
|
|
417
|
+
}
|
|
418
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
419
|
+
if (!response.ok) throw new RelayHTTPError(response.status, String(response.status), text.slice(0, 200));
|
|
420
|
+
return JSON.parse(text);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** True when a string is canonical base64url of 32 bytes (a ring id, an entry id, a key). */
|
|
424
|
+
export function isKey32(text ) {
|
|
425
|
+
if (typeof text !== "string") return false;
|
|
426
|
+
try { return ringB64(text).length === 32; } catch (error) { if (error instanceof RingError) return false; throw error; }
|
|
427
|
+
}
|