@nopeek/chat 0.2.2 → 0.2.4

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/dist/index.js CHANGED
@@ -33,6 +33,9 @@ const defaultStore = () => {
33
33
  * trickle so the visible text eases in smoothly instead of arriving in big jumps.
34
34
  */
35
35
  const STREAM_THROTTLE_MS = 120;
36
+ /** SDK version, exported so apps can run once-per-upgrade maintenance (e.g. a
37
+ * forced backup re-upload) without depending on package.json at runtime. */
38
+ export const SDK_VERSION = "0.2.4";
36
39
  class Emitter {
37
40
  listeners = new Map();
38
41
  on(event, fn) {
@@ -60,7 +63,20 @@ export class NoPeek extends Emitter {
60
63
  ws = null;
61
64
  reqCounter = 0;
62
65
  pending = new Map();
63
- channelKeys = new Map(); // channelId -> AES key
66
+ channelKeys = new Map(); // channelId -> AES key (the SEND key)
67
+ /** channelId -> (rawKeyB64 -> key): decrypt-only HISTORY keys. When a channel
68
+ * key is superseded (newer welcome, divergence heal, backup merge) the old
69
+ * key moves here instead of being dropped, so every message ever sent stays
70
+ * decryptable on every device. Persisted and carried in encrypted backups. */
71
+ channelKeyRings = new Map();
72
+ /** deviceId -> keypair for PREVIOUS device identities of this user, recovered
73
+ * from encrypted backups. A fresh login adopts ONE identity (the newest
74
+ * backup's), but the account may have older lineages — e.g. a web login that
75
+ * once registered its own device instead of adopting. Welcomes addressed to
76
+ * those old deviceIds are still unwrappable with these keys, so channel keys
77
+ * that only ever reached a legacy device remain recoverable. Persisted, and
78
+ * carried inside backup envelopes (`legacyDevices`). */
79
+ legacyIdentities = new Map();
64
80
  mlsGroupToChannel = new Map();
65
81
  /** Latest known presence per user, updated on every presence frame (incl. the
66
82
  * connect-time snapshot that may arrive before app listeners attach). */
@@ -189,8 +205,26 @@ export class NoPeek extends Emitter {
189
205
  this.channelKeys.set(chId, await importChannelKey(raw));
190
206
  }
191
207
  }
208
+ const savedRings = await this.store.get(`nopeek:${this.userId}:channelKeyRings`);
209
+ if (savedRings) {
210
+ for (const [chId, raws] of Object.entries(JSON.parse(savedRings))) {
211
+ const ring = new Map();
212
+ for (const raw of raws) {
213
+ try {
214
+ ring.set(raw, await importChannelKey(raw));
215
+ }
216
+ catch {
217
+ /* skip a corrupt entry */
218
+ }
219
+ }
220
+ if (ring.size)
221
+ this.channelKeyRings.set(chId, ring);
222
+ }
223
+ }
224
+ await this.loadLegacyIdentities();
192
225
  return;
193
226
  }
227
+ await this.loadLegacyIdentities();
194
228
  this.keys = await generateDeviceKeys();
195
229
  // Deferred: the caller decides whether to restore() the real identity from a
196
230
  // backup or registerDevice() a genuinely new one. Registering here would
@@ -239,11 +273,89 @@ export class NoPeek extends Emitter {
239
273
  async persistDevice() {
240
274
  await this.store.set(`nopeek:${this.userId}:device`, JSON.stringify({ deviceId: this.deviceId, keys: this.keys }));
241
275
  }
276
+ // ------------------------------------------------- legacy identities ----
277
+ async loadLegacyIdentities() {
278
+ try {
279
+ const saved = await this.store.get(`nopeek:${this.userId}:legacyDevices`);
280
+ if (!saved)
281
+ return;
282
+ for (const [devId, keys] of Object.entries(JSON.parse(saved))) {
283
+ if (devId !== this.deviceId)
284
+ this.legacyIdentities.set(devId, keys);
285
+ }
286
+ }
287
+ catch {
288
+ /* corrupt entry — legacy identities are a recovery aid, never fatal */
289
+ }
290
+ }
291
+ async persistLegacyIdentities() {
292
+ const out = {};
293
+ for (const [devId, keys] of this.legacyIdentities)
294
+ out[devId] = keys;
295
+ await this.store.set(`nopeek:${this.userId}:legacyDevices`, JSON.stringify(out));
296
+ }
297
+ /** Remember a previous device identity of this user (from a backup envelope)
298
+ * so welcomes addressed to it can still be unwrapped. Returns whether new. */
299
+ async registerLegacyIdentity(device) {
300
+ if (!device?.deviceId || !device.keys?.privateKeyJwk)
301
+ return false;
302
+ if (device.deviceId === this.deviceId || this.legacyIdentities.has(device.deviceId))
303
+ return false;
304
+ this.legacyIdentities.set(device.deviceId, device.keys);
305
+ await this.persistLegacyIdentities();
306
+ return true;
307
+ }
308
+ /** Every deviceId this client can unwrap welcomes for: the live identity plus
309
+ * any legacy identities recovered from backups. */
310
+ welcomeIds() {
311
+ const ids = new Set();
312
+ if (this.deviceId)
313
+ ids.add(this.deviceId);
314
+ for (const id of this.legacyIdentities.keys())
315
+ ids.add(id);
316
+ return ids;
317
+ }
318
+ /** Find the wrap in a welcome addressed to us (live identity first, then
319
+ * legacy) and the private key that opens it. */
320
+ findMyWrap(wrapped) {
321
+ const own = wrapped.find((w) => w.deviceId === this.deviceId);
322
+ if (own && this.keys)
323
+ return { wrap: own, privateKeyJwk: this.keys.privateKeyJwk };
324
+ for (const [devId, keys] of this.legacyIdentities) {
325
+ const w = wrapped.find((x) => x.deviceId === devId);
326
+ if (w)
327
+ return { wrap: w, privateKeyJwk: keys.privateKeyJwk };
328
+ }
329
+ return null;
330
+ }
242
331
  async persistChannelKeys() {
243
332
  const out = {};
244
333
  for (const [chId, key] of this.channelKeys)
245
334
  out[chId] = await exportChannelKey(key);
246
335
  await this.store.set(`nopeek:${this.userId}:channelKeys`, JSON.stringify(out));
336
+ const rings = {};
337
+ for (const [chId, ring] of this.channelKeyRings)
338
+ if (ring.size)
339
+ rings[chId] = [...ring.keys()];
340
+ await this.store.set(`nopeek:${this.userId}:channelKeyRings`, JSON.stringify(rings));
341
+ }
342
+ /** Add a decrypt-only history key to a channel's ring (deduped by raw bytes;
343
+ * the current send key is never ringed). Returns whether the ring grew.
344
+ * Callers persist — this only mutates in-memory state. */
345
+ async ringAdd(channelId, key, rawB64) {
346
+ const raw = rawB64 ?? (await exportChannelKey(key));
347
+ const primary = this.channelKeys.get(channelId);
348
+ if (primary && (await exportChannelKey(primary)) === raw)
349
+ return false;
350
+ let ring = this.channelKeyRings.get(channelId);
351
+ if (!ring) {
352
+ ring = new Map();
353
+ this.channelKeyRings.set(channelId, ring);
354
+ }
355
+ if (ring.has(raw))
356
+ return false;
357
+ ring.set(raw, key);
358
+ return true;
247
359
  }
248
360
  async ensureKeyPackages() {
249
361
  const { available } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/devices/${this.deviceId}/key-packages`);
@@ -479,12 +591,25 @@ export class NoPeek extends Emitter {
479
591
  async handleWelcome(hs) {
480
592
  try {
481
593
  const welcome = JSON.parse(new TextDecoder().decode(b64.dec(hs.payload)));
482
- const mine = welcome.wrapped.find((w) => w.deviceId === this.deviceId);
594
+ const mine = this.findMyWrap(welcome.wrapped ?? []);
483
595
  if (!mine)
484
596
  return;
485
- const key = await unwrapChannelKey(mine, this.keys.privateKeyJwk);
486
- this.channelKeys.set(welcome.channelId, key);
597
+ const key = await unwrapChannelKey(mine.wrap, mine.privateKeyJwk);
598
+ const raw = await exportChannelKey(key);
487
599
  this.mlsGroupToChannel.set(hs.mlsGroupId, welcome.channelId);
600
+ const prev = this.channelKeys.get(welcome.channelId);
601
+ const prevRaw = prev ? await exportChannelKey(prev) : null;
602
+ if (prevRaw === raw)
603
+ return; // already the key we hold
604
+ // A newer welcome SUPERSEDES the held key for sending — that's how every
605
+ // member device converges after a rotation or a divergence heal. The
606
+ // displaced key is NEVER dropped: it moves to the history ring (AFTER the
607
+ // swap, so the ring's dedupe-against-primary guard can't reject it) and
608
+ // all messages sent under it keep decrypting.
609
+ this.channelKeys.set(welcome.channelId, key);
610
+ this.channelKeyRings.get(welcome.channelId)?.delete(raw); // re-promoted: no longer history
611
+ if (prev && prevRaw)
612
+ await this.ringAdd(welcome.channelId, prev, prevRaw);
488
613
  await this.persistChannelKeys();
489
614
  this.emit("channelKeyReceived", { channelId: welcome.channelId });
490
615
  }
@@ -492,66 +617,183 @@ export class NoPeek extends Emitter {
492
617
  /* not a dev-suite welcome or not for us */
493
618
  }
494
619
  }
495
- /** Fetch any welcomes we missed while offline. */
620
+ /** Fetch any welcomes we missed while offline. Folds EVERY welcome addressed
621
+ * to this device, oldest → newest: the newest becomes the send key and the
622
+ * earlier ones land in the history ring, so a device that missed a rotation
623
+ * still reads the whole backlog. */
496
624
  async syncChannelKey(channelId, mlsGroupId) {
497
625
  if (this.channelKeys.has(channelId))
498
626
  return true;
499
627
  const { handshakes } = await this.api("GET", `/v1/apps/${this.appId}/mls/groups/${mlsGroupId}/handshakes?sinceEpoch=0`);
500
- for (const hs of handshakes.reverse()) {
501
- if (hs.kind === "welcome" && hs.recipientDeviceIds?.includes(this.deviceId)) {
628
+ const mine = this.welcomeIds();
629
+ for (const hs of handshakes) {
630
+ if (hs.kind === "welcome" && hs.recipientDeviceIds?.some((id) => mine.has(id))) {
502
631
  await this.handleWelcome({ mlsGroupId, payload: hs.payload });
503
- if (this.channelKeys.has(channelId))
504
- return true;
505
632
  }
506
633
  }
507
- return false;
634
+ return this.channelKeys.has(channelId);
508
635
  }
509
- /** Channels already checked for a self-addressed welcome this session. */
510
- selfWelcomeChecked = new Set();
636
+ /** Channels already reconciled against the group's welcome history this session. */
637
+ keyReconChecked = new Set();
511
638
  /**
512
- * Self-heal for channels created before establishKeys wrapped the welcome to
513
- * the creator's OWN device: if this device HOLDS the channel key but no
514
- * welcome in the group addresses this deviceId, post one (wrapped to
515
- * ourselves). A future device that restores this identity from the encrypted
516
- * backup or an already-restored device sharing this deviceId can then
517
- * pull the key via syncChannelKey even when the backup's channel-key map is
518
- * stale. Runs at most once per channel per device (persisted mark);
519
- * best-effort and safe to fire-and-forget.
639
+ * Group-key reconciliation runs once per channel per device per SDK
640
+ * version (persisted mark) BEFORE the first send, so a device can never
641
+ * encrypt into the void with a key the rest of the group doesn't hold.
642
+ *
643
+ * v2 (0.2.4). v1 reconciled a device only against welcomes addressed to
644
+ * ITSELF: a device holding an orphan key (minted by a stale pre-0.2.3
645
+ * bundle) saw no addressed welcome, posted a SELF-welcome legitimizing the
646
+ * orphan, and marked itself done — while the group's real key lived in a
647
+ * welcome addressed to a different device lineage. Two lineages with
648
+ * disjoint welcome sets never converged. v2:
649
+ * - unwraps every welcome addressed to this device OR a legacy identity
650
+ * restored from a backup, adopting the newest as the send key (displaced
651
+ * keys go to the history ring, nothing is lost);
652
+ * - computes COVERAGE: which member devices provably received a welcome
653
+ * carrying our current send key. If any device observed in the group's
654
+ * handshake history lacks it — or nothing addresses us at all — publish
655
+ * the send key AND the history ring to every member device, so all
656
+ * lineages (bots included) converge on one send key and can decrypt the
657
+ * full backlog;
658
+ * - v1 marks are deliberately ignored: every device re-runs one pass after
659
+ * upgrading, because v1 could have marked a diverged channel as done.
660
+ * Best-effort; retried on failure.
520
661
  */
521
- async ensureSelfWelcome(channelId, mlsGroupId) {
662
+ async reconcileChannelKey(channelId, mlsGroupId) {
522
663
  if (!mlsGroupId || this.deriveChannelKey || !this.deviceId)
523
664
  return; // preshared mode has no welcomes
524
- const key = this.channelKeys.get(channelId);
525
- if (!key)
665
+ const local = this.channelKeys.get(channelId);
666
+ if (!local)
526
667
  return;
527
- if (this.selfWelcomeChecked.has(channelId))
668
+ if (this.keyReconChecked.has(channelId))
528
669
  return;
529
- this.selfWelcomeChecked.add(channelId);
530
- const mark = `nopeek:${this.userId}:selfWelcomed:${channelId}`;
670
+ this.keyReconChecked.add(channelId);
671
+ const mark = `nopeek:${this.userId}:keyRecon2:${channelId}`;
531
672
  try {
532
673
  if (await this.store.get(mark))
533
674
  return;
534
675
  const { handshakes } = await this.api("GET", `/v1/apps/${this.appId}/mls/groups/${mlsGroupId}/handshakes?sinceEpoch=0`);
535
- const covered = handshakes.some((h) => h.kind === "welcome" && h.recipientDeviceIds?.includes(this.deviceId));
536
- if (!covered) {
537
- const wrapped = await wrapChannelKey(key, [{ deviceId: this.deviceId, publicKeyJwk: this.keys.publicKeyJwk }]);
538
- const welcome = { suite: "npdev-1", channelId, wrapped };
539
- // Only `commit` handshakes are epoch-guarded server-side; welcomes
540
- // append at the steady-state epoch (same as the member-add flow).
541
- await this.api("POST", `/v1/apps/${this.appId}/mls/groups/${mlsGroupId}/handshakes`, {
542
- epoch: 1,
543
- kind: "welcome",
544
- senderDeviceId: this.deviceId,
545
- recipientDeviceIds: [this.deviceId],
546
- payload: b64.enc(new TextEncoder().encode(JSON.stringify(welcome))),
547
- });
676
+ const mine = this.welcomeIds();
677
+ // Every deviceId that has EVER appeared in this group's handshakes — the
678
+ // observable universe of member devices (senders and welcome recipients).
679
+ const observed = new Set();
680
+ for (const h of handshakes) {
681
+ if (h.senderDeviceId)
682
+ observed.add(h.senderDeviceId);
683
+ for (const r of h.recipientDeviceIds ?? [])
684
+ observed.add(r);
685
+ }
686
+ // Unwrap every welcome addressed to us (live or legacy identity), oldest
687
+ // → newest: adopt each in turn (newest wins as send key, displaced keys
688
+ // ring), and record which recipients each carried key reached.
689
+ const coverage = new Map(); // rawKeyB64 -> recipient deviceIds
690
+ let addressedCount = 0;
691
+ for (const hs of handshakes) {
692
+ if (hs.kind !== "welcome")
693
+ continue;
694
+ try {
695
+ const welcome = JSON.parse(new TextDecoder().decode(b64.dec(hs.payload)));
696
+ if (welcome.channelId !== channelId)
697
+ continue;
698
+ const my = this.findMyWrap(welcome.wrapped ?? []);
699
+ if (!my)
700
+ continue;
701
+ addressedCount++;
702
+ const key = await unwrapChannelKey(my.wrap, my.privateKeyJwk);
703
+ const raw = await exportChannelKey(key);
704
+ let cov = coverage.get(raw);
705
+ if (!cov)
706
+ coverage.set(raw, (cov = new Set()));
707
+ for (const r of hs.recipientDeviceIds ?? welcome.wrapped.map((w) => w.deviceId))
708
+ cov.add(r);
709
+ // adopt: newest processed welcome becomes the send key
710
+ const prev = this.channelKeys.get(channelId);
711
+ const prevRaw = prev ? await exportChannelKey(prev) : null;
712
+ if (prevRaw !== raw) {
713
+ this.channelKeys.set(channelId, key);
714
+ this.channelKeyRings.get(channelId)?.delete(raw); // re-promoted: no longer history
715
+ if (prev && prevRaw)
716
+ await this.ringAdd(channelId, prev, prevRaw);
717
+ }
718
+ }
719
+ catch {
720
+ /* not a dev-suite welcome / stale wrap — skip */
721
+ }
722
+ }
723
+ const localRaw = await exportChannelKey(local);
724
+ const current = this.channelKeys.get(channelId) ?? local;
725
+ const currentRaw = await exportChannelKey(current);
726
+ if (currentRaw !== localRaw) {
727
+ await this.persistChannelKeys();
728
+ this.emit("channelKeyReceived", { channelId });
729
+ }
730
+ // Coverage gap: some observed member device (not one of our own
731
+ // identities) provably lacks our send key, or a ring key of ours never
732
+ // appeared in ANY welcome (it would die with this device's local store),
733
+ // or nothing addresses us at all. Publish send key + ring to everyone.
734
+ const ringRaws = [...(this.channelKeyRings.get(channelId)?.keys() ?? [])];
735
+ const uncoveredKey = [currentRaw, ...ringRaws].some((raw) => {
736
+ const cov = coverage.get(raw) ?? new Set();
737
+ return [...observed].some((d) => !cov.has(d) && !mine.has(d)) || cov.size === 0;
738
+ });
739
+ if (addressedCount === 0 || uncoveredKey) {
740
+ await this.publishKeysToMembers(channelId, mlsGroupId);
548
741
  }
549
742
  await this.store.set(mark, "1");
550
743
  }
551
744
  catch {
552
- this.selfWelcomeChecked.delete(channelId); // retry on a later ensureKey
745
+ this.keyReconChecked.delete(channelId); // retry on a later ensureKey
553
746
  }
554
747
  }
748
+ /** Back-compat alias: the 0.2.2 self-welcome heal is now part of reconcileChannelKey. */
749
+ async ensureSelfWelcome(channelId, mlsGroupId) {
750
+ return this.reconcileChannelKey(channelId, mlsGroupId);
751
+ }
752
+ /** Append a welcome carrying `wrapped` at the steady-state epoch. Only
753
+ * `commit` handshakes are epoch-guarded server-side; welcomes append freely
754
+ * (same as the member-add flow). */
755
+ async postWelcome(mlsGroupId, channelId, wrapped) {
756
+ const welcome = { suite: "npdev-1", channelId, wrapped };
757
+ await this.api("POST", `/v1/apps/${this.appId}/mls/groups/${mlsGroupId}/handshakes`, {
758
+ epoch: 1,
759
+ kind: "welcome",
760
+ senderDeviceId: this.deviceId,
761
+ recipientDeviceIds: wrapped.map((w) => w.deviceId),
762
+ payload: b64.enc(new TextEncoder().encode(JSON.stringify(welcome))),
763
+ });
764
+ }
765
+ /** Wrap this channel's HISTORY RING keys and then its SEND key to every
766
+ * member device (claiming key packages) plus this device, posting each as a
767
+ * welcome — the convergence carrier. The send key goes LAST so recipients
768
+ * (which adopt the newest welcome) all land on the same send key with the
769
+ * ring keys as decrypt-only history. Members whose packages can't be
770
+ * claimed heal later via sync/backup. */
771
+ async publishKeysToMembers(channelId, mlsGroupId) {
772
+ const recipients = [];
773
+ const { members } = await this.api("GET", `/v1/apps/${this.appId}/channels/${channelId}/members`);
774
+ for (const m of members) {
775
+ try {
776
+ const { keyPackages } = await this.api("POST", `/v1/apps/${this.appId}/users/${m.userId}/key-packages/claim`);
777
+ for (const kp of keyPackages) {
778
+ if (kp.deviceId === this.deviceId)
779
+ continue; // self added below with the known local key
780
+ const parsed = JSON.parse(new TextDecoder().decode(b64.dec(kp.data)));
781
+ recipients.push({ deviceId: kp.deviceId, publicKeyJwk: parsed.pub });
782
+ }
783
+ }
784
+ catch {
785
+ /* a member with no claimable packages converges via sync/backup later */
786
+ }
787
+ }
788
+ recipients.push({ deviceId: this.deviceId, publicKeyJwk: this.keys.publicKeyJwk });
789
+ // ring first (bounded), send key last — recipients adopt the last welcome
790
+ const ring = [...(this.channelKeyRings.get(channelId)?.values() ?? [])].slice(0, 8);
791
+ for (const rk of ring)
792
+ await this.postWelcome(mlsGroupId, channelId, await wrapChannelKey(rk, recipients));
793
+ const send = this.channelKeys.get(channelId);
794
+ if (send)
795
+ await this.postWelcome(mlsGroupId, channelId, await wrapChannelKey(send, recipients));
796
+ }
555
797
  /** In-flight key recoveries, deduped per channel so a burst of undecryptable
556
798
  * frames triggers a single welcome sync rather than one fetch per message. */
557
799
  keyRecovery = new Map();
@@ -634,7 +876,7 @@ export class NoPeek extends Emitter {
634
876
  let rawDK = await exportRawKeyB64(dataKey);
635
877
  let reusedDK = false;
636
878
  const carried = {};
637
- let remoteChannelKeys = null;
879
+ let remotePayload = null;
638
880
  try {
639
881
  const { backups } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`);
640
882
  const newest = backups[0];
@@ -680,8 +922,7 @@ export class NoPeek extends Emitter {
680
922
  const res = await fetch(newest.downloadUrl);
681
923
  if (res.ok) {
682
924
  const ct = b64.enc(new Uint8Array(await res.arrayBuffer()));
683
- const payload = await decryptPayload(await importRawKeyB64(candidate), ct);
684
- remoteChannelKeys = payload.channelKeys ?? null;
925
+ remotePayload = await decryptPayload(await importRawKeyB64(candidate), ct);
685
926
  oldDK = candidate; // decrypt success ⇒ the cached dk is the live one
686
927
  }
687
928
  }
@@ -715,26 +956,25 @@ export class NoPeek extends Emitter {
715
956
  }
716
957
  // Merge: keys from the existing backup that this device lacks stay in the
717
958
  // new payload (and heal this device's local key store while we're at it).
718
- if (remoteChannelKeys) {
719
- let healed = false;
720
- for (const [chId, raw] of Object.entries(remoteChannelKeys)) {
721
- if (this.channelKeys.has(chId))
722
- continue;
723
- try {
724
- this.channelKeys.set(chId, await importChannelKey(raw));
725
- healed = true;
726
- }
727
- catch {
728
- /* skip a corrupt entry */
729
- }
730
- }
731
- if (healed)
959
+ // A remote key that DIFFERS from ours goes to the history ring — a refresh
960
+ // must never erase a key some other device is still decrypting with. The
961
+ // remote envelope's device identity (another lineage's) and its legacy
962
+ // identities are preserved as OUR legacy identities for the same reason.
963
+ if (remotePayload) {
964
+ await this.registerLegacyIdentity(remotePayload.device);
965
+ const healed = await this.foldBackupKeys(remotePayload);
966
+ if (healed.length)
732
967
  await this.persistChannelKeys();
733
968
  }
734
969
  const channelKeys = {};
735
970
  for (const [chId, k] of this.channelKeys)
736
971
  channelKeys[chId] = await exportChannelKey(k);
737
- const ciphertext = await encryptPayload(dataKey, { v: 2, device: { deviceId: this.deviceId, keys: this.keys }, channelKeys });
972
+ const channelKeyRings = {};
973
+ for (const [chId, ring] of this.channelKeyRings)
974
+ if (ring.size)
975
+ channelKeyRings[chId] = [...ring.keys()];
976
+ const legacyDevices = [...this.legacyIdentities].map(([deviceId, keys]) => ({ deviceId, keys }));
977
+ const ciphertext = await encryptPayload(dataKey, { v: 2, device: { deviceId: this.deviceId, keys: this.keys }, legacyDevices, channelKeys, channelKeyRings });
738
978
  const iterations = 210_000;
739
979
  const wraps = { ...carried };
740
980
  for (const [kind, secret] of [["password", s.password], ["recovery", s.recoveryCode]]) {
@@ -778,6 +1018,43 @@ export class NoPeek extends Emitter {
778
1018
  }
779
1019
  return rec.backupId;
780
1020
  }
1021
+ /** Fold a decrypted backup payload's keys into this device's key store:
1022
+ * channels we have NO key for adopt the backup's key; a key that differs
1023
+ * from what we hold (either direction) lands in the history ring — nothing
1024
+ * is ever overwritten-and-lost. Legacy device identities carried in the
1025
+ * payload are registered too (welcome unwrap for old lineages). Returns the
1026
+ * channels that gained a key. Callers persist channel keys. */
1027
+ async foldBackupKeys(payload) {
1028
+ for (const d of payload.legacyDevices ?? [])
1029
+ await this.registerLegacyIdentity(d);
1030
+ const touched = new Set();
1031
+ for (const [chId, raw] of Object.entries(payload.channelKeys ?? {})) {
1032
+ try {
1033
+ if (!this.channelKeys.has(chId)) {
1034
+ this.channelKeys.set(chId, await importChannelKey(raw));
1035
+ touched.add(chId);
1036
+ }
1037
+ else if (await this.ringAdd(chId, await importChannelKey(raw), raw)) {
1038
+ touched.add(chId);
1039
+ }
1040
+ }
1041
+ catch {
1042
+ /* skip a corrupt entry */
1043
+ }
1044
+ }
1045
+ for (const [chId, raws] of Object.entries(payload.channelKeyRings ?? {})) {
1046
+ for (const raw of raws) {
1047
+ try {
1048
+ if (await this.ringAdd(chId, await importChannelKey(raw), raw))
1049
+ touched.add(chId);
1050
+ }
1051
+ catch {
1052
+ /* skip a corrupt entry */
1053
+ }
1054
+ }
1055
+ }
1056
+ return [...touched];
1057
+ }
781
1058
  /** Where the current backup envelope's raw data key is cached on this device.
782
1059
  * Sensitivity-equivalent to the channel keys (`nopeek:<user>:channelKeys`)
783
1060
  * already persisted in the same store — it decrypts a payload containing
@@ -816,18 +1093,7 @@ export class NoPeek extends Emitter {
816
1093
  const ct = b64.enc(new Uint8Array(await res.arrayBuffer()));
817
1094
  // Throws if the envelope was re-keyed by another device (stale cached dk).
818
1095
  const payload = await decryptPayload(await importRawKeyB64(rawDK), ct);
819
- const added = [];
820
- for (const [chId, raw] of Object.entries(payload.channelKeys ?? {})) {
821
- if (this.channelKeys.has(chId))
822
- continue;
823
- try {
824
- this.channelKeys.set(chId, await importChannelKey(raw));
825
- added.push(chId);
826
- }
827
- catch {
828
- /* skip a corrupt entry */
829
- }
830
- }
1096
+ const added = await this.foldBackupKeys(payload);
831
1097
  if (added.length) {
832
1098
  await this.persistChannelKeys();
833
1099
  for (const chId of added)
@@ -835,27 +1101,14 @@ export class NoPeek extends Emitter {
835
1101
  }
836
1102
  return added.length;
837
1103
  }
838
- /**
839
- * Restore chats on a NEW device from the latest encrypted backup. Pass the
840
- * account password (preferred) and/or the recovery code — whichever unlocks
841
- * the backup is used. Rehydrates channel keys so server-stored history
842
- * decrypts. Returns the number of channels restored.
843
- */
844
- async restore(secrets) {
845
- const s = typeof secrets === "string" ? { recoveryCode: secrets } : secrets;
846
- const { backups } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`);
847
- if (!backups.length)
848
- throw new Error("No backup found for this user.");
849
- const b = backups[0]; // newest first
1104
+ /** Unlock ONE backup record with the given secrets; throws if none open it. */
1105
+ async unlockBackup(b, s) {
850
1106
  const kd = b.keyDerivation || {};
851
1107
  const res = await fetch(b.downloadUrl);
852
1108
  if (!res.ok)
853
1109
  throw new Error(`backup download failed: HTTP ${res.status}`);
854
1110
  const ct = b64.enc(new Uint8Array(await res.arrayBuffer()));
855
- let payload;
856
1111
  if (kd.scheme === "wrapped-v2") {
857
- let dataKey = null;
858
- let rawDK = null;
859
1112
  for (const [kind, secret] of [["password", s.password], ["recovery", s.recoveryCode]]) {
860
1113
  const w = secret && kd.wraps?.[kind];
861
1114
  if (!w)
@@ -863,47 +1116,75 @@ export class NoPeek extends Emitter {
863
1116
  try {
864
1117
  const kek = await deriveBackupKey(secret, w.salt, w.iterations);
865
1118
  const { dk } = await decryptPayload(kek, w.wrapped);
866
- dataKey = await importRawKeyB64(dk);
867
- rawDK = dk;
868
- break;
1119
+ return { payload: await decryptPayload(await importRawKeyB64(dk), ct), rawDK: dk };
869
1120
  }
870
1121
  catch {
871
1122
  /* wrong secret for this wrap — try the next */
872
1123
  }
873
1124
  }
874
- if (!dataKey)
875
- throw new Error("Couldn't unlock the backup with the provided password or recovery code.");
876
- payload = await decryptPayload(dataKey, ct);
877
- // Cache the data key: this restored device can then refresh the backup and
878
- // pull later-added keys (refreshKeysFromBackup) with no secret in memory.
879
- if (rawDK) {
880
- try {
881
- await this.store.set(this.backupDkStoreKey(), rawDK);
882
- }
883
- catch {
884
- /* store hiccup — restore itself succeeded */
885
- }
1125
+ throw new Error("Couldn't unlock the backup with the provided password or recovery code.");
1126
+ }
1127
+ // legacy single-layer backup: payload keyed directly off the recovery code
1128
+ const code = s.recoveryCode ?? s.password;
1129
+ if (!code)
1130
+ throw new Error("This backup requires a recovery code.");
1131
+ const key = await deriveBackupKey(code, kd.salt, kd.iterations);
1132
+ try {
1133
+ return { payload: await decryptPayload(key, ct), rawDK: null };
1134
+ }
1135
+ catch {
1136
+ throw new Error("Recovery code is incorrect — could not decrypt the backup.");
1137
+ }
1138
+ }
1139
+ /**
1140
+ * Restore chats on a NEW device from the user's encrypted backups. Pass the
1141
+ * account password (preferred) and/or the recovery code — whichever unlocks
1142
+ * a backup is used. Tries the secrets against EVERY listed backup (newest →
1143
+ * oldest), not just the newest: an account can have several device lineages
1144
+ * (e.g. an old phone's envelope plus a later web login's), and a key that
1145
+ * only ever reached one lineage's envelope must still be recoverable. The
1146
+ * newest unlockable envelope's device identity is adopted; every OTHER
1147
+ * embedded identity is kept as a legacy identity so welcomes addressed to it
1148
+ * still unwrap. Returns the number of channels restored from the newest
1149
+ * envelope (legacy-envelope keys fold in on top).
1150
+ */
1151
+ async restore(secrets) {
1152
+ const s = typeof secrets === "string" ? { recoveryCode: secrets } : secrets;
1153
+ const { backups } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`);
1154
+ if (!backups.length)
1155
+ throw new Error("No backup found for this user.");
1156
+ const unlocked = [];
1157
+ let newestErr = null;
1158
+ for (const b of backups) {
1159
+ try {
1160
+ unlocked.push(await this.unlockBackup(b, s));
1161
+ }
1162
+ catch (e) {
1163
+ if (newestErr === null)
1164
+ newestErr = e; // newest-first: keep its error semantics
886
1165
  }
887
1166
  }
888
- else {
889
- // legacy single-layer backup: payload keyed directly off the recovery code
890
- const code = s.recoveryCode ?? s.password;
891
- if (!code)
892
- throw new Error("This backup requires a recovery code.");
893
- const key = await deriveBackupKey(code, kd.salt, kd.iterations);
1167
+ if (!unlocked.length)
1168
+ throw newestErr ?? new Error("Couldn't unlock the backup with the provided password or recovery code.");
1169
+ const newest = unlocked[0];
1170
+ // Cache the newest envelope's data key: this restored device can then
1171
+ // refresh the backup and pull later-added keys (refreshKeysFromBackup)
1172
+ // with no secret in memory.
1173
+ if (newest.rawDK) {
894
1174
  try {
895
- payload = await decryptPayload(key, ct);
1175
+ await this.store.set(this.backupDkStoreKey(), newest.rawDK);
896
1176
  }
897
1177
  catch {
898
- throw new Error("Recovery code is incorrect could not decrypt the backup.");
1178
+ /* store hiccuprestore itself succeeded */
899
1179
  }
900
1180
  }
901
- await this.adoptBackedUpDevice(payload.device);
902
- for (const [chId, raw] of Object.entries(payload.channelKeys)) {
903
- this.channelKeys.set(chId, await importChannelKey(raw));
1181
+ await this.adoptBackedUpDevice(newest.payload.device);
1182
+ for (const u of unlocked) {
1183
+ await this.registerLegacyIdentity(u.payload.device); // no-op for the adopted identity
1184
+ await this.foldBackupKeys(u.payload); // also folds the payload's legacyDevices
904
1185
  }
905
1186
  await this.persistChannelKeys();
906
- return Object.keys(payload.channelKeys).length;
1187
+ return Object.keys(newest.payload.channelKeys ?? {}).length;
907
1188
  }
908
1189
  // ------------------------------------------------- server-side escrow ----
909
1190
  /** Cached escrow availability for THIS caller ({enabled, escrowPublicKey}). */
@@ -961,11 +1242,10 @@ export class NoPeek extends Emitter {
961
1242
  /* store hiccup — restore itself succeeded */
962
1243
  }
963
1244
  await this.adoptBackedUpDevice(payload.device);
964
- for (const [chId, raw] of Object.entries(payload.channelKeys)) {
965
- this.channelKeys.set(chId, await importChannelKey(raw));
966
- }
1245
+ await this.registerLegacyIdentity(payload.device); // no-op when adopted
1246
+ await this.foldBackupKeys(payload);
967
1247
  await this.persistChannelKeys();
968
- return Object.keys(payload.channelKeys).length;
1248
+ return Object.keys(payload.channelKeys ?? {}).length;
969
1249
  }
970
1250
  /**
971
1251
  * Adopt the device identity stored in a backup so a returning user on a fresh
@@ -1140,17 +1420,20 @@ export class NoPeek extends Emitter {
1140
1420
  if (env.ciphertext) {
1141
1421
  if (!this.channelKeys.has(channelId))
1142
1422
  await this.deriveAndSet(channelId);
1143
- const key = this.channelKeys.get(channelId);
1144
- if (!key) {
1145
- base.decryptionFailed = true;
1146
- return base;
1147
- }
1148
- try {
1149
- base.body = await decryptPayload(key, String(env.ciphertext));
1150
- }
1151
- catch {
1152
- base.decryptionFailed = true;
1423
+ // Send key first, then the history ring — messages encrypted under a
1424
+ // superseded key (rotation, divergence heal) stay readable forever.
1425
+ const primary = this.channelKeys.get(channelId);
1426
+ const candidates = [...(primary ? [primary] : []), ...(this.channelKeyRings.get(channelId)?.values() ?? [])];
1427
+ for (const key of candidates) {
1428
+ try {
1429
+ base.body = await decryptPayload(key, String(env.ciphertext));
1430
+ return base;
1431
+ }
1432
+ catch {
1433
+ /* wrong key for this message — try the next candidate */
1434
+ }
1153
1435
  }
