@plane31/blind-relay 0.1.3 → 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.
Files changed (86) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/index.d.ts +4 -3
  3. package/dist/adapters/index.d.ts.map +1 -1
  4. package/dist/adapters/index.js +2 -1
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/audience.d.ts +1 -1
  7. package/dist/audience.d.ts.map +1 -1
  8. package/dist/audience.js +7 -6
  9. package/dist/audience.js.map +1 -1
  10. package/dist/client.d.ts +159 -3
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +1112 -102
  13. package/dist/client.js.map +1 -1
  14. package/dist/codec.d.ts +25 -1
  15. package/dist/codec.d.ts.map +1 -1
  16. package/dist/codec.js +44 -0
  17. package/dist/codec.js.map +1 -1
  18. package/dist/crypto/noble.d.ts.map +1 -1
  19. package/dist/crypto/noble.js +19 -1
  20. package/dist/crypto/noble.js.map +1 -1
  21. package/dist/crypto.d.ts +1 -1
  22. package/dist/crypto.d.ts.map +1 -1
  23. package/dist/crypto.js.map +1 -1
  24. package/dist/errors.d.ts +9 -1
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +2 -1
  27. package/dist/errors.js.map +1 -1
  28. package/dist/identity.d.ts +8 -2
  29. package/dist/identity.d.ts.map +1 -1
  30. package/dist/identity.js +7 -3
  31. package/dist/identity.js.map +1 -1
  32. package/dist/index.d.ts +4 -4
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/policy.d.ts +13 -0
  37. package/dist/policy.d.ts.map +1 -1
  38. package/dist/policy.js +12 -1
  39. package/dist/policy.js.map +1 -1
  40. package/dist/rows.d.ts.map +1 -1
  41. package/dist/rows.js +2 -0
  42. package/dist/rows.js.map +1 -1
  43. package/dist/store.d.ts +116 -3
  44. package/dist/store.d.ts.map +1 -1
  45. package/dist/store.js +2 -0
  46. package/dist/store.js.map +1 -1
  47. package/dist/succession.d.ts +108 -0
  48. package/dist/succession.d.ts.map +1 -0
  49. package/dist/succession.js +205 -0
  50. package/dist/succession.js.map +1 -0
  51. package/dist/testing/clock.d.ts.map +1 -1
  52. package/dist/testing/clock.js +1 -0
  53. package/dist/testing/clock.js.map +1 -1
  54. package/dist/testing/fakeHost.d.ts +60 -1
  55. package/dist/testing/fakeHost.d.ts.map +1 -1
  56. package/dist/testing/fakeHost.js +201 -31
  57. package/dist/testing/fakeHost.js.map +1 -1
  58. package/dist/testing/faults.d.ts +7 -3
  59. package/dist/testing/faults.d.ts.map +1 -1
  60. package/dist/testing/faults.js +27 -3
  61. package/dist/testing/faults.js.map +1 -1
  62. package/dist/transactions.d.ts +6 -0
  63. package/dist/transactions.d.ts.map +1 -1
  64. package/dist/transactions.js +11 -5
  65. package/dist/transactions.js.map +1 -1
  66. package/dist/transport.d.ts +66 -2
  67. package/dist/transport.d.ts.map +1 -1
  68. package/package.json +1 -1
  69. package/src/adapters/index.ts +14 -0
  70. package/src/audience.ts +7 -6
  71. package/src/client.ts +1165 -108
  72. package/src/codec.ts +64 -1
  73. package/src/crypto/noble.ts +18 -1
  74. package/src/crypto.ts +1 -0
  75. package/src/errors.ts +13 -1
  76. package/src/identity.ts +13 -3
  77. package/src/index.ts +10 -3
  78. package/src/policy.ts +23 -1
  79. package/src/rows.ts +2 -0
  80. package/src/store.ts +113 -3
  81. package/src/succession.ts +270 -0
  82. package/src/testing/clock.ts +1 -0
  83. package/src/testing/fakeHost.ts +204 -28
  84. package/src/testing/faults.ts +32 -7
  85. package/src/transactions.ts +13 -6
  86. package/src/transport.ts +69 -2
package/src/client.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import { concat, equalBytes, fromHex, toHex } from "./bytes.js";
7
7
  import { resolveAudience, type ResolvedAudience } from "./audience.js";
8
8
  import { acceptSnapshot, checkMemberResolution, createCircleSnapshot, newCircleId, pinCircle, successorSnapshot, type CircleState } from "./circle.js";
9
- import { DocumentType, expectType, parseVaultHeader, verifyDocument, type AccountDevice, type RecordKind } from "./codec.js";
9
+ import { DocumentType, buildCircleSnapshot, expectType, parseDocument, parseVaultHeader, verifyDocument, type AccountDevice, type AccountSuccession, type RecordKind } from "./codec.js";
10
10
  import { randomBytes, ready, type BoxKeypair } from "./crypto.js";
11
11
  import { prepareEpoch, resolvePublish, type PreparedEpoch } from "./epoch.js";
12
12
  import { KavachError, isKavachError, type ErrorCode } from "./errors.js";
@@ -21,12 +21,13 @@ export interface EnqueueResult {
21
21
  readonly recordId: Uint8Array;
22
22
  readonly duplicate: boolean;
23
23
  }
24
- import { kindOf, validatePolicy, type RetentionPolicy } from "./policy.js";
24
+ import { acceptsSuccession, allowsContinuity, kindOf, validatePolicy, type ReplacementModel, type RetentionPolicy } from "./policy.js";
25
25
  import { ResetTrust, type ResetTrustConfig } from "./reset.js";
26
26
  import { big, circleToRow, pinToRow, rowToCircle, rowToPin, rowToPrepared, unbig } from "./rows.js";
27
- import type { CircleRow, PendingResetRow, SecretStore, Store, Tx, VaultRow } from "./store.js";
27
+ import type { BootstrapRetryRow, CircleRow, MetaRow, PendingResetRow, ReplacementRow, SecretStore, Store, Tx, VaultRow } from "./store.js";
28
28
  import { META, REVISION, STREAM, VAULT, openStore, t1Activate, t1Header, t1Secrets, t2PrepareActivation, t3ApplyChange, t3Circle, t4PersistPrepared, t5Committed, t8OwnerSnapshot, t9MarkVault, t9Vault } from "./transactions.js";
29
- import type { Relay, Transport } from "./transport.js";
29
+ import { applySuccession, authenticateSuccession, pinnedState, recordChainIn, rootIdentityIn, rootState, sameTransition, transitionOf, validateTransition, verifyChain, type SuccessionEffect, type VerifiedLink } from "./succession.js";
30
+ import type { CircleListing, Directory, Relay, ResolvedAccount, SuccessionLink, Transport } from "./transport.js";
30
31
  import { buildVault, encodeRecoveryCode, newRecoveryKey, openVault, recoverAccount, removeEntry, upsertEntry, type ProviderStatusResult, type RecoveryProvider, type VaultEntry } from "./vault.js";
31
32
 
32
33
  /** The types of the client's queries and of `receive`, defined with the inbox. */
@@ -51,13 +52,28 @@ function sameEntries(a: readonly VaultEntry[], b: readonly VaultEntry[]): boolea
51
52
  return key(a) === key(b);
52
53
  }
53
54
 
54
- export type ClientStatus = "fresh" | "ready" | "locked" | "accountRecoveryRequired" | "activationPending" | "retired" | "paused" | "quarantined";
55
+ export type ClientStatus = "fresh" | "replacementPrepared" | "ready" | "locked" | "accountRecoveryRequired" | "activationPending" | "retired" | "paused" | "quarantined";
55
56
  export type PauseReason = "sync" | "rotation" | "metadata" | "limit";
56
57
 
