@plane31/blind-relay 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -167,6 +167,22 @@ export interface Client {
167
167
  completeReplacement(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }>;
168
168
  /** The key changes this device accepted for its contacts, newest first, bounded. */
169
169
  keyChanges(options?: { accountId?: Uint8Array; limit?: number }): Promise<KeyChangeEntry[]>;
170
+ /**
171
+ * Account recovery as one call over `prepareReplacement` and `completeReplacement`, with the
172
+ * protocol's retry rules: an earlier proposal is settled through the chain first; the same
173
+ * proposal is kept through an unknown outcome and a refused authorization; a new proposal is
174
+ * made only after a definitive conflict, and then only by the next call. The product supplies
175
+ * `authorize`, which obtains the host's token for a proposal after its own fresh sign-in, or
176
+ * answers `"reauthenticate"` when it cannot. Attempts are bounded; `pending` means call again.
177
+ */
178
+ replaceDevice(options: ReplaceDeviceOptions): Promise<ReplaceDeviceResult>;
179
+ /**
180
+ * Synchronise in rounds until nothing is left that this device can do now: the relay has no
181
+ * more records within the budget, bootstrap is complete, or progress needs someone else (an
182
+ * authorization, another person, metadata the host does not serve). Bounded by rounds and by
183
+ * time; the result says why it stopped and what remains.
184
+ */
185
+ syncUntilDrained(options?: DrainOptions): Promise<DrainReport>;
170
186
  /** A durable outbound record of a declared kind; the payload is the product's bytes, never read by the SDK. */
171
187
  enqueue(kind: RecordKind, payload: Uint8Array, options?: EnqueueOptions): Promise<EnqueueResult>;
172
188
  /** One synchronisation; `options.catchUp` bounds how much of a backlog this call reads (`SyncReport.inbox.hasMore` says when to call again). */
@@ -192,9 +208,70 @@ export interface Client {
192
208
  list(): Promise<readonly CircleSummary[]>;
193
209
  };
194
210
  resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }>;
211
+ /**
212
+ * Stop: no new operation is accepted (`CLOSED`), the operations in flight finish their current
213
+ * transaction and a running sync stops at its next phase boundary, then listeners are cleared
214
+ * so nothing is emitted afterwards. Closes nothing it did not open: the store and the secret
215
+ * store are the caller's. Idempotent.
216
+ */
195
217
  close(): Promise<void>;
196
218
  }
197
219
 
