replica-bridge 0.3.20 → 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.
@@ -5,19 +5,28 @@
5
5
  // the app-server says, so a phone that drops off the network resumes where it left off.
6
6
 
7
7
  import crypto from "node:crypto";
8
+ import fs from "node:fs";
8
9
  import os from "node:os";
9
10
  import { Duplex } from "node:stream";
10
11
  import { WebSocket } from "ws";
11
12
 
12
13
  import {
13
- CloseCode, FrameCipher, ChunkAssembler, MAX_FRAME_BYTES, REASSEMBLED_MAX_BYTES, RELAY_REPLY_LIMIT, StreamBuffer, encodeLine, b64url, fromB64url, generateKeyPair, hostSessionKeys,
14
+ CloseCode, FrameCipher, ChunkAssembler, MAX_FRAME_BYTES, REASSEMBLED_MAX_BYTES, RELAY_REPLY_LIMIT, RING_ENTRIES_PER_FRAME, StreamBuffer, encodeLine, b64url, fromB64url,
15
+ RingState, generateKeyPair, hostSessionKeys, ringB64,
14
16
  } from "../protocol.js";
15
-
17
+
18
+
19
+
16
20
  import { relaySocketURL } from "../http.js";
17
21
  import { BoardReader } from "./board.js";
18
22
  import { readWork, workDirectories } from "./work.js";
19
23
  import { killCommand, spawnCommand } from "./command.js";
20
- import { hostKeyPair, loadState } from "./state.js";
24
+ import {
25
+ LogGrowth, PENDING_LOG_MAX_BYTES, RING_LOG_MAX_ENTRIES, RelayRingStore, RingCache, acceptAnchor, admit, anchorPrompt, checkAnchor, isKey32, noteRingDevice, recordPending, rejectAnchor,
26
+ replayAll, storeLog, wireEntries,
27
+ } from "./ring.js";
28
+
29
+ import { hostKeyPair, loadState, statePathForWatch, updateState } from "./state.js";
21
30
  import { annotateWriterError } from "./writer.js";
22
31
 
23
32
 
@@ -60,6 +69,13 @@ import { annotateWriterError } from "./writer.js";
60
69
 
61
70
 
62
71
 
72
+
73
+
74
+
75
+
76
+
77
+
78
+
63
79
 
64
80
 
65
81
 
@@ -79,6 +95,8 @@ import { annotateWriterError } from "./writer.js";
79
95
 
80
96
 
81
97
 
98
+
99
+
82
100
 
83
101
 
84
102
 
@@ -90,6 +108,22 @@ import { annotateWriterError } from "./writer.js";
90
108
 
91
109
 
92
110
 
111
+
112
+
113
+
114
+
115
+
116
+
117
+
118
+
119
+
120
+
121
+
122
+
123
+
124
+
125
+
126
+
93
127
 
94
128
 