58
+ function proposalOf(row: ReplacementRow): ReplacementProposal {
59
+ return { successionId: fromHex(row.successionId), accountName: row.accountName, accountId: fromHex(row.accountId), expectedCertificateHash: fromHex(row.expectedCertificateHash), expectedGeneration: unbig(row.expectedGeneration), certificate: fromHex(row.certificate) };
60
+ }
61
+
62
+ /** The account id a directory answer names: the root of its succession chain, else the identity of its key. */
63
+ export function resolvedAccountId(r: ResolvedAccount): Uint8Array {
64
+ const first = r.successions?.[0];
65
+ if (first) return expectType<AccountSuccession>(parseDocument(first.wire), DocumentType.AccountSuccession, "resolve").accountId;
66
+ const doc = expectType<AccountDevice>(verifyDocument(r.certificate), DocumentType.AccountDevice, "resolve");
67
+ return pinFromCertificate(doc).identityId;
68
+ }
69
+
57
70
  export interface ClientState {
58
71
  readonly status: ClientStatus;
59
72
  readonly paused: PauseReason | undefined;
73
+ /** The account id: what circles, manifests and records name. Constant across successions. */
60
74
  readonly identityId: Uint8Array | undefined;
75
+ /** The identity of the current account key; equal to `identityId` until a succession. */
76
+ readonly keyIdentityId: Uint8Array | undefined;
61
77
  readonly certificateHash: Uint8Array | undefined;
62
78
  readonly generation: bigint | undefined;
63
79
  readonly outbox: number;
@@ -69,6 +85,13 @@ export interface ClientState {
69
85
  /** Diagnostics: where the protocol stands on this device. Never a secret or a plaintext. */
70
86
  export interface Inspection {
71
87
  readonly identityId: Uint8Array | undefined;
88
+ readonly keyIdentityId: Uint8Array | undefined;
89
+ /** The replacement models the profile enables; never a runtime choice. */
90
+ readonly replacementModel: ReplacementModel;
91
+ /** A prepared account-recovery proposal on this device, until it commits or is definitively conflicted. */
92
+ readonly replacement: { readonly successionId: Uint8Array; readonly expectedGeneration: bigint; readonly stale: boolean; readonly conflictGeneration: bigint | undefined } | undefined;
93
+ /** Discovery after account recovery: where it stands. */
94
+ readonly bootstrap: { readonly cursor: string | undefined; readonly complete: boolean } | undefined;
72
95
  readonly certificateHash: Uint8Array | undefined;
73
96
  readonly generation: bigint | undefined;
74
97
  readonly desiredRevision: bigint;
@@ -110,7 +133,8 @@ export type ClientEvent =
110
133
  export interface OpenOptions {
111
134
  readonly accountName: string;
112
135
  readonly retentionPolicy: unknown;
113
- readonly recoveryProvider: RecoveryProvider;
136
+ /** The platform's synced secret store for the recovery key. Required when the profile enables identity continuity; a recovery-only profile may omit it, and then has no vault backup and no recovery code. */
137
+ readonly recoveryProvider?: RecoveryProvider;
114
138
  readonly resetTrust: ResetTrustConfig;
115
139
  readonly secretStore: SecretStore;
116
140
  readonly store: Store;
@@ -128,6 +152,37 @@ export interface Client {
128
152
  recoveryStatus(): Promise<ProviderStatusResult>;
129
153
  recoveryCode(): Promise<string>;
130
154
  restoreAccount(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }>;
155
+ /**
156
+ * Account recovery (amendment A1), on a fresh device with no secret from the old one: prepare
157
+ * a replacement under fresh keys and return the proposal the product takes to the host's fresh
158
+ * authentication. A second call returns the same pending proposal. The remote account is untouched.
159
+ */
160
+ prepareReplacement(): Promise<ReplacementProposal>;
161
+ /**
162
+ * Commit the prepared replacement with the host's authorization. `SUCCESSION_PENDING` means the
163
+ * outcome is unknown and the same call is to be made again; `REPLACEMENT_CONFLICT` means the
164
+ * account moved and the next `prepareReplacement()` starts over; `AUTHORIZATION_REQUIRED` means
165
+ * the host refused the authorization.
166
+ */
167
+ completeReplacement(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }>;
168
+ /** The key changes this device accepted for its contacts, newest first, bounded. */
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>;
131
186
  /** A durable outbound record of a declared kind; the payload is the product's bytes, never read by the SDK. */
132
187
  enqueue(kind: RecordKind, payload: Uint8Array, options?: EnqueueOptions): Promise<EnqueueResult>;
133
188
  /** One synchronisation; `options.catchUp` bounds how much of a backlog this call reads (`SyncReport.inbox.hasMore` says when to call again). */
@@ -153,9 +208,70 @@ export interface Client {
153
208
  list(): Promise<readonly CircleSummary[]>;
154
209
  };
155
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
+ */
156
217
  close(): Promise<void>;
157
218
  }
158
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
+
159
275
  export interface SyncOptions {
160
276
  /**
161
277
  * The inbox budget of this sync, shared by every stream: at most this many records
@@ -169,15 +285,39 @@ export interface SyncOptions {
169
285
  export const DEFAULT_CATCH_UP = { maxRecords: 500, maxMs: 5_000 } as const;
170
286
 
171
287
  export interface SyncReport {
172
- metadata: { certificates: number; snapshots: number; rotations: number; quarantined: number; resets: number };
288
+ metadata: { certificates: number; snapshots: number; rotations: number; quarantined: number; resets: number; successions: number };
173
289
  epoch: "unchanged" | "prepared" | "paused" | "pending";
174
290
  outbox: { sent: number; accepted: number; cancelled: number; unknown: number; retry: number };
175
291
  /** `hasMore`: a stream still has records on the relay that this sync's budget did not reach; sync again soon. */
176
292
  inbox: { streams: number; stored: number; keyPending: number; invalid: number; acked: number; hasMore: boolean };
177
293
  purged: number;
294
+ /** Present while account recovery discovers circles through the host: pinned, pending and failed on this sync. */
295
+ bootstrap?: { circles: number; pending: number; failed: number };
178
296
  errors: SyncError[];
179
297
  }
180
298
 
299
+ /** What `prepareReplacement()` returns: everything the product hands to the host for authorization. All public. */
300
+ export interface ReplacementProposal {
301
+ readonly successionId: Uint8Array;
302
+ readonly accountName: string;
303
+ readonly accountId: Uint8Array;
304
+ readonly expectedCertificateHash: Uint8Array;
305
+ readonly expectedGeneration: bigint;
306
+ readonly certificate: Uint8Array;
307
+ }
308
+
309
+ /** One accepted key change of a contact: a verified succession or a validated reset. */
310
+ export interface KeyChangeEntry {
311
+ readonly accountId: Uint8Array;
312
+ readonly accountName: string;
313
+ readonly previousIdentityId: Uint8Array;
314
+ readonly newIdentityId: Uint8Array;
315
+ readonly generation: bigint;
316
+ readonly reason: "succession" | "reset";
317
+ readonly successionId: Uint8Array | undefined;
318
+ readonly acceptedAt: bigint;
319
+ }
320
+
181
321
  /** One failure a sync worked around: which step, the protocol code and stage, and whether the same step may succeed later unchanged. */
182
322
  export interface SyncError {
183
323
  readonly operation: string;
@@ -191,10 +331,12 @@ export interface SyncError {
191
331
  */
192
332
  readonly streamId?: Uint8Array;
193
333
  readonly authorIdentityId?: Uint8Array;
334
+ /** On a `bootstrap` entry: the circle that could not be adopted on this sync; it is retried by the next. */
335
+ readonly circleId?: Uint8Array;
194
336
  }
195
337
 
196
- function syncError(operation: string, e: unknown): SyncError {
197
- return e instanceof KavachError ? { operation, code: e.code, stage: e.stage, retryable: e.retryable } : { operation, code: "UNEXPECTED", stage: operation, retryable: false };
338
+ function syncError(operation: string, e: unknown, extra: Partial<Pick<SyncError, "circleId" | "streamId" | "authorIdentityId">> = {}): SyncError {
339
+ return e instanceof KavachError ? { operation, code: e.code, stage: e.stage, retryable: e.retryable, ...extra } : { operation, code: "UNEXPECTED", stage: operation, retryable: false, ...extra };
198
340
  }
199
341
 
200
342
  const RESET_LIST_RECHECK_MS = 86_400_000n;
@@ -202,6 +344,7 @@ const RESET_LIST_RECHECK_MS = 86_400_000n;
202
344
  export async function open(options: OpenOptions): Promise<Client> {
203
345
  await ready();
204
346
  const policy = validatePolicy(options.retentionPolicy);
347
+ if (!options.recoveryProvider && allowsContinuity(policy)) throw new KavachError("MALFORMED", "open", "recoveryProvider is required when the profile enables identity continuity");
205
348
  const impl = new ClientImpl(options, policy);
206
349
  await impl.load();
207
350
  return impl;
@@ -212,14 +355,25 @@ class ClientImpl implements Client {
212
355
  private readonly store: Store;
213
356
  private readonly secrets: SecretStore;
214
357
  private readonly transport: Transport;
215
- private readonly provider: RecoveryProvider;
358
+ private readonly provider: RecoveryProvider | undefined;
359
+ /** A vault exists only where a recovery provider does: a recovery-only profile without one has no path that reads it. */
360
+ private get vaultEnabled(): boolean {
361
+ return this.provider !== undefined;
362
+ }
216
363
  private readonly now: () => bigint;
217
364
  private readonly listeners = new Set<(e: ClientEvent) => void>();
218
365
  private account: OwnAccount | undefined;
366
+ /** Retired by a succession or a later device: nothing is written from here again. */
367
+ private retired = false;
219
368
  private device: DeviceKeys | undefined;
220
369
  private selfPin: AccountPin | undefined;
221
370
  private trust: ResetTrust;
222
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>>();
375
+ /** The current sync's reading budget: records, and the links of a succession chain, count against it. */
376
+ private syncBudget: ReadBudget | undefined;
223
377
  private backupChecked = false;
224
378
  readonly circles: Client["circles"];
225
379
 
@@ -235,31 +389,72 @@ class ClientImpl implements Client {
235
389
  this.now = options.now ?? ((): bigint => BigInt(Date.now()));
236
390
  this.trust = new ResetTrust(options.resetTrust);
237
391
  this.circles = {
238
- create: () => this.createCircle(),
239
- requestJoin: (c, o, opts) => this.requestJoin(c, o, opts?.shareOnApproval ?? true),
240
- approve: (c, n) => this.approve(c, n),
241
- remove: (c, m) => this.changeMembership(c, { remove: m }),
242
- leave: (c) => this.leave(c),
243
- setEnabled: (c, e) => this.setEnabled(c, e),
244
- 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)),
245
399
  list: () => this.listCircles()
246
400
  };
247
401
  }
248
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
+
249
442
  // Loading ---------------------------------------------------------------------------------------
250
443
 
251
444
  async load(): Promise<void> {
252
445
  await this.finishInterruptedReset();
253
446
  const opened = await openStore(this.store, this.secrets, this.accountName);
254
447
  if (opened.kind === "fresh") return;
448
+ this.retired = opened.kind === "RETIRED";
255
449
  await ensureHistoryIndex(this.store);
256
450
  await ensureSlots(this.store, this.policy);
257
451
  this.account = opened.account;
258
452
  const deviceSeed = await this.secrets.get("deviceSeed");
259
453
  const self = await this.store.read((tx) => tx.get("self", "self"));
260
- if (deviceSeed && self) {
454
+ if (deviceSeed && self && opened.account) {
455
+ const accountId = opened.account.accountId;
261
456
  this.device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
262
- const pinRow = await this.store.read((tx) => tx.get("pins", toHex(opened.account.identityId)));
457
+ const pinRow = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
263
458
  if (pinRow) this.selfPin = rowToPin(pinRow);
264
459
  }
265
460
  const rows = await this.store.read((tx) => ({
@@ -278,8 +473,18 @@ class ClientImpl implements Client {
278
473
  });
279
474
  }
280
475
 
281
- /** 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()`. */
282
486
  private emit(event: ClientEvent): void {
487
+ if (this.closed) return;
283
488
  for (const l of this.listeners) {
284
489
  try {
285
490
  l(event);
@@ -299,13 +504,14 @@ class ClientImpl implements Client {
299
504
  }
300
505
 
301
506
  private requireAccount(stage: string): { account: OwnAccount; device: DeviceKeys; pin: AccountPin } {
507
+ if (this.retired) throw new KavachError("SUPERSEDED_DEVICE", stage, "this device is retired");
302
508
  if (!this.account || !this.device || !this.selfPin) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no account on this device");
303
509
  return { account: this.account, device: this.device, pin: this.selfPin };
304
510
  }
305
511
 
306
512
  private selfView(): { identityId: Uint8Array; certificateHash: Uint8Array; box: BoxKeypair } {
307
513
  const { device, pin } = this.requireAccount("self");
308
- return { identityId: pin.identityId, certificateHash: pin.certificateHash, box: device.box };
514
+ return { identityId: pin.accountId, certificateHash: pin.certificateHash, box: device.box };
309
515
  }
310
516
 
311
517
  private outboxDeps() {
@@ -315,7 +521,7 @@ class ClientImpl implements Client {
315
521
  relay: this.transport.relay,
316
522
  policy: this.policy,
317
523
  now: this.now,
318
- author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.identityId }),
524
+ author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.accountId }),
319
525
  events: {
320
526
  queued: (recordId: Uint8Array, recordKind: RecordKind) => this.emit({ kind: "queued", recordId, recordKind }),
321
527
  accepted: (recordId: Uint8Array) => this.emit({ kind: "accepted", recordId }),
@@ -347,8 +553,11 @@ class ClientImpl implements Client {
347
553
 
348
554
  async getState(): Promise<ClientState> {
349
555
  const meta = await this.store.read((tx) => tx.get("meta", META));
350
- const base = { identityId: this.selfPin?.identityId, certificateHash: this.selfPin?.certificateHash, generation: this.selfPin?.generation, outbox: 0, waitingFor: [] as Uint8Array[], circles: [] as CircleSummary[] };
351
- if (!meta || !this.account) return { status: "fresh", paused: undefined, ...base };
556
+ const base = { identityId: this.selfPin?.accountId, keyIdentityId: this.selfPin?.identityId, certificateHash: this.selfPin?.certificateHash, generation: this.selfPin?.generation, outbox: 0, waitingFor: [] as Uint8Array[], circles: [] as CircleSummary[] };
557
+ if (!meta || (!this.account && meta.state !== "RETIRED")) {
558
+ const proposal = await this.store.read((tx) => tx.get("replacement", "pending"));
559
+ return { status: proposal && !proposal.stale ? "replacementPrepared" : "fresh", paused: undefined, ...base };
560
+ }
352
561
  const circles = await this.listCircles();
353
562
  const outbox = await outboxCount(this.store);
354
563
  const waitingFor = await this.waitingFor();
@@ -365,7 +574,7 @@ class ClientImpl implements Client {
365
574
 
366
575
  private async waitingFor(): Promise<Uint8Array[]> {
367
576
  if (!this.selfPin) return [];
368
- const selfHex = toHex(this.selfPin.identityId);
577
+ const selfHex = toHex(this.selfPin.accountId);
369
578
  return this.store.read((tx) => {
370
579
  const authors = new Set<string>();
371
580
  for (const [, c] of tx.list("circles")) if (c.status === "member") for (const m of c.members) if (m !== selfHex) authors.add(m);
@@ -386,9 +595,15 @@ class ClientImpl implements Client {
386
595
  const stream = tx.get("stream", STREAM);
387
596
  const revision = tx.get("revision", REVISION);
388
597
  const vault = tx.get("vault", VAULT);
598
+ const meta = tx.get("meta", META);
599
+ const proposal = tx.get("replacement", "pending");
389
600
  const pendingEpoch = tx.list("epochs").find(([, e]) => e.status === "prepared" || e.status === "committed")?.[1].epoch;
390
601
  return {
391
- identityId: this.selfPin?.identityId,
602
+ identityId: this.selfPin?.accountId,
603
+ keyIdentityId: this.selfPin?.identityId,
604
+ replacementModel: this.policy.replacement,
605
+ replacement: proposal ? { successionId: fromHex(proposal.successionId), expectedGeneration: unbig(proposal.expectedGeneration), stale: proposal.stale, conflictGeneration: proposal.conflictGeneration === undefined ? undefined : unbig(proposal.conflictGeneration) } : undefined,
606
+ bootstrap: meta?.bootstrap ? { cursor: meta.bootstrap.cursor, complete: meta.bootstrap.complete } : undefined,
392
607
  certificateHash: this.selfPin?.certificateHash,
393
608
  generation: this.selfPin?.generation,
394
609
  desiredRevision: revision ? unbig(revision.desiredRevision) : 1n,
@@ -403,7 +618,7 @@ class ClientImpl implements Client {
403
618
 
404
619
  // Enrollment ------------------------------------------------------------------------------------
405
620
 
406
- async initializeAccount(): Promise<{ identityId: Uint8Array }> {
621
+ private async initializeAccountNow(): Promise<{ identityId: Uint8Array }> {
407
622
  const stage = "initializeAccount";
408
623
  const opened = await openStore(this.store, this.secrets, this.accountName);
409
624
  if (opened.kind !== "fresh" && opened.kind !== "initializing") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
@@ -424,8 +639,8 @@ class ClientImpl implements Client {
424
639
  const pin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice));
425
640
  const iid = instanceId;
426
641
  await this.store.transaction((tx) => t1Header(tx, { account, device, certificate, instanceId: iid, generation: 1n }));
427
- const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey);
428
- const outcome = await this.transport.directory.registerAccount({ certificate, vault: vault.wire });
642
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
643
+ const outcome = await this.transport.directory.registerAccount({ certificate, vault: vault?.wire });
429
644
  if (outcome.status === "CONFLICT") {
430
645
  // A restart or a lost response after the host committed: the name is ours if the host holds
431
646
  // exactly the certificate this device saved; anything else is somebody else's account.
@@ -435,7 +650,7 @@ class ClientImpl implements Client {
435
650
  throw new KavachError("NOT_AUTHORIZED", stage, `host refused registration: ${outcome.status}`);
436
651
  }
437
652
  await this.store.transaction((tx) => {
438
- t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
653
+ if (vault) t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
439
654
  t1Activate(tx);
440
655
  });
441
656
  this.account = account;
@@ -443,7 +658,7 @@ class ClientImpl implements Client {
443
658
  this.selfPin = pin;
444
659
  // The recovery key goes to the provider; enrollment never blocks on it or on the code.
445
660
  try {
446
- await this.provider.save(this.accountName, pin.identityId, recoveryKey);
661
+ await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
447
662
  } catch {
448
663
  /* status is reported by recoveryStatus() */
449
664
  }
@@ -452,16 +667,20 @@ class ClientImpl implements Client {
452
667
  }
453
668
 
454
669
  async recoveryStatus(): Promise<ProviderStatusResult> {
670
+ if (!this.provider) return { status: "unsupported", backupState: "unavailable", reason: "no recovery provider on a recovery-only profile" };
455
671
  return this.provider.status();
456
672
  }
457
673
 
458
674
  async recoveryCode(): Promise<string> {
675
+ // A code opens a vault for identity continuity; where the profile disables that, or where no
676
+ // vault exists, the code would open nothing and is not issued.
677
+ if (!allowsContinuity(this.policy) || !this.vaultEnabled) throw new KavachError("NOT_AUTHORIZED", "recoveryCode", "this deployment does not restore accounts by recovery code");
459
678
  const key = await this.secrets.get("recoveryKey");
460
679
  if (!key) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", "recoveryCode");
461
680
  return encodeRecoveryCode(key);
462
681
  }
463
682
 
464
- async restoreAccount(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }> {
683
+ private async restoreAccountNow(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }> {
465
684
  const stage = "restoreAccount";
466
685
  const opened = await openStore(this.store, this.secrets, this.accountName);
467
686
  if (opened.kind === "ACTIVE" || opened.kind === "RETIRED") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
@@ -473,12 +692,18 @@ class ClientImpl implements Client {
473
692
  // moved under us): prepare again with the same device keys. Nothing was allocated remotely.
474
693
  pending = resumed;
475
694
  }
695
+ if (!allowsContinuity(this.policy)) throw new KavachError("NOT_AUTHORIZED", stage, "this deployment replaces devices through successions, not the recovery key");
476
696
  const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
477
697
  if (!resolved) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
478
698
  const current = expectType<AccountDevice>(verifyDocument(resolved.certificate), DocumentType.AccountDevice, stage);
479
699
  const vaultWire = await this.transport.directory.getVault(this.accountName);
480
700
  if (!vaultWire) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no vault");
701
+ const accountId = resolvedAccountId(resolved);
481
702
  const identity = pinFromCertificate(current).identityId;
703
+ if (!this.provider) throw new KavachError("NOT_AUTHORIZED", stage, "identity continuity needs a recovery provider");
704
+ // An account that succeeded: the served chain is verified link by link under the current list,
705
+ // from the root to the key this device is about to hold, before anything is written.
706
+ const verified = resolved.successions?.length ? (await this.followChain({ accountId, accountName: this.accountName, finalIdentityId: identity, firstPage: resolved.successions, next: resolved.successionsNext })).verified : [];
482
707
  const recovered = await recoverAccount({
483
708
  accountName: this.accountName,
484
709
  identityId: identity,
@@ -487,12 +712,12 @@ class ClientImpl implements Client {
487
712
  provider: this.provider,
488
713
  ...(recoveryCode === undefined ? {} : { recoveryCode })
489
714
  });
490
- const account = openAccount(this.accountName, recovered.vault.accountSeed);
715
+ const account = openAccount(this.accountName, recovered.vault.accountSeed, accountId);
491
716
  // Fresh device keys and a fresh local store. Nothing from the old device is copied.
492
717
  const device = pending?.device ?? newDevice();
493
718
  const generation = current.generation + 1n;
494
719
  const certificate = certify(account, device, generation);
495
- const pin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice));
720
+ const pin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice), undefined, accountId);
496
721
  const header = parseVaultHeader(vaultWire);
497
722
  const nextVault = buildVault(
498
723
  { accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: header.vaultRevision + 1n },
@@ -503,6 +728,8 @@ class ClientImpl implements Client {
503
728
  if (!pending) await t1Secrets(this.secrets, { accountSeed: recovered.vault.accountSeed, deviceSeed: device.deviceSeed, recoveryKey: recovered.recoveryKey, instanceId });
504
729
  await this.store.transaction((tx) => {
505
730
  t1Header(tx, { account, device, certificate, instanceId, generation });
731
+ recordChainIn(tx, verified, this.now());
732
+ rootIdentityIn(tx, accountId, this.accountName, resolved.rootCertificate);
506
733
  t2PrepareActivation(tx, {
507
734
  certificate: toHex(certificate),
508
735
  vault: toHex(nextVault.wire),
@@ -538,7 +765,7 @@ class ClientImpl implements Client {
538
765
  tx.delete("activation", "pending");
539
766
  t9Vault(tx, { revision: big(input.vaultRevision), hash: toHex(input.vault.hash), wire: toHex(input.vault.wire), pending: false, dirty: false });
540
767
  for (const e of input.entries) {
541
- const owned = equalBytes(e.ownerIdentityId, pin.identityId);
768
+ const owned = equalBytes(e.ownerIdentityId, pin.accountId);
542
769
  const c = pinCircle(e.circleId, e.ownerIdentityId, e.sharingEnabled, owned ? "member" : "requested");
543
770
  tx.put("circles", toHex(e.circleId), circleToRow(c));
544
771
  }
@@ -549,7 +776,7 @@ class ClientImpl implements Client {
549
776
  this.device = device;
550
777
  this.selfPin = pin;
551
778
  try {
552
- await this.provider.save(this.accountName, pin.identityId, input.recoveryKey);
779
+ await this.provider?.save(this.accountName, pin.identityId, input.recoveryKey);
553
780
  } catch {
554
781
  /* reported by recoveryStatus() */
555
782
  }
@@ -568,13 +795,14 @@ class ClientImpl implements Client {
568
795
  const row = await this.store.read((tx) => tx.get("activation", "pending"));
569
796
  if (!row) throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its prepared bytes");
570
797
  const [accountSeed, deviceSeed, recoveryKey, instanceId] = await Promise.all([this.secrets.get("accountSeed"), this.secrets.get("deviceSeed"), this.secrets.get("recoveryKey"), this.secrets.get("instanceId")]);
571
- const self = await this.store.read((tx) => tx.get("self", "self"));
572
- if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId || !self) throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its roots");
573
- const account = openAccount(this.accountName, accountSeed);
798
+ const [self, meta] = await this.store.read((tx) => [tx.get("self", "self"), tx.get("meta", META)] as const);
799
+ if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId || !self || !meta) throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its roots");
800
+ const accountId = fromHex(meta.accountId ?? meta.identityId);
801
+ const account = openAccount(this.accountName, accountSeed, accountId);
574
802
  const device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
575
803
  const certificate = fromHex(row.certificate);
576
804
  const prepared = expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage);
577
- const pin = pinFromCertificate(prepared);
805
+ const pin = pinFromCertificate(prepared, undefined, accountId);
578
806
  const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
579
807
  if (!me) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
580
808
  const theirs = expectType<AccountDevice>(verifyDocument(me.certificate), DocumentType.AccountDevice, stage);
@@ -603,7 +831,7 @@ class ClientImpl implements Client {
603
831
  * asks the provider and saves again when it has nothing for this identity.
604
832
  */
605
833
  private async ensureBackup(): Promise<void> {
606
- if (this.backupChecked || !this.selfPin) return;
834
+ if (this.backupChecked || !this.selfPin || !this.provider) return;
607
835
  this.backupChecked = true;
608
836
  try {
609
837
  const key = await this.secrets.get("recoveryKey");
@@ -621,7 +849,7 @@ class ClientImpl implements Client {
621
849
 
622
850
  // Sending ---------------------------------------------------------------------------------------
623
851
 
624
- async enqueue(kind: RecordKind, payload: Uint8Array, options: EnqueueOptions = {}): Promise<EnqueueResult> {
852
+ private async enqueueNow(kind: RecordKind, payload: Uint8Array, options: EnqueueOptions): Promise<EnqueueResult> {
625
853
  const deps = this.outboxDeps();
626
854
  // A key answered before anything that may need the network: the record it names is already
627
855
  // durable, so a retry offline, or with a rotation due, returns it. The outbox checks the key
@@ -646,7 +874,7 @@ class ClientImpl implements Client {
646
874
 
647
875
  /** After commit: the device's own record is stored like any author's, so one subscription covers self and others. */
648
876
  private enqueued(kind: RecordKind, r: { recordId: Uint8Array; duplicate: boolean; own: boolean }): EnqueueResult {
649
- if (!r.duplicate && this.selfPin) this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.identityId, recordId: r.recordId, tookSlot: r.own });
877
+ if (!r.duplicate && this.selfPin) this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.accountId, recordId: r.recordId, tookSlot: r.own });
650
878
  return { recordId: r.recordId, duplicate: r.duplicate };
651
879
  }
652
880
 
@@ -656,7 +884,11 @@ class ClientImpl implements Client {
656
884
  const { pin } = this.requireAccount("audience");
657
885
  const retired = new Set(this.trust.state.retired);
658
886
  return this.store.read((tx) => {
887
+ // A reset retires the whole account, however many successions it went through.
888
+ for (const [, r] of tx.list("identities")) if (r.resetAt !== undefined) retired.add(r.accountId);
659
889
  const pins = new Map(tx.list("pins").map(([k, p]) => [k, rowToPin(p)]));
890
+ // An account whose chain this device has not finished verifying is out of every audience until it has.
891
+ for (const [, p] of tx.list("chainProgress")) if (!p.complete) pins.delete(p.accountId);
660
892
  const circles = tx.list("circles").map(([, c]) => rowToCircle(c));
661
893
  return resolveAudience(pin, circles, pins, retired);
662
894
  });
@@ -692,7 +924,7 @@ class ClientImpl implements Client {
692
924
  } else {
693
925
  const revision = revisionRow ? unbig(revisionRow.desiredRevision) : 1n;
694
926
  const fresh = prepareEpoch({
695
- author: { identityId: pin.identityId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
927
+ author: { identityId: pin.accountId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
696
928
  epoch: stream.highestCommittedEpoch + 1,
697
929
  createdAt: this.now(),
698
930
  audience,
@@ -736,21 +968,133 @@ class ClientImpl implements Client {
736
968
 
737
969
  async sync(options: SyncOptions = {}): Promise<SyncReport> {
738
970
  if (this.syncing) return this.syncing;
739
- 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(() => {
740
973
  this.syncing = undefined;
741
974
  });
742
975
  return this.syncing;
743
976
  }
744
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
+
745
1072
  private async runSync(options: SyncOptions): Promise<SyncReport> {
746
1073
  const report: SyncReport = {
747
- metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0 },
1074
+ metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
748
1075
  epoch: "unchanged",
749
1076
  outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
750
1077
  inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
751
1078
  purged: 0,
752
1079
  errors: []
753
1080
  };
1081
+ this.syncBudget = {
1082
+ remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
1083
+ deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
1084
+ };
1085
+ if (!this.selfPin) {
1086
+ // A prepared replacement whose commit this device never saw: the chain says whether it landed.
1087
+ const row = await this.store.read((tx) => tx.get("replacement", "pending"));
1088
+ if (row && !row.stale) {
1089
+ try {
1090
+ const link = await this.findOwnLink(row);
1091
+ const roots = await this.secrets.get("pendingReplacement");
1092
+ if (link && roots && roots.length === RESET_ROOTS_BYTES) await this.activateReplacement(row, roots, link.wire);
1093
+ } catch (e) {
1094
+ report.errors.push(syncError("resumeReplacement", e));
1095
+ }
1096
+ }
1097
+ }
754
1098
  if (!this.account || !this.selfPin) return report;
755
1099
  const meta = await this.store.read((tx) => tx.get("meta", META));
756
1100
  if (!meta || meta.state === "RETIRED") return report;
@@ -776,23 +1120,37 @@ class ClientImpl implements Client {
776
1120
  }
777
1121
  } else if (meta.state !== "ACTIVE") return report;
778
1122
  await this.ensureBackup();
1123
+ if (meta.bootstrap && !meta.bootstrap.complete) {
1124
+ try {
1125
+ report.bootstrap = await this.bootstrap(report, meta.bootstrap);
1126
+ } catch (e) {
1127
+ report.errors.push(syncError("bootstrap", e));
1128
+ }
1129
+ }
779
1130
  try {
780
1131
  await this.syncMetadata(report);
781
1132
  } catch (e) {
782
1133
  report.errors.push(syncError("metadata", e));
783
1134
  }
1135
+ try {
1136
+ await this.pushPreferences(report);
1137
+ } catch (e) {
1138
+ report.errors.push(syncError("preference", e));
1139
+ }
784
1140
  const after = await this.store.read((tx) => tx.get("meta", META));
785
- if (after?.state === "RETIRED") return report;
1141
+ if (after?.state === "RETIRED" || this.closed) return report;
786
1142
  try {
787
1143
  report.epoch = await this.ensureEpoch();
788
1144
  } catch (e) {
789
1145
  report.errors.push(syncError("epoch", e));
790
1146
  }
1147
+ if (this.closed) return report;
791
1148
  try {
792
1149
  report.outbox = await drain(this.outboxDeps());
793
1150
  } catch (e) {
794
1151
  report.errors.push(syncError("outbox", e));
795
1152
  }
1153
+ if (this.closed) return report;
796
1154
  let subs: Awaited<ReturnType<Relay["listSubscriptions"]>> = [];
797
1155
  try {
798
1156
  subs = await this.transport.relay.listSubscriptions();
@@ -802,11 +1160,10 @@ class ClientImpl implements Client {
802
1160
  // Each stream on its own: a failed download or a refused record names its stream in the report
803
1161
  // and leaves the other streams to be read; a stream that failed is not counted as synced.
804
1162
  // One budget spans the streams; a stream it stops early reports hasMore.
805
- const budget: ReadBudget = {
806
- remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
807
- deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
808
- };
1163
+ const budget: ReadBudget = this.syncBudget;
1164
+ if (budget.remainingRecords < 1) budget.remainingRecords = 1; // every stream reads at least one page
809
1165
  for (const s of subs) {
1166
+ if (this.closed) return report;
810
1167
  try {
811
1168
  const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
812
1169
  if (!known) continue;
@@ -833,7 +1190,16 @@ class ClientImpl implements Client {
833
1190
  if (me) {
834
1191
  const theirs = expectType<AccountDevice>(verifyDocument(me.certificate), DocumentType.AccountDevice, "sync");
835
1192
  if (!equalBytes(theirs.accountKey, pin.accountKey)) {
836
- // Our own key changed at the host: only a receipt we produced explains that; otherwise ignore.
1193
+ // Our own key changed at the host. A succession from our identity, attested by an authority
1194
+ // we trust, retires this device (its key is no longer the account's); a bare key change or a
1195
+ // reset we did not make is ignored here, as before.
1196
+ if (await this.supersededBySuccession(pin, me, report)) {
1197
+ await this.retire();
1198
+ // The key itself is retired, not only the device: nothing of it stays here.
1199
+ await this.secrets.delete("accountSeed");
1200
+ this.account = undefined;
1201
+ return;
1202
+ }
837
1203
  } else if (!equalBytes(theirs.bodyHash, pin.certificateHash) && theirs.generation > pin.generation) {
838
1204
  await this.retire();
839
1205
  return;
@@ -856,7 +1222,7 @@ class ClientImpl implements Client {
856
1222
  // Unconfirmed writes of our own first: a successor snapshot the host never took, a vault update
857
1223
  // it never saw. Until they land, peers read stale membership and a replacement phone a stale
858
1224
  // preference.
859
- const pendingOwned = await this.store.read((tx) => tx.list("circles").filter(([, r]) => r.pending && r.ownerIdentityId === toHex(pin.identityId)).map(([, r]) => fromHex(r.circleId)));
1225
+ const pendingOwned = await this.store.read((tx) => tx.list("circles").filter(([, r]) => r.pending && r.ownerIdentityId === toHex(pin.accountId)).map(([, r]) => fromHex(r.circleId)));
860
1226
  for (const circleId of pendingOwned) {
861
1227
  try {
862
1228
  if ((await this.publishPendingSnapshot(circleId)) === "COMMITTED") report.metadata.snapshots++;
@@ -874,15 +1240,15 @@ class ClientImpl implements Client {
874
1240
  const unknownOwners: Uint8Array[] = [];
875
1241
  for (const c of circles) {
876
1242
  if (c.status === "closed" || c.status === "quarantined" || c.status === "left") continue;
877
- if (!equalBytes(c.ownerIdentityId, pin.identityId) && !(await this.pinFor(c.ownerIdentityId))) unknownOwners.push(c.ownerIdentityId);
1243
+ if (!equalBytes(c.ownerIdentityId, pin.accountId) && !(await this.pinFor(c.ownerIdentityId))) unknownOwners.push(c.ownerIdentityId);
878
1244
  }
879
1245
  if (unknownOwners.length > 0) {
880
- for (const r of await this.transport.directory.resolveIdentities(unknownOwners)) await this.acceptMemberCertificate(r.certificate, r.resetReceipt, report);
1246
+ for (const r of await this.transport.directory.resolveIdentities(unknownOwners)) await this.acceptMemberCertificate(r, report);
881
1247
  }
882
1248
  const memberIds = new Map<string, Uint8Array>();
883
1249
  for (const c of circles) {
884
1250
  if (c.status === "closed" || c.status === "quarantined" || c.status === "left") continue;
885
- const ownerPin = equalBytes(c.ownerIdentityId, pin.identityId) ? pin : await this.pinFor(c.ownerIdentityId);
1251
+ const ownerPin = equalBytes(c.ownerIdentityId, pin.accountId) ? pin : await this.pinFor(c.ownerIdentityId);
886
1252
  if (!ownerPin) {
887
1253
  memberIds.set(toHex(c.ownerIdentityId), c.ownerIdentityId);
888
1254
  continue;
@@ -890,7 +1256,7 @@ class ClientImpl implements Client {
890
1256
  const wire = await this.transport.directory.getCircle(c.circleId);
891
1257
  if (wire) {
892
1258
  try {
893
- const r = acceptSnapshot(c, wire, ownerPin.accountKey, pin.identityId, now);
1259
+ const r = await this.acceptSnapshotFrom(c, wire, ownerPin, now, report);
894
1260
  if (r.outcome === "ACCEPTED") {
895
1261
  // The row may have moved since it was read: a sharing preference, a leave or a close
896
1262
  // made meanwhile wins over the fetched snapshot, and a newer snapshot already stored
@@ -920,46 +1286,299 @@ class ClientImpl implements Client {
920
1286
  for (const m of c.members) memberIds.set(toHex(m), m);
921
1287
  }
922
1288
  } else for (const m of c.members) memberIds.set(toHex(m), m);
923
- if (equalBytes(c.ownerIdentityId, pin.identityId) && c.status === "member") {
1289
+ if (equalBytes(c.ownerIdentityId, pin.accountId) && c.status === "member") {
924
1290
  const requests = await this.transport.directory.readRequests(c.circleId);
925
1291
  const joins = requests.filter((r) => r.action === "join").map((r) => r.accountName);
926
1292
  if (joins.length) this.emit({ kind: "joinRequests", circleId: c.circleId, accountNames: joins });
927
1293
  for (const r of requests.filter((r) => r.action === "leave")) {
928
1294
  const [acct] = await this.transport.directory.resolveAccounts([r.accountName]);
929
1295
  if (!acct) continue;
930
- const leaver = pinFromCertificate(expectType<AccountDevice>(verifyDocument(acct.certificate), DocumentType.AccountDevice));
931
1296
  try {
932
- await this.changeMembership(c.circleId, { remove: leaver.identityId });
1297
+ await this.changeMembership(c.circleId, { remove: resolvedAccountId(acct) });
933
1298
  } catch (e) {
934
1299
  report.errors.push(syncError("removeLeaver", e));
935
1300
  }
936
1301
  }
937
1302
  }
938
1303
  }
939
- memberIds.delete(toHex(pin.identityId));
1304
+ memberIds.delete(toHex(pin.accountId));
940
1305
  if (memberIds.size > 0) {
941
1306
  const resolved = await this.transport.directory.resolveIdentities([...memberIds.values()]);
942
1307
  const byIdentity = new Map<string, { accountName: string; accountKey: Uint8Array }>();
943
1308
  for (const r of resolved) {
944
- const doc = expectType<AccountDevice>(verifyDocument(r.certificate), DocumentType.AccountDevice, "sync");
945
- const iid = toHex(pinFromCertificate(doc).identityId);
946
- byIdentity.set(iid, { accountName: r.accountName, accountKey: r.accountKey });
947
- await this.acceptMemberCertificate(r.certificate, r.resetReceipt, report);
1309
+ expectType<AccountDevice>(verifyDocument(r.certificate), DocumentType.AccountDevice, "sync");
1310
+ byIdentity.set(toHex(resolvedAccountId(r)), { accountName: r.accountName, accountKey: r.accountKey });
1311
+ await this.acceptMemberCertificate(r, report);
948
1312
  }
949
1313
  checkMemberResolution(byIdentity);
950
1314
  }
951
1315
  }
952
1316
 
953
- private async pinFor(identityId: Uint8Array): Promise<AccountPin | undefined> {
954
- const row = await this.store.read((tx) => tx.get("pins", toHex(identityId)));
1317
+ /**
1318
+ * Has the host's chain for our own account moved past our key? A valid succession from our
1319
+ * current identity means an authority replaced this account's key; the device holding the old
1320
+ * key has nothing left to say. Deferred (no fresh list, untrusted clock) and invalid
1321
+ * attestations leave the device as it is.
1322
+ */
1323
+ private async supersededBySuccession(pin: AccountPin, me: ResolvedAccount, report: SyncReport): Promise<boolean> {
1324
+ if (!me.successions?.length) return false;
1325
+ try {
1326
+ const r = await this.followChain({ accountId: pin.accountId, accountName: pin.accountName, current: pin, firstPage: me.successions, next: me.successionsNext, budget: this.syncBudget });
1327
+ return r.pin !== undefined && !equalBytes(r.pin.identityId, pin.identityId);
1328
+ } catch (e) {
1329
+ // A chain this device cannot follow for its own account is a diagnostic the product must
1330
+ // see: the host has already fenced the device, and only this report says why it still runs.
1331
+ report.errors.push(syncError("ownSuccession", e));
1332
+ return false;
1333
+ }
1334
+ }
1335
+
1336
+ /**
1337
+ * Verify a snapshot under the owner's current key, or, when the owner's key changed through a
1338
+ * succession and the snapshot predates it, under the retired key it was signed with. The
1339
+ * successor republishes at the next revision; until then the older snapshot stays valid.
1340
+ */
1341
+ private async acceptSnapshotFrom(c: CircleState, wire: Uint8Array, ownerPin: AccountPin, now: bigint, report?: SyncReport, resolved = false): Promise<ReturnType<typeof acceptSnapshot>> {
1342
+ const { pin } = this.requireAccount("sync");
1343
+ // The very bytes this device already verified (a bootstrap snapshot under a key since retired)
1344
+ // are a duplicate whatever key signed them; only new bytes are verified.
1345
+ if (c.snapshot && equalBytes(c.snapshot, wire)) return { outcome: "DUPLICATE", state: c };
1346
+ try {
1347
+ return acceptSnapshot(c, wire, ownerPin.accountKey, pin.accountId, now);
1348
+ } catch (e) {
1349
+ if (!isKavachError(e, "BAD_SIGNATURE")) throw e;
1350
+ // A retired key may only vouch for a circle this device holds at revision 0 (bootstrap, first
1351
+ // contact): an established circle advances under the current owner key alone.
1352
+ const ownerHex = toHex(ownerPin.accountId);
1353
+ const retiredKeys = c.revision === 0n ? await this.store.read((tx) => tx.list("identities").flatMap(([, r]) => (r.accountId === ownerHex && r.retired && r.accountKey ? [fromHex(r.accountKey)] : []))) : [];
1354
+ for (const key of retiredKeys) {
1355
+ try {
1356
+ return acceptSnapshot(c, wire, key, pin.accountId, now);
1357
+ } catch (inner) {
1358
+ if (!isKavachError(inner, "BAD_SIGNATURE")) throw inner;
1359
+ }
1360
+ }
1361
+ // Signed by a key we do not know: the owner may have succeeded since we last resolved it.
1362
+ // The chain is verified first; only a key it produced may sign this circle.
1363
+ if (!resolved && report && !equalBytes(ownerPin.accountId, pin.accountId)) {
1364
+ const [r] = await this.transport.directory.resolveIdentities([ownerPin.accountId]);
1365
+ if (r && !equalBytes(r.accountKey, ownerPin.accountKey)) {
1366
+ await this.acceptMemberCertificate(r, report);
1367
+ const fresh = await this.pinFor(ownerPin.accountId);
1368
+ if (fresh && !equalBytes(fresh.accountKey, ownerPin.accountKey)) return this.acceptSnapshotFrom(c, wire, fresh, now, report, true);
1369
+ }
1370
+ }
1371
+ throw e;
1372
+ }
1373
+ }
1374
+
1375
+ private async pinFor(accountId: Uint8Array): Promise<AccountPin | undefined> {
1376
+ const row = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
955
1377
  return row ? rowToPin(row) : undefined;
956
1378
  }
957
1379
 
958
- /** Accept a member's certificate; a changed account key is quarantined unless a valid receipt explains it. */
959
- private async acceptMemberCertificate(wire: Uint8Array, resetReceipt: Uint8Array | undefined, report: SyncReport): Promise<void> {
1380
+ /**
1381
+ * The one path every succession takes (section 4, "Chains"): the policy gate first, then the
1382
+ * key list, then each link verified in order from the start identity (the pinned identity for
1383
+ * an established contact, the account id at first contact or for a restoring device), page by
1384
+ * page within the sync's budget, with the verified prefix persisted so that the next sync
1385
+ * resumes instead of starting over. Only when the chain is complete does anything move: for a
1386
+ * pinned contact every link is applied in one transaction, so no epoch is ever sealed to an
1387
+ * intermediate successor; for a first contact or a restore the verified links are returned and
1388
+ * the caller records them with the pin or the header. Any failure leaves the pin where it was.
1389
+ */
1390
+ private async followChain(input: { accountId: Uint8Array; accountName: string; current?: AccountPin; finalIdentityId?: Uint8Array; untilGeneration?: bigint; firstPage: readonly SuccessionLink[]; next: bigint | undefined; budget?: ReadBudget | undefined }): Promise<{ pin: AccountPin | undefined; verified: VerifiedLink[]; accepted: SuccessionEffect[] }> {
1391
+ const stage = "chain";
1392
+ if (!acceptsSuccession(this.policy)) throw new KavachError("UNSUPPORTED_TYPE", stage, "this deployment does not accept successions");
1393
+ const accountHex = toHex(input.accountId);
1394
+ const startIdentity = input.current?.identityId ?? input.accountId;
1395
+ await this.ensureResetList();
1396
+ // The state the chain is validated against: the pin with what this store retired, or the root.
1397
+ const current = input.current;
1398
+ let state = current ? await this.store.read((tx) => pinnedState(tx, current, this.trust)) : rootState(input.accountId, input.accountName, this.trust);
1399
+ // Reconcile first: the host serves the chain from the root, and a pinned contact already holds
1400
+ // a prefix of it. Links whose transition is on record are dropped (the same transition at a
1401
+ // recorded state; a different one is the authority signing two successors), so verification
1402
+ // starts at the pinned identity with only the transitions this device has not seen.
1403
+ const recorded = current ? await this.store.read((tx) => tx.list("successions", accountHex + ":").map(([, r]) => r)) : [];
1404
+ // The bound, applied to every page alike: links past `untilGeneration` are not this call's to
1405
+ // follow (a replacing device stops at the state its own succession left from).
1406
+ const within = (l: SuccessionLink): boolean => input.untilGeneration === undefined || expectType<AccountSuccession>(parseDocument(l.wire), DocumentType.AccountSuccession, stage).newGeneration <= input.untilGeneration;
1407
+ const unseen = (links: readonly SuccessionLink[]): SuccessionLink[] =>
1408
+ links.filter(within).filter((l) => {
1409
+ const doc = expectType<AccountSuccession>(parseDocument(l.wire), DocumentType.AccountSuccession, stage);
1410
+ const t = transitionOf(doc);
1411
+ const row = recorded.find((r) => unbig(r.previousGeneration) === t.previousGeneration && r.previousIdentityId === t.previousIdentityId);
1412
+ if (!row) return true;
1413
+ if (!sameTransition({ accountName: row.accountName, accountId: row.accountId, successionId: row.successionId, previousIdentityId: row.previousIdentityId, newIdentityId: row.newIdentityId, previousGeneration: unbig(row.previousGeneration), newGeneration: unbig(row.newGeneration), newCertificateHash: row.newCertificateHash }, t)) {
1414
+ // A different transition at a recorded state is evidence against the authority only if
1415
+ // the authority signed it: unauthenticated bytes are a bad signature, nothing more.
1416
+ authenticateSuccession(l.wire, this.trust, this.now());
1417
+ throw new KavachError("EQUIVOCATION", stage, "two successors for one account state");
1418
+ }
1419
+ return false;
1420
+ });
1421
+ const progress = await this.store.read((tx) => tx.get("chainProgress", accountHex));
1422
+ const verified: VerifiedLink[] = [];
1423
+ let cursor: bigint | undefined;
1424
+ let pending: readonly SuccessionLink[];
1425
+ // Only links this device has not recorded are verified, and only they count against the budget.
1426
+ const verifyMore = (links: readonly SuccessionLink[]): void => {
1427
+ const fresh = unseen(links);
1428
+ const r = verifyChain(fresh, { state, trust: this.trust, now: this.now() });
1429
+ verified.push(...r.links);
1430
+ state = r.state;
1431
+ if (input.budget) input.budget.remainingRecords -= fresh.length;
1432
+ };
1433
+ if (progress && progress.startIdentityId === toHex(startIdentity)) {
1434
+ // Resume at the saved cursor, however long the verified prefix is. The prefix's transitions
1435
+ // were verified when their attestations were allowed, so they are replayed through the
1436
+ // transition rules alone; their bytes are authenticated again below, and refreshed from the
1437
+ // host where a key rotation since made them stale.
1438
+ for (const l of progress.links) {
1439
+ const doc = expectType<AccountSuccession>(parseDocument(fromHex(l.wire)), DocumentType.AccountSuccession, stage);
1440
+ state = validateTransition(doc, fromHex(l.certificate), state).next;
1441
+ verified.push({ doc, wire: fromHex(l.wire), certificate: fromHex(l.certificate) });
1442
+ }
1443
+ cursor = unbig(progress.nextGeneration);
1444
+ pending = [];
1445
+ } else {
1446
+ pending = input.firstPage;
1447
+ cursor = input.next;
1448
+ }
1449
+ const persist = (): Promise<void> =>
1450
+ this.store.transaction((tx) => {
1451
+ if (cursor === undefined) tx.delete("chainProgress", accountHex);
1452
+ else tx.put("chainProgress", accountHex, { accountId: accountHex, startIdentityId: toHex(startIdentity), links: verified.map((v) => ({ wire: toHex(v.wire), certificate: toHex(v.certificate) })), nextGeneration: big(cursor), complete: false });
1453
+ });
1454
+ const reached = (): boolean => input.untilGeneration !== undefined && state.generation >= input.untilGeneration;
1455
+ verifyMore(pending);
1456
+ if (reached()) cursor = undefined;
1457
+ while (cursor !== undefined) {
1458
+ if (input.budget && (input.budget.remainingRecords <= 0 || this.now() >= input.budget.deadline)) {
1459
+ await persist();
1460
+ throw new KavachError("METADATA_REQUIRED", stage, "the chain continues past this sync's budget");
1461
+ }
1462
+ let page;
1463
+ try {
1464
+ page = await this.transport.directory.getSuccessions(input.accountId, cursor, 32);
1465
+ } catch (e) {
1466
+ await persist();
1467
+ throw e;
1468
+ }
1469
+ if (page.successions.length === 0) {
1470
+ await persist();
1471
+ throw new KavachError("METADATA_REQUIRED", stage, "the host did not finish serving the chain");
1472
+ }
1473
+ verifyMore(page.successions);
1474
+ cursor = reached() ? undefined : page.nextGeneration;
1475
+ if (cursor !== undefined) await persist();
1476
+ }
1477
+ if (input.finalIdentityId && state.identityId !== toHex(input.finalIdentityId)) throw new KavachError("SUCCESSION_BINDING", stage, "the chain does not end at the served key");
1478
+ // Every attestation under the current list before anything is recorded. A link whose signer
1479
+ // left the list since it was verified is fetched again: the host re-attests a legitimate
1480
+ // transition under a current key, and the transition must be the same one.
1481
+ for (let i = 0; i < verified.length; i++) {
1482
+ const link = verified[i];
1483
+ if (!link) continue;
1484
+ try {
1485
+ authenticateSuccession(link.wire, this.trust, this.now());
1486
+ } catch (e) {
1487
+ if (!isKavachError(e, "RESET_KEY_REVOKED")) throw e;
1488
+ const page = await this.transport.directory.getSuccessions(input.accountId, link.doc.newGeneration - 1n, 1);
1489
+ const again = page.successions[0];
1490
+ if (!again) throw e;
1491
+ const doc = authenticateSuccession(again.wire, this.trust, this.now());
1492
+ if (!sameTransition(transitionOf(doc), transitionOf(link.doc)) || !equalBytes(again.certificate, link.certificate)) throw new KavachError("EQUIVOCATION", stage, "the re-attested link is a different transition");
1493
+ verified[i] = { doc, wire: again.wire, certificate: link.certificate };
1494
+ }
1495
+ }
1496
+ if (!current) {
1497
+ if (progress) await this.store.transaction((tx) => tx.delete("chainProgress", accountHex));
1498
+ return { pin: undefined, verified, accepted: [] };
1499
+ }
1500
+ const applied = await this.store.transaction((tx) => {
1501
+ const effects: SuccessionEffect[] = [];
1502
+ let pin = current;
1503
+ for (const link of verified) {
1504
+ // A link behind the pinned key that is already on record: the pin moved past it earlier.
1505
+ if (link.doc.previousGeneration < pin.generation && tx.list("successions").some(([, s]) => s.accountId === accountHex && unbig(s.previousGeneration) === link.doc.previousGeneration)) continue;
1506
+ const e = applySuccession(tx, { wire: link.wire, newCertificate: link.certificate, policy: this.policy, trust: this.trust, now: this.now() });
1507
+ effects.push(e);
1508
+ pin = e.pin;
1509
+ }
1510
+ this.persistTrustIn(tx);
1511
+ tx.delete("chainProgress", accountHex);
1512
+ return { effects, pin };
1513
+ });
1514
+ return { pin: applied.pin, verified, accepted: applied.effects };
1515
+ }
1516
+
1517
+ /** The report side of an applied chain: counters and events for every accepted link. */
1518
+ private reportAccepted(accepted: readonly SuccessionEffect[], report: SyncReport): void {
1519
+ for (const effect of accepted) {
1520
+ if (effect.outcome !== "ACCEPTED") continue;
1521
+ report.metadata.successions++;
1522
+ report.metadata.rotations++;
1523
+ this.emit({ kind: "accountKeyChanged", accountName: effect.accountName, quarantined: false });
1524
+ this.emit({ kind: "deviceChanged", identityId: effect.pin.accountId, generation: effect.pin.generation });
1525
+ }
1526
+ }
1527
+
1528
+ private securityCodeOf(e: unknown): ErrorCode | undefined {
1529
+ return e instanceof KavachError && (e.code === "EQUIVOCATION" || e.code === "ROLLBACK" || e.code === "SUCCESSION_BINDING" || e.code === "DEVICE_ID_REUSED" || e.code === "SUPERSEDED_IDENTITY") ? e.code : undefined;
1530
+ }
1531
+
1532
+ /** Accept a member's certificate; a changed account key is quarantined unless a succession chain or a reset receipt explains it. */
1533
+ private async acceptMemberCertificate(resolved: ResolvedAccount, report: SyncReport): Promise<void> {
1534
+ const wire = resolved.certificate;
1535
+ const resetReceipt = resolved.resetReceipt;
960
1536
  const doc = expectType<AccountDevice>(verifyDocument(wire), DocumentType.AccountDevice, "sync");
961
1537
  const existing = await this.store.read((tx) => tx.list("pins").find(([, p]) => p.accountName === doc.accountName)?.[1]);
962
- const current = existing ? rowToPin(existing) : undefined;
1538
+ let current = existing ? rowToPin(existing) : undefined;
1539
+ const accountId = current?.accountId ?? resolvedAccountId(resolved);
1540
+ const chain = resolved.successions ?? [];
1541
+ // A served chain is followed whenever there is one, even when the served key equals the pinned
1542
+ // one: a certificate under a retired key at a higher generation must be refused as a return to
1543
+ // that identity, never accepted as a plain device change. Links already on record cost nothing.
1544
+ if (current && chain.length > 0) {
1545
+ try {
1546
+ const r = await this.followChain({ accountId, accountName: doc.accountName, current, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget });
1547
+ this.reportAccepted(r.accepted, report);
1548
+ current = r.pin;
1549
+ } catch (e) {
1550
+ report.errors.push(syncError("succession", e));
1551
+ // A deployment that refuses successions quarantines the key change below, as a bare
1552
+ // directory key change. Anything else leaves the pin where it was until the next sync.
1553
+ if (!isKavachError(e, "UNSUPPORTED_TYPE")) {
1554
+ const code = this.securityCodeOf(e);
1555
+ if (code) this.emit({ kind: "securityError", code, detail: doc.accountName });
1556
+ return;
1557
+ }
1558
+ }
1559
+ if (!current) return;
1560
+ }
1561
+ let verified: VerifiedLink[] = [];
1562
+ if (!current && chain.length > 0) {
1563
+ // A first contact with an account that went through successions: the chain is verified from
1564
+ // the root to the served key before any pin is written, under the same policy gate as for an
1565
+ // established contact. A deployment that refuses successions pins nothing and quarantines.
1566
+ try {
1567
+ verified = (await this.followChain({ accountId, accountName: doc.accountName, finalIdentityId: pinFromCertificate(doc).identityId, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget })).verified;
1568
+ } catch (e) {
1569
+ report.errors.push(syncError("succession", e));
1570
+ if (isKavachError(e, "UNSUPPORTED_TYPE")) {
1571
+ const already = await this.store.read((tx) => tx.get("quarantine", doc.accountName));
1572
+ await this.store.transaction((tx) => tx.put("quarantine", doc.accountName, { accountName: doc.accountName, certificate: toHex(wire), reason: "succession refused", at: big(this.now()) }));
1573
+ report.metadata.quarantined++;
1574
+ if (!already) this.emit({ kind: "accountKeyChanged", accountName: doc.accountName, quarantined: true });
1575
+ } else {
1576
+ const code = this.securityCodeOf(e);
1577
+ if (code) this.emit({ kind: "securityError", code, detail: doc.accountName });
1578
+ }
1579
+ return;
1580
+ }
1581
+ }
963
1582
  let outcome;
964
1583
  try {
965
1584
  outcome = acceptCertificate(current, wire);
@@ -976,21 +1595,30 @@ class ClientImpl implements Client {
976
1595
  throw e;
977
1596
  }
978
1597
  if (outcome.outcome === "DUPLICATE") return;
979
- const p = outcome.pin;
1598
+ const p = current ? outcome.pin : { ...outcome.pin, accountId };
980
1599
  await this.store.transaction((tx) => {
981
- tx.put("pins", toHex(p.identityId), pinToRow(p));
1600
+ tx.put("pins", toHex(p.accountId), pinToRow(p));
1601
+ if (verified.length) {
1602
+ recordChainIn(tx, verified, this.now());
1603
+ rootIdentityIn(tx, p.accountId, p.accountName, resolved.rootCertificate);
1604
+ }
1605
+ if (!tx.get("identities", toHex(p.identityId))) {
1606
+ const last = resolved.successions?.at(-1);
1607
+ const since = last ? expectType<AccountSuccession>(parseDocument(last.wire), DocumentType.AccountSuccession, "sync").newGeneration : 1n;
1608
+ tx.put("identities", toHex(p.identityId), { accountId: toHex(p.accountId), accountName: p.accountName, accountKey: toHex(p.accountKey), sinceGeneration: big(since), retired: false });
1609
+ }
982
1610
  if (outcome.previous) {
983
1611
  const old = tx.get("certificates", toHex(outcome.previous.certificateHash));
984
1612
  if (old) tx.put("certificates", toHex(outcome.previous.certificateHash), { ...old, current: false });
985
1613
  }
986
- tx.put("certificates", toHex(p.certificateHash), { identityId: toHex(p.identityId), certificateHash: toHex(p.certificateHash), signingKey: toHex(p.signingKey), boxKey: toHex(p.boxKey), streamId: toHex(p.streamId), generation: big(p.generation), wire: toHex(p.certificate), current: true });
1614
+ tx.put("certificates", toHex(p.certificateHash), { identityId: toHex(p.accountId), certificateHash: toHex(p.certificateHash), signingKey: toHex(p.signingKey), boxKey: toHex(p.boxKey), streamId: toHex(p.streamId), generation: big(p.generation), wire: toHex(p.certificate), current: true });
987
1615
  if (outcome.rotate) {
988
- const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.identityId, certificateHash: p.certificateHash });
1616
+ const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.accountId, certificateHash: p.certificateHash });
989
1617
  if (ch.changed) report.metadata.rotations++;
990
1618
  }
991
1619
  });
992
1620
  report.metadata.certificates++;
993
- if (outcome.rotate) this.emit({ kind: "deviceChanged", identityId: p.identityId, generation: p.generation });
1621
+ if (outcome.rotate) this.emit({ kind: "deviceChanged", identityId: p.accountId, generation: p.generation });
994
1622
  }
995
1623
 
996
1624
  private async onKeyChanged(current: AccountPin, wire: Uint8Array, receipt: Uint8Array | undefined, report: SyncReport): Promise<void> {
@@ -1025,17 +1653,21 @@ class ClientImpl implements Client {
1025
1653
 
1026
1654
  /** Peers on a validated reset: stop sharing to the old identity, close its circles, rotate. */
1027
1655
  private applyResetIn(tx: Tx, old: AccountPin, newIdentityId: Uint8Array): void {
1028
- tx.delete("pins", toHex(old.identityId));
1656
+ const accountHex = toHex(old.accountId);
1657
+ tx.delete("pins", accountHex);
1029
1658
  tx.delete("quarantine", old.accountName);
1030
- for (const [k, c] of tx.list("certificates")) if (c.identityId === toHex(old.identityId)) tx.put("certificates", k, { ...c, current: false });
1659
+ for (const [k, c] of tx.list("certificates")) if (c.identityId === accountHex) tx.put("certificates", k, { ...c, current: false });
1660
+ // Every identity of the chain is retired; the root remembers the reset, so no successor of
1661
+ // the old account is ever a recipient again.
1662
+ for (const [k, r] of tx.list("identities")) if (r.accountId === accountHex) tx.put("identities", k, k === accountHex ? { ...r, retired: true, resetAt: big(this.now()) } : { ...r, retired: true });
1031
1663
  for (const [k, c] of tx.list("circles")) {
1032
- if (c.ownerIdentityId === toHex(old.identityId)) {
1664
+ if (c.ownerIdentityId === accountHex) {
1033
1665
  t3Circle(tx, { ...c, status: "closed" }, { kind: "leave", circleId: fromHex(k) });
1034
- } else if (c.members.includes(toHex(old.identityId))) {
1035
- t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.identityId, certificateHash: newIdentityId });
1666
+ } else if (c.members.includes(accountHex)) {
1667
+ t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.accountId, certificateHash: newIdentityId });
1036
1668
  }
1037
1669
  }
1038
- forgetAuthorIn(tx, old.identityId);
1670
+ forgetAuthorIn(tx, old.accountId);
1039
1671
  }
1040
1672
 
1041
1673
  private persistTrustIn(tx: Tx): void {
@@ -1056,12 +1688,13 @@ class ClientImpl implements Client {
1056
1688
  const { pin } = this.requireAccount("left");
1057
1689
  const others = await this.store.read((tx) => tx.list("circles").filter(([, x]) => x.circleId !== toHex(c.circleId) && x.status === "member").flatMap(([, x]) => x.members));
1058
1690
  for (const m of c.members) {
1059
- if (equalBytes(m, pin.identityId) || others.includes(toHex(m))) continue;
1691
+ if (equalBytes(m, pin.accountId) || others.includes(toHex(m))) continue;
1060
1692
  await forgetAuthor(this.store, m);
1061
1693
  }
1062
1694
  }
1063
1695
 
1064
1696
  private async retire(): Promise<void> {
1697
+ this.retired = true;
1065
1698
  await this.store.transaction((tx) => {
1066
1699
  const meta = tx.get("meta", META);
1067
1700
  if (meta) tx.put("meta", META, { ...meta, state: "RETIRED" });
@@ -1074,10 +1707,6 @@ class ClientImpl implements Client {
1074
1707
 
1075
1708
  // Receiving and reads ---------------------------------------------------------------------------
1076
1709
 
1077
- receive(wire: Uint8Array): Promise<ReceiveResult> {
1078
- return receiveRecord(this.inboxDeps(), wire);
1079
- }
1080
-
1081
1710
  history(query?: HistoryQuery): Promise<HistoryEntry[]> {
1082
1711
  return history(this.store, this.policy, query);
1083
1712
  }
@@ -1094,12 +1723,36 @@ class ClientImpl implements Client {
1094
1723
  return newestSlot(this.store, identityId, kind, this.now());
1095
1724
  }
1096
1725
 
1726
+ keyChanges(options: { accountId?: Uint8Array; limit?: number } = {}): Promise<KeyChangeEntry[]> {
1727
+ const limit = Math.max(1, Math.min(500, Math.floor(options.limit ?? 50)));
1728
+ const who = options.accountId ? toHex(options.accountId) : undefined;
1729
+ return this.store.read((tx) =>
1730
+ tx
1731
+ .list("keyChanges")
1732
+ .map(([, r]) => r)
1733
+ .filter((r) => who === undefined || r.accountId === who)
1734
+ // Newest first; two changes accepted in one transaction order by generation.
1735
+ .sort((x, y) => (unbig(y.acceptedAt) > unbig(x.acceptedAt) ? 1 : unbig(y.acceptedAt) < unbig(x.acceptedAt) ? -1 : unbig(y.generation) > unbig(x.generation) ? 1 : unbig(y.generation) < unbig(x.generation) ? -1 : 0))
1736
+ .slice(0, limit)
1737
+ .map((r) => ({
1738
+ accountId: fromHex(r.accountId),
1739
+ accountName: r.accountName,
1740
+ previousIdentityId: fromHex(r.previousIdentityId),
1741
+ newIdentityId: fromHex(r.newIdentityId),
1742
+ generation: unbig(r.generation),
1743
+ reason: r.reason,
1744
+ successionId: r.successionId === undefined ? undefined : fromHex(r.successionId),
1745
+ acceptedAt: unbig(r.acceptedAt)
1746
+ }))
1747
+ );
1748
+ }
1749
+
1097
1750
 
1098
1751
 
1099
1752
  // Circles ---------------------------------------------------------------------------------------
1100
1753
 
1101
1754
  private async listCircles(): Promise<CircleSummary[]> {
1102
- const self = this.selfPin?.identityId;
1755
+ const self = this.selfPin?.accountId;
1103
1756
  const now = this.now();
1104
1757
  return this.store.read((tx) =>
1105
1758
  tx.list("circles").map(([, r]) => {
@@ -1117,6 +1770,7 @@ class ClientImpl implements Client {
1117
1770
  * the state, so a change that landed in the rows reaches the vault however the process dies.
1118
1771
  */
1119
1772
  private async syncVault(): Promise<void> {
1773
+ if (!this.vaultEnabled) return;
1120
1774
  const { account, pin } = this.requireAccount("vault");
1121
1775
  const recoveryKey = await this.secrets.get("recoveryKey");
1122
1776
  const accountSeed = await this.secrets.get("accountSeed");
@@ -1195,10 +1849,13 @@ class ClientImpl implements Client {
1195
1849
  private async createCircle(): Promise<{ circleId: Uint8Array }> {
1196
1850
  const { account, pin } = this.requireAccount("createCircle");
1197
1851
  const circleId = newCircleId();
1198
- const genesis = createCircleSnapshot({ identityId: pin.identityId, accountSecretKey: account.account.secretKey }, circleId);
1199
- const state = acceptSnapshot(pinCircle(circleId, pin.identityId, true, "member"), genesis, pin.accountKey, pin.identityId, this.now());
1852
+ const genesis = createCircleSnapshot({ identityId: pin.accountId, accountSecretKey: account.account.secretKey }, circleId);
1853
+ const state = acceptSnapshot(pinCircle(circleId, pin.accountId, true, "member"), genesis, pin.accountKey, pin.accountId, this.now());
1200
1854
  if (state.outcome !== "ACCEPTED") throw new KavachError("STORAGE_FAILURE", "createCircle");
1201
- await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(state.state), pending: { snapshot: toHex(genesis), revision: "1" } }));
1855
+ await this.store.transaction((tx) => {
1856
+ t3Circle(tx, { ...circleToRow(state.state), pending: { snapshot: toHex(genesis), revision: "1" } });
1857
+ this.recordPreferenceIn(tx, circleId, true);
1858
+ });
1202
1859
  const published = await this.publishPendingSnapshot(circleId);
1203
1860
  if (published !== "COMMITTED") throw new KavachError("STORAGE_FAILURE", "createCircle", published);
1204
1861
  await this.store.transaction((tx) => {
@@ -1206,6 +1863,7 @@ class ClientImpl implements Client {
1206
1863
  t9MarkVault(tx);
1207
1864
  });
1208
1865
  await this.syncVault();
1866
+ await this.pushPreferencesQuietly();
1209
1867
  this.emit({ kind: "circleChanged", circleId, revision: 1n, status: "member" });
1210
1868
  return { circleId };
1211
1869
  }
@@ -1214,6 +1872,7 @@ class ClientImpl implements Client {
1214
1872
  this.requireAccount("requestJoin");
1215
1873
  await this.store.transaction((tx) => {
1216
1874
  const existing = tx.get("circles", toHex(circleId));
1875
+ if (!existing || existing.status === "left") this.recordPreferenceIn(tx, circleId, shareOnApproval);
1217
1876
  if (!existing) t3Circle(tx, circleToRow(pinCircle(circleId, ownerIdentityId, shareOnApproval, "requested")));
1218
1877
  else if (existing.status === "left") {
1219
1878
  // An explicit rejoin: the pinned owner and the revision reached stay, so no older snapshot
@@ -1226,17 +1885,28 @@ class ClientImpl implements Client {
1226
1885
  await this.syncVault();
1227
1886
  const outcome = await this.transport.directory.requestJoin(circleId);
1228
1887
  if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") throw new KavachError("STORAGE_FAILURE", "requestJoin", outcome.status);
1888
+ await this.pushPreferencesQuietly();
1889
+ }
1890
+
1891
+ /** The host's copy of a preference, best effort now and by sync later. */
1892
+ private async pushPreferencesQuietly(): Promise<void> {
1893
+ if (!acceptsSuccession(this.policy)) return;
1894
+ try {
1895
+ await this.pushPreferences();
1896
+ } catch {
1897
+ /* retried by sync */
1898
+ }
1229
1899
  }
1230
1900
 
1231
1901
  private async approve(circleId: Uint8Array, accountName: string): Promise<{ revision: bigint }> {
1232
1902
  const [resolved] = await this.transport.directory.resolveAccounts([accountName]);
1233
1903
  if (!resolved) throw new KavachError("METADATA_REQUIRED", "approve", "unknown account");
1234
- const report: SyncReport = { metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0 }, epoch: "unchanged", outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 }, inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false }, purged: 0, errors: [] };
1235
- await this.acceptMemberCertificate(resolved.certificate, resolved.resetReceipt, report);
1236
- const requester = pinFromCertificate(expectType<AccountDevice>(verifyDocument(resolved.certificate), DocumentType.AccountDevice));
1237
- const pinned = await this.pinFor(requester.identityId);
1238
- if (!pinned) throw new KavachError("ACCOUNT_KEY_CHANGED", "approve", "requester's key is quarantined");
1239
- return this.changeMembership(circleId, { admit: requester.identityId });
1904
+ const report: SyncReport = { metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 }, epoch: "unchanged", outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 }, inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false }, purged: 0, errors: [] };
1905
+ await this.acceptMemberCertificate(resolved, report);
1906
+ const requester = resolvedAccountId(resolved);
1907
+ const pinned = await this.pinFor(requester);
1908
+ if (!pinned || !equalBytes(pinned.certificateHash, expectType<AccountDevice>(verifyDocument(resolved.certificate), DocumentType.AccountDevice).bodyHash)) throw new KavachError("ACCOUNT_KEY_CHANGED", "approve", "requester's key is quarantined");
1909
+ return this.changeMembership(circleId, { admit: requester });
1240
1910
  }
1241
1911
 
1242
1912
  private async changeMembership(circleId: Uint8Array, change: { admit: Uint8Array } | { remove: Uint8Array } | { close: true }): Promise<{ revision: bigint }> {
@@ -1251,7 +1921,7 @@ class ClientImpl implements Client {
1251
1921
  }
1252
1922
  const state = rowToCircle(row);
1253
1923
  if (state.status === "closed") return { revision: state.revision };
1254
- const successor = successorSnapshot(state, change, { identityId: pin.identityId, accountSecretKey: account.account.secretKey });
1924
+ const successor = successorSnapshot(state, change, { identityId: pin.accountId, accountSecretKey: account.account.secretKey });
1255
1925
  if (!successor) {
1256
1926
  if (!("close" in change)) return { revision: state.revision };
1257
1927
  // Nobody but the owner to tell: the close is local, and the vault entry goes with it.
@@ -1289,7 +1959,7 @@ class ClientImpl implements Client {
1289
1959
  const expectedRevision = unbig(pending.revision) - 1n;
1290
1960
  const outcome = await this.transport.directory.publishOwnedCircle({ circleId, expectedRevision, expectedHash: expectedRevision === state.revision ? state.bodyHash : undefined, newSnapshot: successor });
1291
1961
  if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
1292
- const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.identityId, this.now());
1962
+ const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.accountId, this.now());
1293
1963
  if (accepted.outcome !== "ACCEPTED" && accepted.outcome !== "DUPLICATE") throw new KavachError("STORAGE_FAILURE", stage, "the successor was refused locally");
1294
1964
  let next = pending.close ? { ...accepted.state, status: "closed" as const } : accepted.state;
1295
1965
  await this.store.transaction((tx) => {
@@ -1301,7 +1971,7 @@ class ClientImpl implements Client {
1301
1971
  });
1302
1972
  const after = new Set(next.members.map(toHex));
1303
1973
  for (const m of state.members) {
1304
- if (after.has(toHex(m)) || equalBytes(m, pin.identityId)) continue;
1974
+ if (after.has(toHex(m)) || equalBytes(m, pin.accountId)) continue;
1305
1975
  const stillElsewhere = await this.store.read((tx) => tx.list("circles").some(([, c]) => c.circleId !== toHex(circleId) && c.status === "member" && c.members.includes(toHex(m))));
1306
1976
  if (!stillElsewhere) await forgetAuthor(this.store, m);
1307
1977
  }
@@ -1310,7 +1980,7 @@ class ClientImpl implements Client {
1310
1980
  return "COMMITTED";
1311
1981
  }
1312
1982
  if (outcome.status === "CONFLICT" && outcome.current) {
1313
- const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.identityId, this.now());
1983
+ const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.accountId, this.now());
1314
1984
  await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(accepted.outcome === "ACCEPTED" ? accepted.state : state), pending: undefined }));
1315
1985
  return "CONFLICT";
1316
1986
  }
@@ -1321,7 +1991,7 @@ class ClientImpl implements Client {
1321
1991
  const { pin } = this.requireAccount("leave");
1322
1992
  const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1323
1993
  if (!row) return;
1324
- if (row.ownerIdentityId === toHex(pin.identityId)) throw new KavachError("NOT_AUTHORIZED", "leave", "the owner closes instead");
1994
+ if (row.ownerIdentityId === toHex(pin.accountId)) throw new KavachError("NOT_AUTHORIZED", "leave", "the owner closes instead");
1325
1995
  const state = rowToCircle(row);
1326
1996
  await this.store.transaction((tx) => {
1327
1997
  t3Circle(tx, circleToRow({ ...state, enabled: false, status: "left" }), { kind: "leave", circleId });
@@ -1341,8 +2011,57 @@ class ClientImpl implements Client {
1341
2011
  }
1342
2012
  await this.store.transaction((tx) => {
1343
2013
  t3Circle(tx, { ...row, enabled }, { kind: enabled ? "enable" : "disable", circleId });
2014
+ this.recordPreferenceIn(tx, circleId, enabled);
1344
2015
  });
1345
2016
  await this.syncVault();
2017
+ await this.pushPreferencesQuietly();
2018
+ }
2019
+
2020
+ /** The user's latest action on a circle's preference, with the local sequence that a host response must name to clear it. */
2021
+ private recordPreferenceIn(tx: Tx, circleId: Uint8Array, enabled: boolean): void {
2022
+ const key = toHex(circleId);
2023
+ const row = tx.get("preferences", key) ?? { circleId: key, enabled, revision: "0", pending: undefined, nextSequence: "1" };
2024
+ const sequence = row.nextSequence;
2025
+ tx.put("preferences", key, { ...row, enabled, pending: { enabled, sequence }, nextSequence: big(unbig(sequence) + 1n) });
2026
+ }
2027
+
2028
+ /**
2029
+ * Send pending preference changes to the host (amendment A1, section 7). On CONFLICT the
2030
+ * device's latest persisted action wins, read from the store at retry time, and is written again
2031
+ * at the served revision; a response clears only the sequence it answered.
2032
+ */
2033
+ private async pushPreferences(report?: SyncReport): Promise<void> {
2034
+ if (!acceptsSuccession(this.policy) || !this.selfPin) return;
2035
+ const pending = await this.store.read((tx) => tx.list("preferences").filter(([, p]) => p.pending !== undefined).map(([, p]) => p.circleId));
2036
+ for (const key of pending) {
2037
+ for (let attempt = 0; attempt < 3; attempt++) {
2038
+ const row = await this.store.read((tx) => tx.get("preferences", key));
2039
+ if (!row?.pending) break;
2040
+ const sent = row.pending;
2041
+ const outcome = await this.transport.directory.setSharingPreference(fromHex(key), sent.enabled, unbig(row.revision));
2042
+ if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
2043
+ await this.store.transaction((tx) => {
2044
+ const live = tx.get("preferences", key);
2045
+ if (!live) return;
2046
+ const cleared = live.pending?.sequence === sent.sequence ? undefined : live.pending;
2047
+ // A late success for an older request never moves the revision back.
2048
+ const revision = outcome.revision > unbig(live.revision) ? big(outcome.revision) : live.revision;
2049
+ tx.put("preferences", key, { ...live, revision, pending: cleared });
2050
+ });
2051
+ break;
2052
+ }
2053
+ if (outcome.status === "CONFLICT") {
2054
+ // Adopt the served revision; the latest persisted action is re-sent against it.
2055
+ await this.store.transaction((tx) => {
2056
+ const live = tx.get("preferences", key);
2057
+ if (live) tx.put("preferences", key, { ...live, revision: big(outcome.revision) });
2058
+ });
2059
+ continue;
2060
+ }
2061
+ if (report) report.errors.push({ operation: "preference", code: outcome.status === "UNAUTHORIZED" ? "NOT_AUTHORIZED" : "NETWORK", stage: "preference", retryable: outcome.status !== "UNAUTHORIZED" });
2062
+ break;
2063
+ }
2064
+ }
1346
2065
  }
1347
2066
 
1348
2067
  /** Close: the successor names only the owner; its publication forgets the members and removes the vault entry. */
@@ -1350,9 +2069,304 @@ class ClientImpl implements Client {
1350
2069
  await this.changeMembership(circleId, { close: true });
1351
2070
  }
1352
2071
 
2072
+ // Account recovery (amendment A1) ---------------------------------------------------------------
2073
+
2074
+ private async prepareReplacementNow(): Promise<ReplacementProposal> {
2075
+ const stage = "prepareReplacement";
2076
+ if (!acceptsSuccession(this.policy)) throw new KavachError("NOT_AUTHORIZED", stage, "this deployment does not use account recovery");
2077
+ const opened = await openStore(this.store, this.secrets, this.accountName);
2078
+ if (opened.kind !== "fresh") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
2079
+ const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
2080
+ const roots = await this.secrets.get("pendingReplacement");
2081
+ if (existing && !existing.stale && roots && roots.length === RESET_ROOTS_BYTES) return proposalOf(existing);
2082
+ const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
2083
+ if (!resolved) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
2084
+ const current = expectType<AccountDevice>(verifyDocument(resolved.certificate), DocumentType.AccountDevice, stage);
2085
+ const accountId = resolvedAccountId(resolved);
2086
+ // Fresh roots: nothing from the old device, nothing from the host. The certificate is at the
2087
+ // next generation under the new key; the vault is fresh at revision 1 so that identity
2088
+ // continuity works on the next replacement.
2089
+ const accountSeed = newAccountSeed();
2090
+ const device = newDevice();
2091
+ const recoveryKey = newRecoveryKey();
2092
+ const instanceId = randomBytes(16);
2093
+ const successionId = randomBytes(16);
2094
+ const account = openAccount(this.accountName, accountSeed, accountId);
2095
+ const generation = current.generation + 1n;
2096
+ const certificate = certify(account, device, generation);
2097
+ const pin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage), undefined, accountId);
2098
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: generation, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
2099
+ // T11: roots in the secret store, the exact bytes in the store, before anything is sent.
2100
+ await this.secrets.set("pendingReplacement", concat(accountSeed, device.deviceSeed, recoveryKey, instanceId, device.deviceId));
2101
+ const row: ReplacementRow = {
2102
+ successionId: toHex(successionId),
2103
+ accountName: this.accountName,
2104
+ accountId: toHex(accountId),
2105
+ expectedCertificateHash: toHex(current.bodyHash),
2106
+ expectedGeneration: big(current.generation),
2107
+ certificate: toHex(certificate),
2108
+ vault: vault ? toHex(vault.wire) : undefined,
2109
+ vaultHash: vault ? toHex(vault.hash) : undefined,
2110
+ deviceId: toHex(device.deviceId),
2111
+ stale: false,
2112
+ conflictGeneration: undefined
2113
+ };
2114
+ await this.store.transaction((tx) => tx.put("replacement", "pending", row));
2115
+ await this.emitState();
2116
+ return proposalOf(row);
2117
+ }
2118
+
2119
+ private async completeReplacementNow(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }> {
2120
+ const stage = "completeReplacement";
2121
+ // Already activated (by an earlier call, or by a sync that found the commit on the chain): idempotent.
2122
+ if (this.selfPin && this.account && !this.retired) return { identityId: this.selfPin.identityId, generation: this.selfPin.generation };
2123
+ const row = await this.store.read((tx) => tx.get("replacement", "pending"));
2124
+ if (!row || row.successionId !== toHex(input.successionId)) throw new KavachError("MALFORMED", stage, "no such prepared replacement on this device");
2125
+ if (row.stale) throw new KavachError("REPLACEMENT_CONFLICT", stage, row.conflictGeneration === undefined ? "the account moved" : `the account is at generation ${row.conflictGeneration}`);
2126
+ const roots = await this.secrets.get("pendingReplacement");
2127
+ if (!roots || roots.length !== RESET_ROOTS_BYTES) throw new KavachError("RECOVERY_REQUIRED", stage, "replacement prepared without its roots");
2128
+ let outcome: Awaited<ReturnType<Directory["succeedAccount"]>>;
2129
+ try {
2130
+ outcome = await this.transport.directory.succeedAccount({
2131
+ accountName: this.accountName,
2132
+ expectedCertificateHash: fromHex(row.expectedCertificateHash),
2133
+ newCertificate: fromHex(row.certificate),
2134
+ newVault: row.vault === undefined ? undefined : fromHex(row.vault),
2135
+ successionId: input.successionId,
2136
+ authorization: input.authorization
2137
+ });
2138
+ } catch (e) {
2139
+ if (!isKavachError(e, "NETWORK")) throw e;
2140
+ // Unknown: the chain says whether ours committed. Never a second identity.
2141
+ const link = await this.findOwnLink(row).catch(() => undefined);
2142
+ if (!link) throw new KavachError("SUCCESSION_PENDING", stage, "the commit's outcome is unknown; call again with the same proposal");
2143
+ outcome = { status: "COMMITTED", succession: link.wire };
2144
+ }
2145
+ if (outcome.status === "CONFLICT") {
2146
+ // Definitive: the current certificate moved under the proposal, and the succession id is
2147
+ // not on the chain (that answers first). The proposal can never commit: it is marked stale
2148
+ // and its keys, never activated anywhere, are discarded.
2149
+ let current: bigint | undefined;
2150
+ try {
2151
+ current = outcome.current ? expectType<AccountDevice>(verifyDocument(outcome.current), DocumentType.AccountDevice, stage).generation : undefined;
2152
+ } catch {
2153
+ current = undefined;
2154
+ }
2155
+ await this.store.transaction((tx) => tx.put("replacement", "pending", { ...row, stale: true, conflictGeneration: current === undefined ? undefined : big(current) }));
2156
+ await this.secrets.delete("pendingReplacement");
2157
+ await this.emitState();
2158
+ throw new KavachError("REPLACEMENT_CONFLICT", stage, current === undefined ? "the account moved" : `the account is at generation ${current}`);
2159
+ }
2160
+ if (outcome.status === "UNAUTHORIZED") throw new KavachError("AUTHORIZATION_REQUIRED", stage, "the host refused the authorization; obtain a fresh one for the same proposal");
2161
+ if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.succession) {
2162
+ throw new KavachError("SUCCESSION_PENDING", stage, `host answered ${outcome.status}`);
2163
+ }
2164
+ return this.activateReplacement(row, roots, outcome.succession);
2165
+ }
2166
+
2167
+ /** Our own link on the host's chain, if the proposal committed: the one naming our succession id and certificate. */
2168
+ private async findOwnLink(row: ReplacementRow): Promise<SuccessionLink | undefined> {
2169
+ const page = await this.transport.directory.getSuccessions(fromHex(row.accountId), unbig(row.expectedGeneration), 8);
2170
+ for (const l of page.successions) {
2171
+ try {
2172
+ const doc = expectType<AccountSuccession>(parseDocument(l.wire), DocumentType.AccountSuccession, "resume");
2173
+ if (toHex(doc.successionId) === row.successionId && equalBytes(l.certificate, fromHex(row.certificate))) return l;
2174
+ } catch {
2175
+ /* not ours */
2176
+ }
2177
+ }
2178
+ return undefined;
2179
+ }
2180
+
2181
+ /** The reset-key list, fetched when the one held is not fresh. */
2182
+ private async ensureResetList(): Promise<void> {
2183
+ const now = this.now();
2184
+ if (this.trust.fresh(now)) return;
2185
+ const wire = await this.transport.directory.getResetKeyList();
2186
+ if (!wire) return;
2187
+ this.trust.install(wire, now);
2188
+ await this.persistTrust();
2189
+ }
2190
+
2191
+ /**
2192
+ * Activate: verify the returned succession exactly as a peer would, against the previous state
2193
+ * pinned from the host, then the T1 header under the new identity with the account id carried
2194
+ * from the proposal. A succession that does not verify leaves the proposal intact.
2195
+ */
2196
+ private async activateReplacement(row: ReplacementRow, roots: Uint8Array, succession: Uint8Array): Promise<{ identityId: Uint8Array; generation: bigint }> {
2197
+ const stage = "completeReplacement";
2198
+ await this.ensureResetList();
2199
+ const previousWire = await this.transport.directory.getCertificate(fromHex(row.expectedCertificateHash));
2200
+ if (!previousWire) throw new KavachError("METADATA_REQUIRED", stage, "the previous certificate is not served");
2201
+ const previous = expectType<AccountDevice>(verifyDocument(previousWire), DocumentType.AccountDevice, stage);
2202
+ if (toHex(previous.bodyHash) !== row.expectedCertificateHash) throw new KavachError("SUCCESSION_BINDING", stage, "the served certificate is not the expected one");
2203
+ const accountId = fromHex(row.accountId);
2204
+ const previousPin = pinFromCertificate(previous, undefined, accountId);
2205
+ // The account's earlier successions, if any: the chain from the root to the previous identity,
2206
+ // verified through the one chain path and kept, so this device holds every key the account
2207
+ // has signed with (an owned circle's snapshot from before an earlier replacement) and can
2208
+ // follow its own chain later (to learn of its retirement).
2209
+ const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
2210
+ // The evidence must be there before anything is written: without the account's resolution the
2211
+ // history is unknown, not empty, and the proposal stays for the next call. An empty history is
2212
+ // right only when the previous identity is the account root.
2213
+ if (!me) throw new KavachError("METADATA_REQUIRED", stage, "the account's history is not served; the proposal stays");
2214
+ const served = me.successions ?? [];
2215
+ const ownSuccession = authenticateSuccession(succession, this.trust, this.now());
2216
+ if (served.length === 0 && me.successionsNext === undefined && !equalBytes(previousPin.identityId, accountId)) throw new KavachError("METADATA_REQUIRED", stage, "the account's earlier successions are not served; the proposal stays");
2217
+ // Bounded by the generation this device's own succession left from, on every page alike.
2218
+ const history = served.length > 0 || me.successionsNext !== undefined ? (await this.followChain({ accountId, accountName: this.accountName, finalIdentityId: previousPin.identityId, untilGeneration: ownSuccession.previousGeneration, firstPage: served, next: me.successionsNext })).verified : [];
2219
+ const accountSeed = roots.subarray(0, 32);
2220
+ const deviceSeed = roots.subarray(32, 64);
2221
+ const recoveryKey = roots.subarray(64, 96);
2222
+ const instanceId = roots.subarray(96, 112);
2223
+ const deviceId = roots.subarray(112, 128);
2224
+ const account = openAccount(this.accountName, accountSeed, accountId);
2225
+ const device = deviceFromSeed(deviceSeed, deviceId);
2226
+ const certificate = fromHex(row.certificate);
2227
+ const doc = expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage);
2228
+ const pin = pinFromCertificate(doc, undefined, accountId);
2229
+ // Roots first, then the store: a crash in between leaves a device the next call finishes.
2230
+ await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
2231
+ await this.store.transaction((tx) => {
2232
+ // The previous state as a first contact pins it, then the succession as a peer verifies it:
2233
+ // nothing is written unless the document binds this account, this generation and this key.
2234
+ tx.put("pins", toHex(accountId), pinToRow(previousPin));
2235
+ tx.put("certificates", toHex(previousPin.certificateHash), { identityId: toHex(accountId), certificateHash: toHex(previousPin.certificateHash), signingKey: toHex(previousPin.signingKey), boxKey: toHex(previousPin.boxKey), streamId: toHex(previousPin.streamId), generation: big(previous.generation), wire: toHex(previousWire), current: true });
2236
+ recordChainIn(tx, history, this.now());
2237
+ rootIdentityIn(tx, accountId, this.accountName, me?.rootCertificate);
2238
+ const effect = applySuccession(tx, { wire: succession, newCertificate: certificate, policy: this.policy, trust: this.trust, now: this.now() });
2239
+ if (!equalBytes(effect.newIdentityId, account.identityId) || toHex(effect.accountId) !== row.accountId) throw new KavachError("SUCCESSION_BINDING", stage, "the succession does not name this device's key");
2240
+ this.persistTrustIn(tx);
2241
+ t1Header(tx, { account, device, certificate, instanceId, generation: doc.generation });
2242
+ if (row.vault !== undefined && row.vaultHash !== undefined) t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
2243
+ t3ApplyChange(tx, { kind: "ownActivation" });
2244
+ t1Activate(tx);
2245
+ const meta = tx.get("meta", META);
2246
+ if (meta) tx.put("meta", META, { ...meta, bootstrap: { complete: false } });
2247
+ tx.delete("replacement", "pending");
2248
+ });
2249
+ await this.secrets.delete("pendingReplacement");
2250
+ this.account = account;
2251
+ this.device = device;
2252
+ this.selfPin = pin;
2253
+ this.retired = false;
2254
+ try {
2255
+ await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
2256
+ } catch {
2257
+ /* reported by recoveryStatus() */
2258
+ }
2259
+ await this.emitState();
2260
+ return { identityId: pin.identityId, generation: pin.generation };
2261
+ }
2262
+
2263
+ /**
2264
+ * Discovery after account recovery (amendment A1, section 5): the host lists the account's
2265
+ * circles page by page; each is pinned only when its snapshot verifies under the owner's
2266
+ * verified chain and names this account as the host says. Discovery is not permission to share:
2267
+ * the circle is enabled only if the served preference says so. Never writes membership; an
2268
+ * owner's successor republishes the served snapshot at the next revision.
2269
+ */
2270
+ private async bootstrap(report: SyncReport, state: NonNullable<MetaRow["bootstrap"]>): Promise<{ circles: number; pending: number; failed: number }> {
2271
+ const out = { circles: 0, pending: 0, failed: 0 };
2272
+ // A circle that could not be adopted is kept with what the host served and retried by every
2273
+ // sync; a failure never advances past it silently, and never undoes another circle.
2274
+ let retry: BootstrapRetryRow[] = state.retry ?? [];
2275
+ let cursor = state.cursor;
2276
+ let pagesDone = state.pagesDone === true;
2277
+ const adopt = async (c: CircleListing): Promise<boolean> => {
2278
+ try {
2279
+ const r = await this.adoptListing(c, report);
2280
+ if (r === "member") out.circles++;
2281
+ else if (r === "pending") out.pending++;
2282
+ return true;
2283
+ } catch (e) {
2284
+ out.failed++;
2285
+ report.errors.push(syncError("bootstrap", e, { circleId: c.circleId }));
2286
+ return false;
2287
+ }
2288
+ };
2289
+ const save = (done: boolean): Promise<void> =>
2290
+ this.store.transaction((tx) => {
2291
+ const meta = tx.get("meta", META);
2292
+ if (!meta) return;
2293
+ const bootstrap = done && retry.length === 0 ? { complete: true } : { ...(cursor === undefined ? {} : { cursor }), complete: false, retry, pagesDone: done };
2294
+ tx.put("meta", META, { ...meta, bootstrap });
2295
+ t9MarkVault(tx);
2296
+ });
2297
+ const stillFailing: BootstrapRetryRow[] = [];
2298
+ for (const r of retry) {
2299
+ const listing: CircleListing = { circleId: fromHex(r.circleId), ownerAccountId: fromHex(r.ownerAccountId), role: r.role, snapshot: r.snapshot === undefined ? undefined : fromHex(r.snapshot), enabled: r.enabled, preferenceRevision: unbig(r.preferenceRevision) };
2300
+ if (!(await adopt(listing))) stillFailing.push(r);
2301
+ }
2302
+ retry = stillFailing;
2303
+ for (let pages = 0; !pagesDone && pages < 64; pages++) {
2304
+ let page;
2305
+ try {
2306
+ page = await this.transport.directory.listCircles(cursor);
2307
+ } catch (e) {
2308
+ // What landed stays; the cursor says where to resume on the next sync.
2309
+ report.errors.push(syncError("bootstrap", e));
2310
+ await save(false);
2311
+ return out;
2312
+ }
2313
+ for (const c of page.circles) {
2314
+ if (!(await adopt(c))) retry.push({ circleId: toHex(c.circleId), ownerAccountId: toHex(c.ownerAccountId), role: c.role, snapshot: c.snapshot === undefined ? undefined : toHex(c.snapshot), enabled: c.enabled, preferenceRevision: big(c.preferenceRevision) });
2315
+ }
2316
+ cursor = page.cursor;
2317
+ if (cursor === undefined) pagesDone = true;
2318
+ await save(pagesDone);
2319
+ }
2320
+ await save(pagesDone);
2321
+ return out;
2322
+ }
2323
+
2324
+ private async adoptListing(c: CircleListing, report: SyncReport): Promise<"member" | "pending" | "skipped"> {
2325
+ const stage = "bootstrap";
2326
+ const { pin, account } = this.requireAccount(stage);
2327
+ const key = toHex(c.circleId);
2328
+ const existing = await this.store.read((tx) => tx.get("circles", key));
2329
+ if (existing && (existing.revision !== "0" || existing.status === "requested")) return "skipped"; // a restart between pages
2330
+ if (!c.snapshot) return "skipped"; // closed: nothing to pin, nothing to reopen
2331
+ const ownerIsMe = equalBytes(c.ownerAccountId, pin.accountId);
2332
+ let ownerPin = ownerIsMe ? pin : await this.pinFor(c.ownerAccountId);
2333
+ if (!ownerPin) {
2334
+ const [r] = await this.transport.directory.resolveIdentities([c.ownerAccountId]);
2335
+ if (r) await this.acceptMemberCertificate(r, report);
2336
+ ownerPin = await this.pinFor(c.ownerAccountId);
2337
+ }
2338
+ if (!ownerPin) throw new KavachError("METADATA_REQUIRED", stage, "the circle's owner cannot be resolved");
2339
+ const enabled = c.enabled === true;
2340
+ const base = pinCircle(c.circleId, c.ownerAccountId, enabled, c.role === "pending" ? "requested" : "member");
2341
+ const accepted = await this.acceptSnapshotFrom(base, c.snapshot, ownerPin, this.now());
2342
+ if (accepted.outcome !== "ACCEPTED") throw new KavachError("MALFORMED", stage, "an empty snapshot");
2343
+ let state = accepted.state;
2344
+ const named = c.role === "owner" ? equalBytes(state.ownerIdentityId, pin.accountId) : c.role === "member" ? state.members.some((m) => equalBytes(m, pin.accountId)) : true;
2345
+ if (!named) throw new KavachError("NOT_AUTHORIZED", stage, "the snapshot does not name this account as the host says");
2346
+ if (c.role === "pending") state = { ...state, status: "requested" };
2347
+ // The successor owner republishes what it was served at the next revision; the host's
2348
+ // compare-and-swap decides.
2349
+ const republish = ownerIsMe ? buildCircleSnapshot({ circleId: c.circleId, ownerIdentityId: pin.accountId, revision: state.revision + 1n, members: state.members }, account.account.secretKey) : undefined;
2350
+ await this.store.transaction((tx) => {
2351
+ const row = circleToRow(state);
2352
+ t3Circle(tx, republish ? { ...row, pending: { snapshot: toHex(republish), revision: big(state.revision + 1n) } } : row, { kind: "snapshot", circleId: c.circleId, revision: state.revision });
2353
+ if (state.status === "member") t3ApplyChange(tx, { kind: "join", circleId: c.circleId });
2354
+ tx.put("preferences", key, { circleId: key, enabled, revision: big(c.preferenceRevision), pending: undefined, nextSequence: "1" });
2355
+ });
2356
+ this.emit({ kind: "circleChanged", circleId: c.circleId, revision: state.revision, status: state.status });
2357
+ if (republish) {
2358
+ try {
2359
+ await this.publishPendingSnapshot(c.circleId);
2360
+ } catch (e) {
2361
+ report.errors.push(syncError("publishSnapshot", e));
2362
+ }
2363
+ }
2364
+ return state.status === "requested" ? "pending" : "member";
2365
+ }
2366
+
1353
2367
  // Reset -----------------------------------------------------------------------------------------
1354
2368
 
1355
- async resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
2369
+ private async resetAccountNow(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
1356
2370
  const stage = "resetAccount";
1357
2371
  if (!options.confirmed) throw new KavachError("NOT_AUTHORIZED", stage, "reset requires confirmation");
1358
2372
  const { pin } = this.requireAccount(stage);
@@ -1363,13 +2377,13 @@ class ClientImpl implements Client {
1363
2377
  const certificate = certify(account, device, 1n);
1364
2378
  const newPin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice));
1365
2379
  const recoveryKey = newRecoveryKey();
1366
- const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey);
2380
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey) : undefined;
1367
2381
  const resetId = randomBytes(16);
1368
2382
  const instanceId = randomBytes(16);
1369
2383
  // Persisted before it is sent: the host may commit the new identity and the response be lost,
1370
2384
  // and the only keys that can continue are these. Roots in the secret store, wires in the store.
1371
2385
  await this.secrets.set("pendingReset", concat(accountSeed, device.deviceSeed, recoveryKey, instanceId, device.deviceId));
1372
- await this.store.transaction((tx) => tx.put("pendingReset", "pending", { resetId: toHex(resetId), oldIdentityId: toHex(pin.identityId), certificate: toHex(certificate), vault: toHex(vault.wire), vaultHash: toHex(vault.hash) }));
2386
+ await this.store.transaction((tx) => tx.put("pendingReset", "pending", { resetId: toHex(resetId), oldIdentityId: toHex(pin.identityId), certificate: toHex(certificate), vault: vault ? toHex(vault.wire) : undefined, vaultHash: vault ? toHex(vault.hash) : undefined }));
1373
2387
  return this.resumeReset(stage, options.reauthProof);
1374
2388
  }
1375
2389
 
@@ -1380,7 +2394,7 @@ class ClientImpl implements Client {
1380
2394
  if (!row || !roots || roots.length !== RESET_ROOTS_BYTES) throw new KavachError("RECOVERY_REQUIRED", stage, "reset pending without its roots");
1381
2395
  const certificate = fromHex(row.certificate);
1382
2396
  expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage);
1383
- const outcome = await this.transport.directory.resetAccount({ accountName: this.accountName, expectedOldIdentityId: fromHex(row.oldIdentityId), newCertificate: certificate, newVault: fromHex(row.vault), resetId: fromHex(row.resetId), reauthProof: reauthProof ?? new Uint8Array() });
2397
+ const outcome = await this.transport.directory.resetAccount({ accountName: this.accountName, expectedOldIdentityId: fromHex(row.oldIdentityId), newCertificate: certificate, newVault: row.vault === undefined ? undefined : fromHex(row.vault), resetId: fromHex(row.resetId), reauthProof: reauthProof ?? new Uint8Array() });
1384
2398
  if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.receipt) throw new KavachError("NOT_AUTHORIZED", stage, `host refused reset: ${outcome.status}`);
1385
2399
  return this.finishReset(stage, row, roots);
1386
2400
  }
@@ -1402,11 +2416,11 @@ class ClientImpl implements Client {
1402
2416
  const newPin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage));
1403
2417
  await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
1404
2418
  await this.store.transaction((tx) => {
1405
- for (const table of ["meta", "self", "pins", "certificates", "quarantine", "circles", "revision", "vault", "epochs", "stream", "outbox", "history", "historyIndex", "enqueueKeys", "newest", "readerEpochs", "subscriptions", "unresolved", "activation", "pendingReset"] as const) {
2419
+ for (const table of ["meta", "self", "pins", "certificates", "quarantine", "circles", "revision", "vault", "epochs", "stream", "outbox", "history", "historyIndex", "enqueueKeys", "newest", "successions", "identities", "keyChanges", "replacement", "preferences", "chainProgress", "readerEpochs", "subscriptions", "unresolved", "activation", "pendingReset"] as const) {
1406
2420
  for (const [k] of tx.list(table)) tx.delete(table, k);
1407
2421
  }
1408
2422
  t1Header(tx, { account, device, certificate, instanceId, generation: 1n });
1409
- t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
2423
+ if (row.vault !== undefined && row.vaultHash !== undefined) t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
1410
2424
  t1Activate(tx);
1411
2425
  });
1412
2426
  await this.secrets.delete("pendingReset");
@@ -1414,7 +2428,7 @@ class ClientImpl implements Client {
1414
2428
  this.device = device;
1415
2429
  this.selfPin = newPin;
1416
2430
  try {
1417
- await this.provider.save(this.accountName, newPin.identityId, recoveryKey);
2431
+ await this.provider?.save(this.accountName, newPin.identityId, recoveryKey);
1418
2432
  } catch {
1419
2433
  /* reported by recoveryStatus() */
1420
2434
  }
@@ -1436,7 +2450,50 @@ class ClientImpl implements Client {
1436
2450
  }
1437
2451
 
1438
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]);
1439
2456
  this.listeners.clear();
1440
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
+ }
1441
2498
  }
1442
2499