220
+ export interface ReplaceDeviceOptions {
221
+ /** Obtain the host's authorization for this proposal after the product's fresh sign-in, or say that a sign-in is needed first. */
222
+ readonly authorize: (proposal: ReplacementProposal) => Promise<Uint8Array | "reauthenticate">;
223
+ /** Commit attempts per call, over unknown outcomes and refused authorizations. Default 3. */
224
+ readonly maxAttempts?: number;
225
+ }
226
+
227
+ export type ReplaceDeviceResult =
228
+ | { readonly status: "activated"; readonly identityId: Uint8Array; readonly generation: bigint }
229
+ /** The outcome is still unknown or the attempts are spent: the proposal is kept; call again. */
230
+ | { readonly status: "pending"; readonly proposal: ReplacementProposal }
231
+ /** The account moved under the proposal, which is discarded; the next call prepares a new one after a fresh sign-in. */
232
+ | { readonly status: "conflict"; readonly currentGeneration: bigint | undefined }
233
+ /** The product's `authorize` asked for a fresh sign-in; the proposal is kept for the next call. */
234
+ | { readonly status: "reauthenticate"; readonly proposal: ReplacementProposal };
235
+
236
+ export interface DrainOptions {
237
+ readonly catchUp?: SyncOptions["catchUp"];
238
+ /** Rounds at most. Default 10. */
239
+ readonly maxRounds?: number;
240
+ /** A soft bound in milliseconds across every round: no round starts past it and each round's reading budget is the time left, but a transport call already in flight ends on its own timeout. Default 30,000. */
241
+ readonly maxMs?: number;
242
+ }
243
+
244
+ export type DrainStop =
245
+ /** Nothing is left that this device can do now. */
246
+ | "drained"
247
+ | "maxRounds"
248
+ | "maxMs"
249
+ /** A round changed nothing and reported the same errors as the one before. */
250
+ | "noProgress"
251
+ /** Progress needs metadata the host does not serve yet (a chain, a certificate, the key list). */
252
+ | "metadata"
253
+ /** Progress needs an authorization or a sign-in (a prepared replacement, an account to recover). */
254
+ | "authentication"
255
+ /** Nothing more is reported, but a round could not read its inbox fully (discovery or a stream failed), so emptiness is not established. */
256
+ | "incomplete"
257
+ | "closed";
258
+
259
+ /** The merged report of every round, with why the loop stopped and what remains. */
260
+ export interface DrainReport extends SyncReport {
261
+ readonly rounds: number;
262
+ readonly stoppedBecause: DrainStop;
263
+ readonly remaining: {
264
+ /** The relay still holds records this device has not read; `"unknown"` when the last round could not read its inbox fully. */
265
+ readonly inboxHasMore: boolean | "unknown";
266
+ /** Bootstrap after account recovery has circles left to adopt. */
267
+ readonly bootstrapIncomplete: boolean;
268
+ /** Contacts whose succession chain this device has verified only partly; the next rounds continue them. */
269
+ readonly chainsIncomplete: number;
270
+ /** Authors whose current epoch has not reached this device: nothing to do but wait for them. */
271
+ readonly waitingFor: number;
272
+ };
273
+ }
274
+
198
275
  export interface SyncOptions {
199
276
  /**
200
277
  * The inbox budget of this sync, shared by every stream: at most this many records
@@ -292,6 +369,9 @@ class ClientImpl implements Client {
292
369
  private selfPin: AccountPin | undefined;
293
370
  private trust: ResetTrust;
294
371
  private syncing: Promise<SyncReport> | undefined;
372
+ private closed = false;
373
+ /** Every public operation in flight, so `close()` can wait for them. */
374
+ private readonly running = new Set<Promise<unknown>>();
295
375
  /** The current sync's reading budget: records, and the links of a succession chain, count against it. */
296
376
  private syncBudget: ReadBudget | undefined;
297
377
  private backupChecked = false;
@@ -309,17 +389,56 @@ class ClientImpl implements Client {
309
389
  this.now = options.now ?? ((): bigint => BigInt(Date.now()));
310
390
  this.trust = new ResetTrust(options.resetTrust);
311
391
  this.circles = {
312
- create: () => this.createCircle(),
313
- requestJoin: (c, o, opts) => this.requestJoin(c, o, opts?.shareOnApproval ?? true),
314
- approve: (c, n) => this.approve(c, n),
315
- remove: (c, m) => this.changeMembership(c, { remove: m }),
316
- leave: (c) => this.leave(c),
317
- setEnabled: (c, e) => this.setEnabled(c, e),
318
- closeCircle: (c) => this.closeCircle(c),
392
+ create: () => this.track("createCircle", () => this.createCircle()),
393
+ requestJoin: (c, o, opts) => this.track("requestJoin", () => this.requestJoin(c, o, opts?.shareOnApproval ?? true)),
394
+ approve: (c, n) => this.track("approve", () => this.approve(c, n)),
395
+ remove: (c, m) => this.track("changeMembership", () => this.changeMembership(c, { remove: m })),
396
+ leave: (c) => this.track("leave", () => this.leave(c)),
397
+ setEnabled: (c, e) => this.track("setEnabled", () => this.setEnabled(c, e)),
398
+ closeCircle: (c) => this.track("closeCircle", () => this.closeCircle(c)),
319
399
  list: () => this.listCircles()
320
400
  };
321
401
  }
322
402
 
403
+ // Every public operation that writes enters through `track`, explicitly and typed, so `close()`
404
+ // refuses new ones and waits for those in flight. Reads stay untracked.
405
+
406
+ initializeAccount(): Promise<{ identityId: Uint8Array }> {
407
+ return this.track("initializeAccount", () => this.initializeAccountNow());
408
+ }
409
+
410
+ restoreAccount(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }> {
411
+ return this.track("restoreAccount", () => this.restoreAccountNow(recoveryCode));
412
+ }
413
+
414
+ enqueue(kind: RecordKind, payload: Uint8Array, options: EnqueueOptions = {}): Promise<EnqueueResult> {
415
+ return this.track("enqueue", () => this.enqueueNow(kind, payload, options));
416
+ }
417
+
418
+ receive(wire: Uint8Array): Promise<ReceiveResult> {
419
+ return this.track("receive", () => receiveRecord(this.inboxDeps(), wire));
420
+ }
421
+
422
+ prepareReplacement(): Promise<ReplacementProposal> {
423
+ return this.track("prepareReplacement", () => this.prepareReplacementNow());
424
+ }
425
+
426
+ completeReplacement(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }> {
427
+ return this.track("completeReplacement", () => this.completeReplacementNow(input));
428
+ }
429
+
430
+ resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
431
+ return this.track("resetAccount", () => this.resetAccountNow(options));
432
+ }
433
+
434
+ replaceDevice(options: ReplaceDeviceOptions): Promise<ReplaceDeviceResult> {
435
+ return this.track("replaceDevice", () => this.replaceDeviceNow(options));
436
+ }
437
+
438
+ syncUntilDrained(options: DrainOptions = {}): Promise<DrainReport> {
439
+ return this.track("syncUntilDrained", () => this.drainNow(options));
440
+ }
441
+
323
442
  // Loading ---------------------------------------------------------------------------------------