95
129
  export class BridgeService {
@@ -103,6 +137,16 @@ export class BridgeService {
103
137
 
104
138
  options ;
105
139
  board ;
140
+ ringCache = new RingCache();
141
+ relayRing = new RelayRingStore(() => loadState());
142
+ /** What connected ring peers last heard from this bridge, so a change is told once. */
143
+ ringTold = "";
144
+ /** The ring this bridge last saw itself anchored to, to catch up once a new anchor is confirmed. */
145
+ anchoredId ;
146
+ /** The newest relay push not handled yet, and whether one is being handled: pushes never pile up. */
147
+ nextPush = null;
148
+ pushRunning = false;
149
+ watching = null;
106
150
 
107
151
  constructor(options = {}) {
108
152
  this.options = options;
@@ -111,11 +155,16 @@ export class BridgeService {
111
155
  }
112
156
 
113
157
  start() {
158
+ try { this.anchoredId = loadState().ring?.id ?? null; } catch { this.anchoredId = null; /* not set up: connectRelay says so */ }
114
159
  this.connectRelay();
160
+ // `replica-bridge ring accept`, `ring reset` and `revoke` run as other processes and change state.json.
161
+ this.watching = statePathForWatch();
162
+ fs.watchFile(this.watching, { interval: 1000 }, () => this.ringChanged());
115
163
  }
116
164
 
117
165
  stop() {
118
166
  this.stopped = true;
167
+ if (this.watching) { fs.unwatchFile(this.watching); this.watching = null; }
119
168
  if (this.retryTimer) clearTimeout(this.retryTimer);
120
169
  this.relay?.close();
121
170
  this.board.stop();
@@ -157,7 +206,8 @@ export class BridgeService {
157
206
 
158
207
  connectRelay() {
159
208
  const state = loadState();
160
- const ws = new WebSocket(relaySocketURL(state.relay, "/v1/host"), { headers: { authorization: `Bearer ${state.hostToken}` }, maxPayload: MAX_FRAME_BYTES + 4096, perMessageDeflate: false });
209
+ // ring=1: this bridge reads `key` in `open` and the relay's ring pushes; a relay without the ring ignores it.
210
+ const ws = new WebSocket(relaySocketURL(state.relay, "/v1/host?ring=1"), { headers: { authorization: `Bearer ${state.hostToken}` }, maxPayload: MAX_FRAME_BYTES + 4096, perMessageDeflate: false });
161
211
  this.relay = ws;
162
212
  let refused = false;
163
213
  // A laptop that sleeps or changes networks keeps a socket the relay dropped long ago, and nothing here would
@@ -178,6 +228,8 @@ export class BridgeService {
178
228
  this.log(`connected to relay ${state.relay} as ${state.hostId}`);
179
229
  this.options.onRelayConnected?.(true);
180
230
  this.relayChanged();
231
+ this.relayRing.forget();
232
+ void this.catchUpFromRelay();
181
233
  });
182
234
  ws.on("message", (data ) => {
183
235
  let envelope ;
@@ -243,55 +295,98 @@ export class BridgeService {
243
295
  this.conns.delete(conn.id);
244
296
  }
245
297
 
246
- onEnvelope(envelope ) {
298
+ onEnvelope(envelope ) {
247
299
  if (envelope.type === "open") {
248
- this.conns.set(envelope.conn, { id: envelope.conn, deviceId: envelope.device, cipher: null, stream: null, queue: Promise.resolve(), boardUnsubscribe: null });
300
+ const offered = envelope.key;
301
+ this.conns.set(envelope.conn, {
302
+ id: envelope.conn, deviceId: envelope.device, cipher: null, stream: null, queue: Promise.resolve(), boardUnsubscribe: null,
303
+ offeredKey: isKey32(offered) ? fromB64url(offered) : null, admitted: null, ringPeer: false, held: null, ringWaiter: null, nextHead: null, headRunning: false, peerRing: null, peerSeq: -1,
304
+ });
249
305
  } else if (envelope.type === "close") {
250
306
  const conn = this.conns.get(envelope.conn);
251
307
  if (conn) { this.detach(conn); this.conns.delete(conn.id); }
252
308
  } else if (envelope.type === "frame") {
253
309
  const conn = this.conns.get(envelope.conn);
254
310
  if (!conn) return;
255
- conn.queue = conn.queue.then(() => this.onFrame(conn, envelope.frame)).catch((error) => {
256
- this.log(`connection ${conn.id} from ${conn.deviceId}: ${(error ).message}`);
257
- this.sendFrame(conn, { type: "error", code: "protocol", message: "the bridge could not handle a frame" });
258
- this.closeConn(conn, "protocol error");
259
- });
311
+ this.enqueue(conn, () => this.onFrame(conn, envelope.frame));
312
+ } else if (envelope.type === "ring") {
313
+ void this.onRingPush(envelope);
260
314
  }
261
315
  }
262
316
 
263
- /** Looks the device up in state.json; a device paired a moment ago may not be saved yet, so look again briefly. */
264
- async pairedDevice(deviceId ) {
317
+ /** Runs work in this connection's order; a failure is a protocol error that ends the connection. */
318
+ enqueue(conn , work ) {
319
+ conn.queue = conn.queue.then(work).catch((error) => {
320
+ this.log(`connection ${conn.id} from ${conn.deviceId}: ${(error ).message}`);
321
+ this.sendFrame(conn, { type: "error", code: "protocol", message: "the bridge could not handle a frame" });
322
+ this.closeConn(conn, "protocol error");
323
+ });
324
+ }
325
+
326
+ /**
327
+ * Who this device is: paired here by QR code, or an active signer of the anchored ring (ring.ts admit). A device
328
+ * paired a moment ago may not be saved yet, so look again briefly; one the ring revoked is refused at once.
329
+ */
330
+ async admitDevice(conn ) {
331
+ let refused = "";
265
332
  for (let attempt = 0; attempt < 6; attempt++) {
266
333
  const state = loadState();
267
- const device = state.devices.find((d) => d.deviceId === deviceId);
268
- if (device) return { state, publicKey: fromB64url(device.publicKey) };
334
+ const ring = this.ringCache.get(state.ring, this.log);
335
+ // Every handshake looks at the log again, so a revocation another process stored ends live connections too.
336
+ this.enforceRing(state, ring);
337
+ const result = admit(state, ring, hostKeyPair(state).publicKey, conn.deviceId, conn.offeredKey);
338
+ if (!("refused" in result)) return { state, admitted: result };
339
+ refused = result.refused;
340
+ // Only an unknown device may be one paired a moment ago; the ring's refusals are final.
341
+ if (state.devices.some((d) => d.deviceId === conn.deviceId) || !refused.startsWith("not paired")) break;
269
342
  await new Promise((resolve) => setTimeout(resolve, 500));
270
343
  }
271
- return null;
344
+ return { refused };
272
345
  }
273
346
 
274
347
  async onFrame(conn , frame ) {
275
348
  if (frame.type === "hello") {
276
349
  if (conn.cipher) throw new Error("second hello");
277
- const paired = await this.pairedDevice(conn.deviceId);
278
- if (!paired) {
279
- this.sendFrame(conn, { type: "error", code: "unknown_device", message: "this phone is not paired with this computer" });
350
+ const found = await this.admitDevice(conn);
351
+ if ("refused" in found) {
352
+ this.log(`refused ${conn.deviceId}: ${found.refused}`);
353
+ this.sendFrame(conn, { type: "error", code: "unknown_device", message: found.refused === "not paired with this computer" ? "this phone is not paired with this computer" : `this device may not connect: ${found.refused}` });
280
354
  return this.closeConn(conn, "unknown device");
281
355
  }
282
356
  const deviceEphemeral = fromB64url(frame.e);
283
357
  if (deviceEphemeral.length !== 32) throw new Error("bad ephemeral key");
284
358
  const ephemeral = generateKeyPair();
285
- const keys = hostSessionKeys(hostKeyPair(paired.state), ephemeral, paired.publicKey, deviceEphemeral);
359
+ const keys = hostSessionKeys(hostKeyPair(found.state), ephemeral, found.admitted.key, deviceEphemeral);
286
360
  conn.cipher = new FrameCipher(keys.hostToDevice, keys.deviceToHost, keys.transcript);
361
+ conn.admitted = found.admitted;
362
+ if (found.admitted.via === "ring") {
363
+ this.log(`${conn.deviceId} connects as ${found.admitted.name}, a signer of this computer's device ring`);
364
+ // Kept so `replica-bridge revoke <deviceId>` can refuse this key here, though it was never paired by QR code.
365
+ const { deviceId } = conn, { key, name } = found.admitted;
366
+ if (noteRingDevice(loadState(), deviceId, key, name)) updateState((state) => { noteRingDevice(state, deviceId, key, name); });
367
+ }
287
368
  this.sendFrame(conn, { type: "welcome", v: 1, e: b64url(ephemeral.publicKey) });
288
369
  return;
289
370
  }
290
371
  if (frame.type !== "data" || !conn.cipher) throw new Error(`unexpected ${frame.type} frame`);
291
372
  const message = conn.cipher.openFrame(frame);
373
+ if (message.t === "ring/head" || message.t === "ring/get" || message.t === "ring/entries") return this.onRingMessage(conn, message);
374
+ // A ring peer's messages wait until its ring/head is handled (and any entries it has that this computer lacks).
375
+ if (conn.held) {
376
+ conn.held.messages.push(message);
377
+ conn.held.bytes += frame.c.length;
378
+ // A peer that keeps sending without ever saying its head is not held without limit.
379
+ if (conn.held.messages.length >= HELD_MESSAGES_MAX || conn.held.bytes >= HELD_BYTES_MAX) this.releaseHeld(conn);
380
+ return;
381
+ }
382
+ await this.onMessage(conn, message);
383
+ }
384
+
385
+ async onMessage(conn , message ) {
292
386
  if (message.t === "resume") {
293
387
  if (conn.stream) throw new Error("second resume");
294
388
  await this.resume(conn, message.stream, message.ack, message.seg === 1);
389
+ if (message.ring === 1 && conn.stream) this.holdForRing(conn);
295
390
  } else if (message.t === "msg") {
296
391
  const stream = conn.stream;
297
392
  if (!stream) throw new Error("message before resume");
@@ -328,24 +423,298 @@ export class BridgeService {
328
423
  }
329
424
  }
330
425
 
426
+ // MARK: - Device ring (ring.ts, PROTOCOL.md "设备环:电脑一侧")
427
+
428
+ get ringWaitMs() { return (this.options.ringWaitSeconds ?? 10) * 1000; }
429
+
430
+ /** The pending anchors, as a person here is asked about them. */
431
+ pendingAnchors() { return (loadState().ringPending ?? []).map(anchorPrompt); }
432
+
433
+ /** A person here answered an anchor request (the terminal's y/n, the desktop app's dialog); `ring` is its id. */
434
+ answerAnchor(ring , accept ) {
435
+ let removed = [];
436
+ if (accept) {
437
+ removed = acceptAnchor(ring, hostKeyPair(loadState()).publicKey).removed;
438
+ this.log(`device ring: anchored to ring ${ring}${removed.length ? `; removed ${removed.join(", ")}, revoked in the ring, from the paired devices` : ""}`);
439
+ } else {
440
+ rejectAnchor(ring);
441
+ this.log(`device ring: rejected the request to anchor to ring ${ring}`);
442
+ }
443
+ this.ringChanged();
444
+ return { removed };
445
+ }
446
+
447
+ /** Holds a ring peer's messages until its ring/head is handled, or for at most twice the wait. */
448
+ holdForRing(conn ) {
449
+ conn.ringPeer = true;
450
+ if (conn.held) return;
451
+ conn.held = { messages: [], bytes: 0, timer: setTimeout(() => this.releaseHeld(conn), this.ringWaitMs * 2), opening: false };
452
+ }
453
+
454
+ /** Handles the held messages in order, behind whatever this connection already queued. */
455
+ releaseHeld(conn ) {
456
+ const held = conn.held;
457
+ if (!held || held.opening) return;
458
+ held.opening = true;
459
+ clearTimeout(held.timer);
460
+ this.enqueue(conn, async () => {
461
+ while (held.messages.length) {
462
+ if (this.conns.get(conn.id) !== conn) return;
463
+ await this.onMessage(conn, held.messages.shift() );
464
+ }
465
+ if (conn.held === held) conn.held = null;
466
+ });
467
+ }
468
+
469
+ onRingMessage(conn , message ) {
470
+ conn.ringPeer = true;
471
+ if (message.t === "ring/entries") {
472
+ const waiter = conn.ringWaiter;
473
+ conn.ringWaiter = null;
474
+ waiter?.(message);
475
+ } else if (message.t === "ring/get") {
476
+ this.answerRingGet(conn, message);
477
+ } else {
478
+ // Not awaited: catching up asks the peer for entries, and its answers arrive behind this frame.
479
+ conn.nextHead = message;
480
+ if (conn.headRunning) return;
481
+ conn.headRunning = true;
482
+ void (async () => {
483
+ while (conn.nextHead) {
484
+ const head = conn.nextHead;
485
+ conn.nextHead = null;
486
+ try { await this.onPeerHead(conn, head); } catch (error) { this.log(`device ring: ${conn.deviceId}: ${(error ).message}`); }
487
+ this.releaseHeld(conn);
488
+ }
489
+ conn.headRunning = false;
490
+ })();
491
+ }
492
+ }
493
+
494
+ answerRingGet(conn , message ) {
495
+ const state = loadState();
496
+ const ring = this.ringCache.get(state.ring, this.log);
497
+ const after = message.after ?? -1;
498
+ if (!state.ring || !ring || message.ring !== state.ring.id || !Number.isSafeInteger(after) || after < -1) {
499
+ return this.sendInner(conn, { t: "ring/entries", ring: String(message.ring), entries: [] });
500
+ }
501
+ const valid = state.ring.entries.slice(0, ring.seq + 1);
502
+ const entries = valid.slice(after + 1, after + 1 + RING_ENTRIES_PER_FRAME);
503
+ this.sendInner(conn, { t: "ring/entries", ring: state.ring.id, entries, ...(after + 1 + entries.length < valid.length ? { more: true } : {}) });
504
+ }
505
+
506
+ ownHead(state , ring , peerRing ) {
507
+ if (state.ring && ring) return { t: "ring/head", ring: state.ring.id, seq: ring.seq, id: b64url(ring.head) };
508
+ const pending = !state.ring && peerRing ? state.ringPending?.find((p) => p.ring === peerRing) : undefined;
509
+ return { t: "ring/head", ring: null, ...(pending ? { pending: pending.ring } : {}) };
510
+ }
511
+
512
+ async onPeerHead(conn , head ) {
513
+ const peerRing = isKey32(head.ring) ? head.ring : null;
514
+ const seq = typeof head.seq === "number" && Number.isSafeInteger(head.seq) && head.seq >= 0 ? head.seq : null;
515
+ conn.peerRing = peerRing;
516
+ conn.peerSeq = seq ?? -1;
517
+ const state = loadState();
518
+ if (state.ring) {
519
+ // An anchored computer listens to its own ring only.
520
+ if (peerRing === state.ring.id && seq !== null) await this.catchUp(peerRing, seq, isKey32(head.id) ? head.id : null, conn);
521
+ } else if (peerRing && seq !== null && isKey32(head.id) && isKey32(head.key) && conn.admitted?.via === "qr") {
522
+ await this.requestAnchor(conn, peerRing, seq, head.id, head.key);
523
+ }
524
+ if (this.conns.get(conn.id) !== conn) return;
525
+ const now = loadState();
526
+ this.sendInner(conn, this.ownHead(now, this.ringCache.get(now.ring, this.log), peerRing));
527
+ }
528
+
529
+ /**
530
+ * Brings the stored log up to `until`: from the peer over the session first, then from the relay when it has the
531
+ * ring endpoints. Every entry is applied with RingState as it arrives (LogGrowth); the first that does not apply
532
+ * ends that source (a fork is only logged: fork handling is design only). Fetching holds no lock, so a slow peer
533
+ * delays nobody else; what was verified is merged into the stored log in one synchronous step (storeLog), and
534
+ * revocations in it take effect at once.
535
+ */
536
+ async catchUp(ringId , until , untilId , conn ) {
537
+ const state = loadState();
538
+ if (state.ring?.id !== ringId || state.ring.stalled) return;
539
+ const base = state.ring.entries;
540
+ let working ;
541
+ try { working = replayAll(base); } catch (error) { return this.log(`device ring: the stored log does not replay, so it cannot grow: ${(error ).message}`); }
542
+ if (until <= working.seq) {
543
+ if (until === working.seq && untilId && untilId !== b64url(working.head)) this.log(`device ring: ${conn?.deviceId ?? "the relay"} has another entry at seq ${until} (a fork?); keeping this computer's`);
544
+ return;
545
+ }
546
+ const growth = new LogGrowth(working, base, until);
547
+ if (conn) {
548
+ try { await this.pullFromPeer(conn, ringId, growth); } catch (error) { this.log(`device ring: ${conn.deviceId}: ${(error ).message}`); }
549
+ if (growth.stopped && !growth.full) { this.log(`device ring: ${growth.stopped}`); growth.stopped = null; }
550
+ }
551
+ if (!growth.done && await this.relayRing.supported()) {
552
+ try { await this.relayRing.fetchInto(ringId, growth); } catch (error) { this.log(`device ring: could not catch up from the relay: ${(error ).message}`); }
553
+ if (growth.stopped && !growth.full) this.log(`device ring: ${growth.stopped}`);
554
+ }
555
+ if (growth.state.seq === until && untilId && untilId !== b64url(growth.state.head)) this.log(`device ring: the head at seq ${until} is not the one ${conn?.deviceId ?? "the relay"} named (a fork?)`);
556
+ const stalled = growth.full ? growth.stopped : null;
557
+ if (stalled) this.log(`device ring: ${stalled} while there is more; the ring lets nobody in until "replica-bridge ring reset"`);
558
+ if (!growth.added.length && !stalled) return;
559
+ const stored = storeLog(ringId, base, growth.added, stalled);
560
+ if (stored) this.log(`device ring: caught up to seq ${stored.seq}${stored.removed.length ? `; removed ${stored.removed.join(", ")}, revoked in the ring, from the paired devices` : ""}`);
561
+ this.ringChanged();
562
+ }
563
+
564
+ /** After the relay socket opens, or a new anchor: entries the relay has that this computer missed. */
565
+ async catchUpFromRelay() {
566
+ try {
567
+ const state = loadState();
568
+ if (!state.ring || !(await this.relayRing.supported())) return;
569
+ await this.catchUp(state.ring.id, Number.MAX_SAFE_INTEGER, null, null);
570
+ } catch (error) {
571
+ this.log(`device ring: ${(error ).message}`);
572
+ }
573
+ }
574
+
575
+ /** Handles the newest push only; pushes that arrive meanwhile replace each other. */
576
+ async onRingPush(push ) {
577
+ if (!Number.isSafeInteger(push.seq) || typeof push.ring !== "string") return;
578
+ if (!this.nextPush || push.seq > this.nextPush.seq) this.nextPush = push;
579
+ if (this.pushRunning) return;
580
+ this.pushRunning = true;
581
+ try {
582
+ while (this.nextPush) {
583
+ const next = this.nextPush;
584
+ this.nextPush = null;
585
+ try {
586
+ const state = loadState();
587
+ if (state.ring && next.ring === state.ring.id) await this.catchUp(next.ring, next.seq, isKey32(next.id) ? next.id : null, null);
588
+ } catch (error) {
589
+ this.log(`device ring: ${(error ).message}`);
590
+ }
591
+ }
592
+ } finally {
593
+ this.pushRunning = false;
594
+ }
595
+ }
596
+
597
+ /** Asks the peer for pages after the growth's head until it is done, the peer has no more, or a page brings nothing. */
598
+ async pullFromPeer(conn , ringId , growth ) {
599
+ while (!growth.done) {
600
+ const after = growth.state.seq;
601
+ const answer = await this.askPeer(conn, { t: "ring/get", ring: ringId, ...(after >= 0 ? { after } : {}) });
602
+ const page = answer.ring === ringId ? wireEntries(answer.entries) : null;
603
+ if (!page) throw new Error("the peer answered ring/get with something else");
604
+ if (!growth.take(page, conn.deviceId) || !answer.more || !page.length) return;
605
+ }
606
+ }
607
+
608
+ askPeer(conn , message ) {
609
+ return new Promise((resolve, reject) => {
610
+ if (this.conns.get(conn.id) !== conn) return reject(new Error("the peer left"));
611
+ const timer = setTimeout(() => {
612
+ if (conn.ringWaiter === done) conn.ringWaiter = null;
613
+ reject(new Error("the peer did not answer ring/get in time"));
614
+ }, this.ringWaitMs);
615
+ const done = (answer ) => { clearTimeout(timer); resolve(answer); };
616
+ conn.ringWaiter = done;
617
+ this.sendInner(conn, message);
618
+ });
619
+ }
620
+
621
+ /**
622
+ * A paired signer named its ring and this computer has none yet: fetch the whole log (checked as it arrives, at most
623
+ * PENDING_LOG_MAX_BYTES), check the anchoring conditions (ring.ts checkAnchor) and record a pending anchor for a
624
+ * person here to confirm. Nothing else changes.
625
+ */
626
+ async requestAnchor(conn , ringId , seq , headId , signingKey ) {
627
+ const admitted = conn.admitted ;
628
+ if (seq >= RING_LOG_MAX_ENTRIES) return this.log(`device ring: ${admitted.name}'s ring is longer than this bridge takes`);
629
+ let growth = new LogGrowth(new RingState(), [], seq, PENDING_LOG_MAX_BYTES);
630
+ try { await this.pullFromPeer(conn, ringId, growth); } catch (error) { this.log(`device ring: ${admitted.name}: ${(error ).message}`); }
631
+ if (growth.state.seq < seq && !growth.full && await this.relayRing.supported()) {
632
+ growth = new LogGrowth(new RingState(), [], seq, PENDING_LOG_MAX_BYTES);
633
+ try { await this.relayRing.fetchInto(ringId, growth); } catch (error) { this.log(`device ring: could not read ring ${ringId} from the relay: ${(error ).message}`); }
634
+ }
635
+ const entries = growth.added;
636
+ const state = loadState();
637
+ const checked = growth.full ? { refused: "the log is too long" } : checkAnchor(state, hostKeyPair(state).publicKey, { deviceId: conn.deviceId, key: admitted.key, signingKey: ringB64(signingKey), ring: ringId, entries });
638
+ const refused = "refused" in checked ? checked.refused : b64url(checked.ring.head) !== headId ? "the log does not end at the head it named" : null;
639
+ if (refused) return this.log(`device ring: not asking about ${admitted.name}'s request to anchor to ring ${ringId}: ${refused}`);
640
+ const fresh = !state.ringPending?.some((p) => p.ring === ringId);
641
+ const pending = { ring: ringId, entries, requester: { deviceId: conn.deviceId, name: admitted.name, key: b64url(admitted.key), signingKey }, requestedAt: new Date().toISOString() };
642
+ if (!recordPending(pending)) return this.log(`device ring: not recording ${admitted.name}'s request: this computer is anchored, or too many rings are waiting`);
643
+ if (!fresh) return;
644
+ const prompt = anchorPrompt(pending);
645
+ this.log(`device ring: ${prompt.requester} asks to anchor this computer to its ring (fingerprint ${prompt.fingerprint}); confirm with "npx replica-bridge ring accept"`);
646
+ this.options.onAnchorRequest?.(prompt);
647
+ }
648
+
649
+ /** Applies what state.json says now: drops whoever the ring no longer lets in, and tells ring peers a new head. */
650
+ ringChanged() {
651
+ let state ;
652
+ try { state = loadState(); } catch { return; }
653
+ const ring = this.ringCache.get(state.ring, this.log);
654
+ this.enforceRing(state, ring);
655
+ const anchored = state.ring?.id ?? null;
656
+ if (anchored !== this.anchoredId) {
657
+ const first = this.anchoredId === undefined;
658
+ this.anchoredId = anchored;
659
+ // A new anchor carries the log as the requester sent it, maybe hours ago: revocations since then come first.
660
+ if (anchored && !first) void this.catchUpAfterAnchor(anchored);
661
+ }
662
+ const told = state.ring && ring ? `${state.ring.id}|${b64url(ring.head)}` : `-|${(state.ringPending ?? []).map((p) => p.ring).join(",")}`;
663
+ if (told === this.ringTold) return;
664
+ this.ringTold = told;
665
+ for (const conn of this.conns.values()) if (conn.ringPeer && conn.cipher) this.sendInner(conn, this.ownHead(state, ring, conn.peerRing));
666
+ }
667
+
668
+ async catchUpAfterAnchor(ringId ) {
669
+ await this.catchUpFromRelay();
670
+ for (const conn of [...this.conns.values()]) {
671
+ if (conn.peerRing === ringId && conn.cipher) {
672
+ try { await this.catchUp(ringId, conn.peerSeq, null, conn); } catch (error) { this.log(`device ring: ${(error ).message}`); }
673
+ }
674
+ }
675
+ }
676
+
677
+ /** Revocation takes effect at once: connections and kept streams of a device that would be refused now end. */
678
+ enforceRing(state , ring ) {
679
+ if (!this.conns.size && !this.streams.size) return;
680
+ const hostKey = hostKeyPair(state).publicKey;
681
+ const refusal = (deviceId , admitted ) => {
682
+ const now = admit(state, ring, hostKey, deviceId, admitted.via === "ring" ? admitted.key : null);
683
+ if ("refused" in now) return now.refused;
684
+ return now.key.equals(admitted.key) ? null : "its key changed";
685
+ };
686
+ for (const conn of [...this.conns.values()]) {
687
+ const why = conn.admitted && refusal(conn.deviceId, conn.admitted);
688
+ if (!why) continue;
689
+ this.log(`dropping ${conn.admitted .name} (${conn.deviceId}): ${why}`);
690
+ if (conn.stream) this.endStream(conn.stream, why);
691
+ if (this.conns.has(conn.id)) this.closeConn(conn, why);
692
+ }
693
+ for (const stream of [...this.streams.values()]) {
694
+ const why = refusal(stream.deviceId, stream.admitted);
695
+ if (why) this.endStream(stream, why);
696
+ }
697
+ }
698
+
331
699
  // MARK: - Streams
332
700
 
333
701
  async resume(conn , streamId , peerAck , segmented ) {
334
702
  let stream = streamId ? this.streams.get(streamId) : undefined;
335
703
  const resumed = !!stream && stream.deviceId === conn.deviceId;
336
704
  if (!resumed) {
337
- stream = await this.openStream(conn.deviceId);
705
+ stream = await this.openStream(conn.deviceId, conn.admitted );
338
706
  // The phone may have left while the app-server came up. It never heard this stream's id, so it cannot come back
339
707
  // for it, and detach found no stream to start the timer on: left alone, its proxy stays as long as the bridge.
340
708
  if (this.stopped || this.conns.get(conn.id) !== conn) return this.endStream(stream, "the phone left before its stream opened");
341
709
  }
342
710
  const s = stream ;
711
+ s.admitted = conn.admitted ;
343
712
  if (s.conn && s.conn !== conn) this.closeConn(s.conn, "resumed elsewhere");
344
713
  if (s.expiry) { clearTimeout(s.expiry); s.expiry = null; }
345
714
  s.conn = conn;
346
715
  s.segmented = segmented;
347
716
  conn.stream = s;
348
- this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received, seg: 1 });
717
+ this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received, seg: 1, ring: 1 });
349
718
  // A new stream's sequence numbers start over, whatever the phone acknowledged on its old one.
350
719
  const after = resumed ? peerAck : 0;
351
720
  s.out.ack(after);
@@ -364,11 +733,11 @@ export class BridgeService {
364
733
  }
365
734
  }
366
735
 
367
- async openStream(deviceId ) {
736
+ async openStream(deviceId , admitted ) {
368
737
  const { ws: appServer, frameBytes } = await this.connectAppServer();
369
738
  const stream = {
370
739
  id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(STREAM_BUFFER_BYTES), in: new StreamBuffer(STREAM_BUFFER_BYTES),
371
- conn: null, expiry: null, ackTimer: null, relayed: Promise.resolve(), segmented: false, chunks: new ChunkAssembler(), appServerFrameBytes: frameBytes,
740
+ conn: null, expiry: null, ackTimer: null, relayed: Promise.resolve(), segmented: false, chunks: new ChunkAssembler(), appServerFrameBytes: frameBytes, admitted,
372
741
  };
373
742
  this.streams.set(stream.id, stream);
374
743
  appServer.on("message", (data ) => {
@@ -405,6 +774,8 @@ export class BridgeService {
405
774
  }
406
775
 
407
776
  detach(conn ) {
777
+ if (conn.held) { clearTimeout(conn.held.timer); conn.held = null; }
778
+ conn.ringWaiter = null;
408
779
  conn.boardUnsubscribe?.();
409
780
  conn.boardUnsubscribe = null;
410
781
  const stream = conn.stream;
@@ -417,12 +788,13 @@ export class BridgeService {
417
788
 
418
789
  endStream(stream , reason ) {
419
790
  if (!this.streams.delete(stream.id)) return;
420
- if (stream.expiry) clearTimeout(stream.expiry);
421
791
  if (stream.ackTimer) clearTimeout(stream.ackTimer);
422
792
  if (stream.conn) {
423
793
  this.sendInner(stream.conn, { t: "closed", reason });
424
794
  this.closeConn(stream.conn, reason);
425
795
  }
796
+ // After closing the connection: detach starts the stream's expiry, which an ended stream must not keep.
797
+ if (stream.expiry) clearTimeout(stream.expiry);
426
798
  stream.appServer.close();
427
799
  this.log(`closed stream ${stream.id}: ${reason}`);
428
800
  }
@@ -525,6 +897,11 @@ export class BridgeService {
525
897
  }
526
898
  }
527
899
 
900
+ /** Messages a ring peer may send before its ring/head is handled; past this they go through anyway. */
901
+ const HELD_MESSAGES_MAX = 1024;
902
+ /** Nor more than this many bytes of them (sealed, as they came). */
903
+ const HELD_BYTES_MAX = 32 * 1024 * 1024;
904
+
528
905
  /** Larger than any request the app-server makes of a client; only replies and notifications run this long. */
529
906
  const MAX_REQUEST_CHARS_TO_INSPECT = 1024 * 1024;
530
907
 
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fromB64url, keyPairFromPrivate } from "../protocol.js";
6
-
6
+
7
7
 
8
8
 
9
9
 
@@ -20,6 +20,45 @@ import { fromB64url, keyPairFromPrivate } from "../protocol.js";
20
20
 
21
21
 
22
22
 
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
23
62
 
24
63
 
25
64
  export const bridgeHome = () => process.env.REPLICA_BRIDGE_HOME ?? path.join(os.homedir(), ".config", "replica-bridge");
@@ -41,4 +80,17 @@ export function saveState(state ) {
41
80
  fs.renameSync(temporary, statePath());
42
81
  }
43
82
 
83
+ /**
84
+ * Reads state.json, changes it and writes it back in one synchronous step, so this process never interleaves two
85
+ * changes; another process (`replica-bridge pair`, `ring accept`) writes the same way and the window between is tiny.
86
+ */
87
+ export function updateState(change ) {
88
+ const state = loadState();
89
+ change(state);
90
+ saveState(state);
91
+ return state;
92
+ }
93
+
94
+ export const statePathForWatch = statePath;
95
+
44
96
  export const hostKeyPair = (state ) => keyPairFromPrivate(fromB64url(state.hostPrivateKey));