1436
+ base.decryptionFailed = true;
1154
1437
  }
1155
1438
  return base;
1156
1439
  }
@@ -1201,7 +1484,10 @@ export class Channel {
1201
1484
  /** E2EE key ceremony (creator side). */
1202
1485
  async establishKeys(memberIds) {
1203
1486
  const key = await generateChannelKey();
1204
- this.np.setChannelKey(this.channelId, key);
1487
+ // NOTE: the minted key is NOT adopted yet — only the winner of the epoch
1488
+ // 0→1 commit below may use it. Adopting first was the root cause of the
1489
+ // "restored device sends with a key nobody else holds" bug: a device that
1490
+ // LOST the race kept the orphan key locally and encrypted into the void.
1205
1491
  // Wrap for EVERY member device — peers, the creator's OTHER devices, and
1206
1492
  // (crucially) this device itself. The self-wrap makes the welcome a durable
1207
1493
  // second carrier of the key: a future device that restores this identity
@@ -1229,13 +1515,15 @@ export class Channel {
1229
1515
  }
1230
1516
  }
1231
1517
  recipients.push({ deviceId: this.np.deviceId, publicKeyJwk: this.np.devicePublicKeyJwk });
1232
- // advance epoch 0 -> 1 (server-enforced single writer)
1518
+ // advance epoch 0 -> 1 (server-enforced single writer). Losing this race
1519
+ // throws STALE_EPOCH — and the minted key is discarded with the throw.
1233
1520
  await this.np.api("POST", `/v1/apps/${this.np.appId}/mls/groups/${this.record.mlsGroupId}/handshakes`, {
1234
1521
  epoch: 0,
1235
1522
  kind: "commit",
1236
1523
  senderDeviceId: this.np.deviceId,
1237
1524
  payload: b64.enc(new TextEncoder().encode(JSON.stringify({ suite: "npdev-1", op: "init" }))),
1238
1525
  });
1526
+ this.np.setChannelKey(this.channelId, key); // commit won — the key is canonical
1239
1527
  if (recipients.length) {
1240
1528
  const wrapped = await wrapChannelKey(key, recipients);
1241
1529
  const welcome = { suite: "npdev-1", channelId: this.channelId, wrapped };
@@ -1253,9 +1541,11 @@ export class Channel {
1253
1541
  if (!this.e2ee)
1254
1542
  return true;
1255
1543
  if (this.np.getChannelKey(this.channelId)) {
1256
- // We hold the key — make sure a welcome addressed to THIS device exists in
1257
- // the group so a backup-restored successor of this identity can pull it.
1258
- void this.np.ensureSelfWelcome(this.channelId, this.record.mlsGroupId);
1544
+ // We hold a key — reconcile it against the group's welcome history ONCE
1545
+ // (posting the missing self-welcome, or ADOPTING the group's newer key if
1546
+ // this device had diverged) BEFORE it is ever used to send. Awaited so a
1547
+ // diverged device can never fire off even one message under a dead key.
1548
+ await this.np.reconcileChannelKey(this.channelId, this.record.mlsGroupId);
1259
1549
  return true;
1260
1550
  }
1261
1551
  if (await this.np.deriveAndSet(this.channelId))
@@ -1264,10 +1554,25 @@ export class Channel {
1264
1554
  return false;
1265
1555
  if (await this.np.syncChannelKey(this.channelId, this.record.mlsGroupId))
1266
1556
  return true;
1557
+ // Still keyless — a key-holding device may have refreshed the backup since
1558
+ // we restored. Recovering the EXISTING key always beats minting a new one.
1559
+ try {
1560
+ if (await this.np.hasCachedBackupKey()) {
1561
+ await this.np.refreshKeysFromBackup();
1562
+ if (this.np.getChannelKey(this.channelId))
1563
+ return true;
1564
+ }
1565
+ }
1566
+ catch {
1567
+ /* stale cached dk or no backup — fall through */
1568
+ }
1267
1569
  // No key and no welcome yet — e.g. the channel was created server-side, or the
1268
1570
  // original creator's key was lost. Bootstrap the ceremony as the originator:
1269
1571
  // the server enforces a single epoch-0→1 writer, so if a peer races us we lose
1270
- // the commit and fall back to pulling their welcome.
1572
+ // the commit (STALE_EPOCH) and fall back to pulling their welcome — and since
1573
+ // establishKeys only adopts its key AFTER winning, losing leaves no orphan
1574
+ // key behind. If no key is reachable at all, sending fails closed rather
1575
+ // than encrypting under a key no other device could ever obtain.
1271
1576
  try {
1272
1577
  const { members } = await this.np.api("GET", `/v1/apps/${this.np.appId}/channels/${this.channelId}/members`);
1273
1578
  await this.establishKeys(members.map((m) => m.userId));