324
443
 
325
444
  async load(): Promise<void> {
@@ -354,8 +473,18 @@ class ClientImpl implements Client {
354
473
  });
355
474
  }
356
475
 
357
- /** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. */
476
+ /** Refuse after `close()`, and keep the operation's promise until it settles. */
477
+ private track<T>(stage: string, fn: () => Promise<T>): Promise<T> {
478
+ if (this.closed) return Promise.reject(new KavachError("CLOSED", stage, "the client is closed"));
479
+ const p = fn();
480
+ this.running.add(p);
481
+ void p.finally(() => this.running.delete(p)).catch(() => undefined);
482
+ return p;
483
+ }
484
+
485
+ /** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. Nothing after `close()`. */
358
486
  private emit(event: ClientEvent): void {
487
+ if (this.closed) return;
359
488
  for (const l of this.listeners) {
360
489
  try {
361
490
  l(event);
@@ -489,7 +618,7 @@ class ClientImpl implements Client {
489
618
 
490
619
  // Enrollment ------------------------------------------------------------------------------------
491
620
 
492
- async initializeAccount(): Promise<{ identityId: Uint8Array }> {
621
+ private async initializeAccountNow(): Promise<{ identityId: Uint8Array }> {
493
622
  const stage = "initializeAccount";
494
623
  const opened = await openStore(this.store, this.secrets, this.accountName);
495
624
  if (opened.kind !== "fresh" && opened.kind !== "initializing") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
@@ -551,7 +680,7 @@ class ClientImpl implements Client {
551
680
  return encodeRecoveryCode(key);
552
681
  }
553
682
 
554
- async restoreAccount(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }> {
683
+ private async restoreAccountNow(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }> {
555
684
  const stage = "restoreAccount";
556
685
  const opened = await openStore(this.store, this.secrets, this.accountName);
557
686
  if (opened.kind === "ACTIVE" || opened.kind === "RETIRED") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
@@ -720,7 +849,7 @@ class ClientImpl implements Client {
720
849
 
721
850
  // Sending ---------------------------------------------------------------------------------------
722
851
 
723
- async enqueue(kind: RecordKind, payload: Uint8Array, options: EnqueueOptions = {}): Promise<EnqueueResult> {
852
+ private async enqueueNow(kind: RecordKind, payload: Uint8Array, options: EnqueueOptions): Promise<EnqueueResult> {
724
853
  const deps = this.outboxDeps();
725
854
  // A key answered before anything that may need the network: the record it names is already
726
855
  // durable, so a retry offline, or with a rotation due, returns it. The outbox checks the key
@@ -839,12 +968,107 @@ class ClientImpl implements Client {
839
968
 
840
969
  async sync(options: SyncOptions = {}): Promise<SyncReport> {
841
970
  if (this.syncing) return this.syncing;
842
- this.syncing = this.runSync(options).finally(() => {
971
+ if (this.closed) throw new KavachError("CLOSED", "sync", "the client is closed");
972
+ this.syncing = this.track("sync", () => this.runSync(options)).finally(() => {
843
973
  this.syncing = undefined;
844
974
  });
845
975
  return this.syncing;
846
976
  }
847
977
 
978
+ private async drainNow(options: DrainOptions): Promise<DrainReport> {
979
+ const maxRounds = Math.max(1, Math.floor(options.maxRounds ?? 10));
980
+ const maxMs = Math.max(0, Math.floor(options.maxMs ?? 30_000));
981
+ const deadline = this.now() + BigInt(maxMs);
982
+ const merged: SyncReport = {
983
+ metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
984
+ epoch: "unchanged",
985
+ outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
986
+ inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
987
+ purged: 0,
988
+ errors: []
989
+ };
990
+ let rounds = 0;
991
+ let previousErrors: string | undefined;
992
+ let stop: DrainStop = "drained";
993
+ let last: SyncReport | undefined;
994
+ // An inbox this device could not fully read this round: discovery or a stream failed, or metadata it
995
+ // depends on did. Such a round never establishes that the inbox is empty.
996
+ const inboxUncertain = (r: SyncReport): boolean => r.errors.some((e) => e.operation === "listSubscriptions" || e.operation === "inbox" || e.operation === "metadata");
997
+ // Chains verified only partly: remaining work, and their cursors moving is progress.
998
+ const chains = (): Promise<Map<string, string>> => this.store.read((tx) => new Map(tx.list("chainProgress").filter(([, c]) => !c.complete).map(([k, c]) => [k, c.nextGeneration])));
999
+ let previousChains = await chains();
1000
+ for (;;) {
1001
+ if (this.closed) {
1002
+ stop = "closed";
1003
+ break;
1004
+ }
1005
+ // The time left is this round's reading budget too, so a round never starts work the deadline
1006
+ // does not cover; a transport call already in flight ends on its own timeout.
1007
+ const remainingMs = Number(deadline - this.now());
1008
+ if (rounds > 0 && remainingMs <= 0) {
1009
+ stop = "maxMs";
1010
+ break;
1011
+ }
1012
+ const roundMs = Math.max(0, Math.min(remainingMs, options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs));
1013
+ const r = await this.sync({ catchUp: { maxMs: roundMs, ...(options.catchUp?.maxRecords !== undefined ? { maxRecords: options.catchUp.maxRecords } : {}) } });
1014
+ rounds++;
1015
+ last = r;
1016
+ // The round's work counts whether or not it was the last: what it accepted, stored and failed
1017
+ // is merged first, and only then is a close that landed meanwhile reported.
1018
+ for (const k of Object.keys(merged.metadata) as (keyof SyncReport["metadata"])[]) merged.metadata[k] += r.metadata[k];
1019
+ for (const k of Object.keys(merged.outbox) as (keyof SyncReport["outbox"])[]) merged.outbox[k] += r.outbox[k];
1020
+ merged.inbox.streams += r.inbox.streams;
1021
+ merged.inbox.stored += r.inbox.stored;
1022
+ merged.inbox.keyPending += r.inbox.keyPending;
1023
+ merged.inbox.invalid += r.inbox.invalid;
1024
+ merged.inbox.acked += r.inbox.acked;
1025
+ merged.purged += r.purged;
1026
+ merged.epoch = r.epoch;
1027
+ merged.errors.push(...r.errors);
1028
+ if (r.bootstrap) merged.bootstrap = r.bootstrap;
1029
+ if (this.closed) {
1030
+ // The round may have stopped at a boundary before its inbox: nothing it reports is complete.
1031
+ stop = "closed";
1032
+ break;
1033
+ }
1034
+ const bootstrapIncomplete = (await this.inspect()).bootstrap?.complete === false;
1035
+ const currentChains = await chains();
1036
+ const more = r.inbox.hasMore || bootstrapIncomplete || currentChains.size > 0;
1037
+ const status = (await this.getState()).status;
1038
+ if (status === "replacementPrepared" || status === "accountRecoveryRequired") {
1039
+ stop = "authentication";
1040
+ break;
1041
+ }
1042
+ if (!more) {
1043
+ stop = inboxUncertain(r) ? "incomplete" : "drained";
1044
+ break;
1045
+ }
1046
+ const signature = r.errors.map((e) => `${e.operation}:${e.code}`).sort().join(",");
1047
+ const chainsAdvanced = [...currentChains].some(([k, next]) => previousChains.get(k) !== next) || [...previousChains.keys()].some((k) => !currentChains.has(k));
1048
+ previousChains = currentChains;
1049
+ const changed = chainsAdvanced || r.inbox.stored + r.metadata.certificates + r.metadata.snapshots + r.metadata.successions + (r.bootstrap?.circles ?? 0) + (r.bootstrap?.pending ?? 0) > 0;
1050
+ if (!changed && signature !== "" && signature === previousErrors) {
1051
+ stop = r.errors.some((e) => e.code === "METADATA_REQUIRED" || e.code === "RESET_KEYS_REQUIRED" || e.code === "RESET_CLOCK_UNTRUSTED") ? "metadata" : "noProgress";
1052
+ break;
1053
+ }
1054
+ previousErrors = signature;
1055
+ if (rounds >= maxRounds) {
1056
+ stop = "maxRounds";
1057
+ break;
1058
+ }
1059
+ if (this.now() >= deadline) {
1060
+ stop = "maxMs";
1061
+ break;
1062
+ }
1063
+ }
1064
+ merged.inbox.hasMore = last?.inbox.hasMore ?? false;
1065
+ const bootstrapIncomplete = this.closed ? false : (await this.inspect()).bootstrap?.complete === false;
1066
+ const chainsIncomplete = this.closed ? previousChains.size : (await chains()).size;
1067
+ const waitingFor = this.closed ? 0 : (await this.getState()).waitingFor.length;
1068
+ const inboxHasMore = !last || inboxUncertain(last) || stop === "closed" ? "unknown" : merged.inbox.hasMore;
1069
+ return { ...merged, rounds, stoppedBecause: stop, remaining: { inboxHasMore, bootstrapIncomplete, chainsIncomplete, waitingFor } };
1070
+ }
1071
+
848
1072
  private async runSync(options: SyncOptions): Promise<SyncReport> {
849
1073
  const report: SyncReport = {
850
1074
  metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
@@ -914,17 +1138,19 @@ class ClientImpl implements Client {
914
1138
  report.errors.push(syncError("preference", e));
915
1139
  }
916
1140
  const after = await this.store.read((tx) => tx.get("meta", META));
917
- if (after?.state === "RETIRED") return report;
1141
+ if (after?.state === "RETIRED" || this.closed) return report;
918
1142
  try {
919
1143
  report.epoch = await this.ensureEpoch();
920
1144
  } catch (e) {
921
1145
  report.errors.push(syncError("epoch", e));
922
1146
  }
1147
+ if (this.closed) return report;
923
1148
  try {
924
1149
  report.outbox = await drain(this.outboxDeps());
925
1150
  } catch (e) {
926
1151
  report.errors.push(syncError("outbox", e));
927
1152
  }
1153
+ if (this.closed) return report;
928
1154
  let subs: Awaited<ReturnType<Relay["listSubscriptions"]>> = [];
929
1155
  try {
930
1156
  subs = await this.transport.relay.listSubscriptions();
@@ -937,6 +1163,7 @@ class ClientImpl implements Client {
937
1163
  const budget: ReadBudget = this.syncBudget;
938
1164
  if (budget.remainingRecords < 1) budget.remainingRecords = 1; // every stream reads at least one page
939
1165
  for (const s of subs) {
1166
+ if (this.closed) return report;
940
1167
  try {
941
1168
  const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
942
1169
  if (!known) continue;
@@ -1480,10 +1707,6 @@ class ClientImpl implements Client {
1480
1707
 
1481
1708
  // Receiving and reads ---------------------------------------------------------------------------
1482
1709
 
1483
- receive(wire: Uint8Array): Promise<ReceiveResult> {
1484
- return receiveRecord(this.inboxDeps(), wire);
1485
- }
1486
-
1487
1710
  history(query?: HistoryQuery): Promise<HistoryEntry[]> {
1488
1711
  return history(this.store, this.policy, query);
1489
1712
  }
@@ -1848,7 +2071,7 @@ class ClientImpl implements Client {
1848
2071
 
1849
2072
  // Account recovery (amendment A1) ---------------------------------------------------------------
1850
2073
 
1851
- async prepareReplacement(): Promise<ReplacementProposal> {
2074
+ private async prepareReplacementNow(): Promise<ReplacementProposal> {
1852
2075
  const stage = "prepareReplacement";
1853
2076
  if (!acceptsSuccession(this.policy)) throw new KavachError("NOT_AUTHORIZED", stage, "this deployment does not use account recovery");
1854
2077
  const opened = await openStore(this.store, this.secrets, this.accountName);
@@ -1893,7 +2116,7 @@ class ClientImpl implements Client {
1893
2116
  return proposalOf(row);
1894
2117
  }
1895
2118
 
1896
- async completeReplacement(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }> {
2119
+ private async completeReplacementNow(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }> {
1897
2120
  const stage = "completeReplacement";
1898
2121
  // Already activated (by an earlier call, or by a sync that found the commit on the chain): idempotent.
1899
2122
  if (this.selfPin && this.account && !this.retired) return { identityId: this.selfPin.identityId, generation: this.selfPin.generation };
@@ -2143,7 +2366,7 @@ class ClientImpl implements Client {
2143
2366
 
2144
2367
  // Reset -----------------------------------------------------------------------------------------
2145
2368
 
2146
- async resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
2369
+ private async resetAccountNow(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
2147
2370
  const stage = "resetAccount";
2148
2371
  if (!options.confirmed) throw new KavachError("NOT_AUTHORIZED", stage, "reset requires confirmation");
2149
2372
  const { pin } = this.requireAccount(stage);
@@ -2227,7 +2450,50 @@ class ClientImpl implements Client {
2227
2450
  }
2228
2451
 
2229
2452
  async close(): Promise<void> {
2453
+ this.closed = true;
2454
+ // Everything in flight finishes its current transaction; a sync stops at its next boundary.
2455
+ while (this.running.size > 0) await Promise.allSettled([...this.running]);
2230
2456
  this.listeners.clear();
2231
2457
  }
2458
+
2459
+ // The replacement helper ------------------------------------------------------------------------
2460
+
2461
+ private async replaceDeviceNow(options: ReplaceDeviceOptions): Promise<ReplaceDeviceResult> {
2462
+ const maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 3));
2463
+ const activated = (): ReplaceDeviceResult | undefined => (this.selfPin && this.account && !this.retired ? { status: "activated", identityId: this.selfPin.identityId, generation: this.selfPin.generation } : undefined);
2464
+ const done = activated();
2465
+ if (done) return done;
2466
+ // A proposal left by an earlier run may have committed: the chain settles it before anything else.
2467
+ const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
2468
+ if (existing && !existing.stale) {
2469
+ await this.sync();
2470
+ const settled = activated();
2471
+ if (settled) return settled;
2472
+ }
2473
+ const proposal = await this.prepareReplacementNow();
2474
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
2475
+ const authorization = await options.authorize(proposal);
2476
+ if (authorization === "reauthenticate") return { status: "reauthenticate", proposal };
2477
+ for (;;) {
2478
+ try {
2479
+ const r = await this.completeReplacementNow({ successionId: proposal.successionId, authorization });
2480
+ return { status: "activated", identityId: r.identityId, generation: r.generation };
2481
+ } catch (e) {
2482
+ if (isKavachError(e, "SUCCESSION_PENDING")) {
2483
+ // The same call again with the same bytes; the host answers a committed id first.
2484
+ if (++attempt >= maxAttempts) return { status: "pending", proposal };
2485
+ continue;
2486
+ }
2487
+ if (isKavachError(e, "AUTHORIZATION_REQUIRED")) break; // a fresh token for the same proposal
2488
+ if (isKavachError(e, "REPLACEMENT_CONFLICT")) {
2489
+ const conflict = (await this.inspect()).replacement;
2490
+ return { status: "conflict", currentGeneration: conflict?.conflictGeneration };
2491
+ }
2492
+ throw e;
2493
+ }
2494
+ }
2495
+ }
2496
+ return { status: "pending", proposal };
2497
+ }
2232
2498
  }
2233
2499
 
@@ -4,7 +4,24 @@ import { hsalsa, xsalsa20poly1305 } from "@noble/ciphers/salsa";
4
4
  import { ed25519, x25519 } from "@noble/curves/ed25519";
5
5
  import { blake2b } from "@noble/hashes/blake2b";
6
6
  import { sha512 } from "@noble/hashes/sha512";
7
- import { randomBytes } from "@noble/hashes/utils";
7
+ import { KavachError } from "../errors.js";
8
+
9
+ /**
10
+ * Randomness from the Web Crypto global, read at every call. `@noble/hashes` captures
11
+ * `globalThis.crypto` once when it is imported, which on Hermes is before the app has installed
12
+ * a source (the Expo helper installs expo-crypto's inside `openKavach`), so noble's own
13
+ * `randomBytes` would throw for the life of the process. Reading the global here, when the bytes
14
+ * are needed, sees whatever is installed by then.
15
+ */
16
+ function randomBytes(length: number): Uint8Array {
17
+ const g = globalThis as { crypto?: { getRandomValues?: (array: Uint8Array) => Uint8Array } };
18
+ const get = g.crypto?.getRandomValues;
19
+ if (typeof get !== "function") throw new KavachError("INVALID_KEY", "randomBytes", "no crypto.getRandomValues: install a secure random source before opening Kavach");
20
+ const out = new Uint8Array(length);
21
+ // Web Crypto caps one call at 65,536 bytes; nothing here is near it, but the rule is kept.
22
+ for (let offset = 0; offset < length; offset += 65_536) get.call(g.crypto, out.subarray(offset, Math.min(length, offset + 65_536)));
23
+ return out;
24
+ }
8
25
  import type { BoxKeypair, CryptoBackend, SignKeypair } from "./backend.js";
9
26
 
10
27
  // "expand 32-byte k" as little-endian u32 words: the Salsa constant.
package/src/errors.ts CHANGED
@@ -49,6 +49,7 @@ export type ErrorCode =
49
49
  | "STALE_APPEND"
50
50
  // Storage and transport
51
51
  | "LOCKED"
52
+ | "CLOSED"
52
53
  | "RECOVERY_REQUIRED"
53
54
  | "STORAGE_FAILURE"
54
55
  /** The host could not be reached or did not answer in time; the same call may succeed later. A transport raises it, never a plain error. */
package/src/index.ts CHANGED
@@ -14,6 +14,9 @@ export {
14
14
  open,
15
15
  type CircleSummary,
16
16
  type Client,
17
+ type DrainOptions,
18
+ type DrainReport,
19
+ type DrainStop,
17
20
  type ClientEvent,
18
21
  type ClientState,
19
22
  type ClientStatus,
@@ -28,6 +31,8 @@ export {
28
31
  type OpenOptions,
29
32
  type PauseReason,
30
33
  type ReceiveResult,
34
+ type ReplaceDeviceOptions,
35
+ type ReplaceDeviceResult,
31
36
  type ReplacementProposal,
32
37
  type SyncError,
33
38
  type SyncOptions,
package/src/store.ts CHANGED
@@ -19,6 +19,9 @@ export interface SecretStore {
19
19
 
20
20
  export type SecretName = "accountSeed" | "deviceSeed" | "recoveryKey" | "storageKey" | "instanceId" | "pendingReset" | "pendingReplacement";
21
21
 
22
+ /** Every secret the SDK keeps for an account, in the order an erasure deletes them: the account's roots first, the storage key last, so an interrupted erasure leaves a store that refuses to open rather than one that re-enrolls under old roots. */
23
+ export const SECRET_NAMES: readonly SecretName[] = ["accountSeed", "deviceSeed", "recoveryKey", "instanceId", "pendingReset", "pendingReplacement", "storageKey"];
24
+
22
25
  // Logical tables. Rows are JSON-safe: bytes as hex strings, u64 as decimal strings.
23
26
 
24
27
  export type StoreState = "INITIALIZING" | "ACTIVE" | "ACTIVATION_PENDING" | "RETIRED";
@@ -111,8 +111,10 @@ export interface MaliciousKnobs {
111
111
  readonly refuse: Set<MutationName>;
112
112
  /** Mutations whose next response waits for the given promise after the host committed (consumed once). */
113
113
  readonly hold: Map<MutationName, Promise<void>>;
114
+ /** Called when a held mutation has reached its gate (the host has answered; the response is being held), so a test can act at exactly that point. */
115
+ onHeld?: (name: MutationName) => void;
114
116
  /** Reads that answer "nothing" while present. */
115
- readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates" | "successions" | "circleList">;
117
+ readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates" | "successions" | "circleList" | "subscriptions">;
116
118
  /** Older documents served instead of the current ones. */
117
119
  readonly replay: {
118
120
  vault?: Uint8Array;
@@ -188,6 +190,7 @@ export class MaliciousTransport implements Transport {
188
190
  const gate = this.knobs.hold.get(name);
189
191
  if (gate) {
190
192
  this.knobs.hold.delete(name);
193
+ this.knobs.onHeld?.(name);
191
194
  await gate;
192
195
  }
193
196
  if (this.knobs.loseResponseOf.has(name)) {
@@ -328,6 +331,7 @@ export class MaliciousTransport implements Transport {
328
331
  ackThrough: (streamId, cursor) => this.mutate("ackThrough", () => r().ackThrough(streamId, cursor)),
329
332
  listSubscriptions: async () => {
330
333
  this.calls.push("listSubscriptions");
334
+ if (k.withhold.has("subscriptions")) throw new KavachError("NETWORK", "transport", "network unreachable");
331
335
  return r().listSubscriptions();
332
336
  }
333
337
  };