@plane31/blind-relay 0.1.3 → 0.2.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 (81) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/index.d.ts +3 -3
  3. package/dist/adapters/index.d.ts.map +1 -1
  4. package/dist/adapters/index.js +1 -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 +78 -3
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +903 -85
  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.d.ts +1 -1
  19. package/dist/crypto.d.ts.map +1 -1
  20. package/dist/crypto.js.map +1 -1
  21. package/dist/errors.d.ts +9 -1
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +2 -1
  24. package/dist/errors.js.map +1 -1
  25. package/dist/identity.d.ts +8 -2
  26. package/dist/identity.d.ts.map +1 -1
  27. package/dist/identity.js +7 -3
  28. package/dist/identity.js.map +1 -1
  29. package/dist/index.d.ts +4 -4
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/policy.d.ts +13 -0
  34. package/dist/policy.d.ts.map +1 -1
  35. package/dist/policy.js +12 -1
  36. package/dist/policy.js.map +1 -1
  37. package/dist/rows.d.ts.map +1 -1
  38. package/dist/rows.js +2 -0
  39. package/dist/rows.js.map +1 -1
  40. package/dist/store.d.ts +114 -3
  41. package/dist/store.d.ts.map +1 -1
  42. package/dist/store.js.map +1 -1
  43. package/dist/succession.d.ts +108 -0
  44. package/dist/succession.d.ts.map +1 -0
  45. package/dist/succession.js +205 -0
  46. package/dist/succession.js.map +1 -0
  47. package/dist/testing/clock.d.ts.map +1 -1
  48. package/dist/testing/clock.js +1 -0
  49. package/dist/testing/clock.js.map +1 -1
  50. package/dist/testing/fakeHost.d.ts +60 -1
  51. package/dist/testing/fakeHost.d.ts.map +1 -1
  52. package/dist/testing/fakeHost.js +201 -31
  53. package/dist/testing/fakeHost.js.map +1 -1
  54. package/dist/testing/faults.d.ts +5 -3
  55. package/dist/testing/faults.d.ts.map +1 -1
  56. package/dist/testing/faults.js +24 -3
  57. package/dist/testing/faults.js.map +1 -1
  58. package/dist/transactions.d.ts +6 -0
  59. package/dist/transactions.d.ts.map +1 -1
  60. package/dist/transactions.js +11 -5
  61. package/dist/transactions.js.map +1 -1
  62. package/dist/transport.d.ts +66 -2
  63. package/dist/transport.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/adapters/index.ts +13 -0
  66. package/src/audience.ts +7 -6
  67. package/src/client.ts +881 -90
  68. package/src/codec.ts +64 -1
  69. package/src/crypto.ts +1 -0
  70. package/src/errors.ts +12 -1
  71. package/src/identity.ts +13 -3
  72. package/src/index.ts +5 -3
  73. package/src/policy.ts +23 -1
  74. package/src/rows.ts +2 -0
  75. package/src/store.ts +110 -3
  76. package/src/succession.ts +270 -0
  77. package/src/testing/clock.ts +1 -0
  78. package/src/testing/fakeHost.ts +204 -28
  79. package/src/testing/faults.ts +28 -7
  80. package/src/transactions.ts +13 -6
  81. 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,21 @@ 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[]>;
131
170
  /** A durable outbound record of a declared kind; the payload is the product's bytes, never read by the SDK. */
132
171
  enqueue(kind: RecordKind, payload: Uint8Array, options?: EnqueueOptions): Promise<EnqueueResult>;
133
172
  /** One synchronisation; `options.catchUp` bounds how much of a backlog this call reads (`SyncReport.inbox.hasMore` says when to call again). */
@@ -169,15 +208,39 @@ export interface SyncOptions {
169
208
  export const DEFAULT_CATCH_UP = { maxRecords: 500, maxMs: 5_000 } as const;
170
209
 
171
210
  export interface SyncReport {
172
- metadata: { certificates: number; snapshots: number; rotations: number; quarantined: number; resets: number };
211
+ metadata: { certificates: number; snapshots: number; rotations: number; quarantined: number; resets: number; successions: number };
173
212
  epoch: "unchanged" | "prepared" | "paused" | "pending";
174
213
  outbox: { sent: number; accepted: number; cancelled: number; unknown: number; retry: number };
175
214
  /** `hasMore`: a stream still has records on the relay that this sync's budget did not reach; sync again soon. */
176
215
  inbox: { streams: number; stored: number; keyPending: number; invalid: number; acked: number; hasMore: boolean };
177
216
  purged: number;
217
+ /** Present while account recovery discovers circles through the host: pinned, pending and failed on this sync. */
218
+ bootstrap?: { circles: number; pending: number; failed: number };
178
219
  errors: SyncError[];
179
220
  }
180
221
 
222
+ /** What `prepareReplacement()` returns: everything the product hands to the host for authorization. All public. */
223
+ export interface ReplacementProposal {
224
+ readonly successionId: Uint8Array;
225
+ readonly accountName: string;
226
+ readonly accountId: Uint8Array;
227
+ readonly expectedCertificateHash: Uint8Array;
228
+ readonly expectedGeneration: bigint;
229
+ readonly certificate: Uint8Array;
230
+ }
231
+
232
+ /** One accepted key change of a contact: a verified succession or a validated reset. */
233
+ export interface KeyChangeEntry {
234
+ readonly accountId: Uint8Array;
235
+ readonly accountName: string;
236
+ readonly previousIdentityId: Uint8Array;
237
+ readonly newIdentityId: Uint8Array;
238
+ readonly generation: bigint;
239
+ readonly reason: "succession" | "reset";
240
+ readonly successionId: Uint8Array | undefined;
241
+ readonly acceptedAt: bigint;
242
+ }
243
+
181
244
  /** One failure a sync worked around: which step, the protocol code and stage, and whether the same step may succeed later unchanged. */
182
245
  export interface SyncError {
183
246
  readonly operation: string;
@@ -191,10 +254,12 @@ export interface SyncError {
191
254
  */
192
255
  readonly streamId?: Uint8Array;
193
256
  readonly authorIdentityId?: Uint8Array;
257
+ /** On a `bootstrap` entry: the circle that could not be adopted on this sync; it is retried by the next. */
258
+ readonly circleId?: Uint8Array;
194
259
  }
195
260
 
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 };
261
+ function syncError(operation: string, e: unknown, extra: Partial<Pick<SyncError, "circleId" | "streamId" | "authorIdentityId">> = {}): SyncError {
262
+ return e instanceof KavachError ? { operation, code: e.code, stage: e.stage, retryable: e.retryable, ...extra } : { operation, code: "UNEXPECTED", stage: operation, retryable: false, ...extra };
198
263
  }
199
264
 
200
265
  const RESET_LIST_RECHECK_MS = 86_400_000n;
@@ -202,6 +267,7 @@ const RESET_LIST_RECHECK_MS = 86_400_000n;
202
267
  export async function open(options: OpenOptions): Promise<Client> {
203
268
  await ready();
204
269
  const policy = validatePolicy(options.retentionPolicy);
270
+ if (!options.recoveryProvider && allowsContinuity(policy)) throw new KavachError("MALFORMED", "open", "recoveryProvider is required when the profile enables identity continuity");
205
271
  const impl = new ClientImpl(options, policy);
206
272
  await impl.load();
207
273
  return impl;
@@ -212,14 +278,22 @@ class ClientImpl implements Client {
212
278
  private readonly store: Store;
213
279
  private readonly secrets: SecretStore;
214
280
  private readonly transport: Transport;
215
- private readonly provider: RecoveryProvider;
281
+ private readonly provider: RecoveryProvider | undefined;
282
+ /** A vault exists only where a recovery provider does: a recovery-only profile without one has no path that reads it. */
283
+ private get vaultEnabled(): boolean {
284
+ return this.provider !== undefined;
285
+ }
216
286
  private readonly now: () => bigint;
217
287
  private readonly listeners = new Set<(e: ClientEvent) => void>();
218
288
  private account: OwnAccount | undefined;
289
+ /** Retired by a succession or a later device: nothing is written from here again. */
290
+ private retired = false;
219
291
  private device: DeviceKeys | undefined;
220
292
  private selfPin: AccountPin | undefined;
221
293
  private trust: ResetTrust;
222
294
  private syncing: Promise<SyncReport> | undefined;
295
+ /** The current sync's reading budget: records, and the links of a succession chain, count against it. */
296
+ private syncBudget: ReadBudget | undefined;
223
297
  private backupChecked = false;
224
298
  readonly circles: Client["circles"];
225
299
 
@@ -252,14 +326,16 @@ class ClientImpl implements Client {
252
326
  await this.finishInterruptedReset();
253
327
  const opened = await openStore(this.store, this.secrets, this.accountName);
254
328
  if (opened.kind === "fresh") return;
329
+ this.retired = opened.kind === "RETIRED";
255
330
  await ensureHistoryIndex(this.store);
256
331
  await ensureSlots(this.store, this.policy);
257
332
  this.account = opened.account;
258
333
  const deviceSeed = await this.secrets.get("deviceSeed");
259
334
  const self = await this.store.read((tx) => tx.get("self", "self"));
260
- if (deviceSeed && self) {
335
+ if (deviceSeed && self && opened.account) {
336
+ const accountId = opened.account.accountId;
261
337
  this.device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
262
- const pinRow = await this.store.read((tx) => tx.get("pins", toHex(opened.account.identityId)));
338
+ const pinRow = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
263
339
  if (pinRow) this.selfPin = rowToPin(pinRow);
264
340
  }
265
341
  const rows = await this.store.read((tx) => ({
@@ -299,13 +375,14 @@ class ClientImpl implements Client {
299
375
  }
300
376
 
301
377
  private requireAccount(stage: string): { account: OwnAccount; device: DeviceKeys; pin: AccountPin } {
378
+ if (this.retired) throw new KavachError("SUPERSEDED_DEVICE", stage, "this device is retired");
302
379
  if (!this.account || !this.device || !this.selfPin) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no account on this device");
303
380
  return { account: this.account, device: this.device, pin: this.selfPin };
304
381
  }
305
382
 
306
383
  private selfView(): { identityId: Uint8Array; certificateHash: Uint8Array; box: BoxKeypair } {
307
384
  const { device, pin } = this.requireAccount("self");
308
- return { identityId: pin.identityId, certificateHash: pin.certificateHash, box: device.box };
385
+ return { identityId: pin.accountId, certificateHash: pin.certificateHash, box: device.box };
309
386
  }
310
387
 
311
388
  private outboxDeps() {
@@ -315,7 +392,7 @@ class ClientImpl implements Client {
315
392
  relay: this.transport.relay,
316
393
  policy: this.policy,
317
394
  now: this.now,
318
- author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.identityId }),
395
+ author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.accountId }),
319
396
  events: {
320
397
  queued: (recordId: Uint8Array, recordKind: RecordKind) => this.emit({ kind: "queued", recordId, recordKind }),
321
398
  accepted: (recordId: Uint8Array) => this.emit({ kind: "accepted", recordId }),
@@ -347,8 +424,11 @@ class ClientImpl implements Client {
347
424
 
348
425
  async getState(): Promise<ClientState> {
349
426
  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 };
427
+ 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[] };
428
+ if (!meta || (!this.account && meta.state !== "RETIRED")) {
429
+ const proposal = await this.store.read((tx) => tx.get("replacement", "pending"));
430
+ return { status: proposal && !proposal.stale ? "replacementPrepared" : "fresh", paused: undefined, ...base };
431
+ }
352
432
  const circles = await this.listCircles();
353
433
  const outbox = await outboxCount(this.store);
354
434
  const waitingFor = await this.waitingFor();
@@ -365,7 +445,7 @@ class ClientImpl implements Client {
365
445
 
366
446
  private async waitingFor(): Promise<Uint8Array[]> {
367
447
  if (!this.selfPin) return [];
368
- const selfHex = toHex(this.selfPin.identityId);
448
+ const selfHex = toHex(this.selfPin.accountId);
369
449
  return this.store.read((tx) => {
370
450
  const authors = new Set<string>();
371
451
  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 +466,15 @@ class ClientImpl implements Client {
386
466
  const stream = tx.get("stream", STREAM);
387
467
  const revision = tx.get("revision", REVISION);
388
468
  const vault = tx.get("vault", VAULT);
469
+ const meta = tx.get("meta", META);
470
+ const proposal = tx.get("replacement", "pending");
389
471
  const pendingEpoch = tx.list("epochs").find(([, e]) => e.status === "prepared" || e.status === "committed")?.[1].epoch;
390
472
  return {
391
- identityId: this.selfPin?.identityId,
473
+ identityId: this.selfPin?.accountId,
474
+ keyIdentityId: this.selfPin?.identityId,
475
+ replacementModel: this.policy.replacement,
476
+ replacement: proposal ? { successionId: fromHex(proposal.successionId), expectedGeneration: unbig(proposal.expectedGeneration), stale: proposal.stale, conflictGeneration: proposal.conflictGeneration === undefined ? undefined : unbig(proposal.conflictGeneration) } : undefined,
477
+ bootstrap: meta?.bootstrap ? { cursor: meta.bootstrap.cursor, complete: meta.bootstrap.complete } : undefined,
392
478
  certificateHash: this.selfPin?.certificateHash,
393
479
  generation: this.selfPin?.generation,
394
480
  desiredRevision: revision ? unbig(revision.desiredRevision) : 1n,
@@ -424,8 +510,8 @@ class ClientImpl implements Client {
424
510
  const pin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice));
425
511
  const iid = instanceId;
426
512
  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 });
513
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
514
+ const outcome = await this.transport.directory.registerAccount({ certificate, vault: vault?.wire });
429
515
  if (outcome.status === "CONFLICT") {
430
516
  // A restart or a lost response after the host committed: the name is ours if the host holds
431
517
  // exactly the certificate this device saved; anything else is somebody else's account.
@@ -435,7 +521,7 @@ class ClientImpl implements Client {
435
521
  throw new KavachError("NOT_AUTHORIZED", stage, `host refused registration: ${outcome.status}`);
436
522
  }
437
523
  await this.store.transaction((tx) => {
438
- t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
524
+ if (vault) t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
439
525
  t1Activate(tx);
440
526
  });
441
527
  this.account = account;
@@ -443,7 +529,7 @@ class ClientImpl implements Client {
443
529
  this.selfPin = pin;
444
530
  // The recovery key goes to the provider; enrollment never blocks on it or on the code.
445
531
  try {
446
- await this.provider.save(this.accountName, pin.identityId, recoveryKey);
532
+ await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
447
533
  } catch {
448
534
  /* status is reported by recoveryStatus() */
449
535
  }
@@ -452,10 +538,14 @@ class ClientImpl implements Client {
452
538
  }
453
539
 
454
540
  async recoveryStatus(): Promise<ProviderStatusResult> {
541
+ if (!this.provider) return { status: "unsupported", backupState: "unavailable", reason: "no recovery provider on a recovery-only profile" };
455
542
  return this.provider.status();
456
543
  }
457
544
 
458
545
  async recoveryCode(): Promise<string> {
546
+ // A code opens a vault for identity continuity; where the profile disables that, or where no
547
+ // vault exists, the code would open nothing and is not issued.
548
+ if (!allowsContinuity(this.policy) || !this.vaultEnabled) throw new KavachError("NOT_AUTHORIZED", "recoveryCode", "this deployment does not restore accounts by recovery code");
459
549
  const key = await this.secrets.get("recoveryKey");
460
550
  if (!key) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", "recoveryCode");
461
551
  return encodeRecoveryCode(key);
@@ -473,12 +563,18 @@ class ClientImpl implements Client {
473
563
  // moved under us): prepare again with the same device keys. Nothing was allocated remotely.
474
564
  pending = resumed;
475
565
  }
566
+ if (!allowsContinuity(this.policy)) throw new KavachError("NOT_AUTHORIZED", stage, "this deployment replaces devices through successions, not the recovery key");
476
567
  const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
477
568
  if (!resolved) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
478
569
  const current = expectType<AccountDevice>(verifyDocument(resolved.certificate), DocumentType.AccountDevice, stage);
479
570
  const vaultWire = await this.transport.directory.getVault(this.accountName);
480
571
  if (!vaultWire) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no vault");
572
+ const accountId = resolvedAccountId(resolved);
481
573
  const identity = pinFromCertificate(current).identityId;
574
+ if (!this.provider) throw new KavachError("NOT_AUTHORIZED", stage, "identity continuity needs a recovery provider");
575
+ // An account that succeeded: the served chain is verified link by link under the current list,
576
+ // from the root to the key this device is about to hold, before anything is written.
577
+ const verified = resolved.successions?.length ? (await this.followChain({ accountId, accountName: this.accountName, finalIdentityId: identity, firstPage: resolved.successions, next: resolved.successionsNext })).verified : [];
482
578
  const recovered = await recoverAccount({
483
579
  accountName: this.accountName,
484
580
  identityId: identity,
@@ -487,12 +583,12 @@ class ClientImpl implements Client {
487
583
  provider: this.provider,
488
584
  ...(recoveryCode === undefined ? {} : { recoveryCode })
489
585
  });
490
- const account = openAccount(this.accountName, recovered.vault.accountSeed);
586
+ const account = openAccount(this.accountName, recovered.vault.accountSeed, accountId);
491
587
  // Fresh device keys and a fresh local store. Nothing from the old device is copied.
492
588
  const device = pending?.device ?? newDevice();
493
589
  const generation = current.generation + 1n;
494
590
  const certificate = certify(account, device, generation);
495
- const pin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice));
591
+ const pin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice), undefined, accountId);
496
592
  const header = parseVaultHeader(vaultWire);
497
593
  const nextVault = buildVault(
498
594
  { accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: header.vaultRevision + 1n },
@@ -503,6 +599,8 @@ class ClientImpl implements Client {
503
599
  if (!pending) await t1Secrets(this.secrets, { accountSeed: recovered.vault.accountSeed, deviceSeed: device.deviceSeed, recoveryKey: recovered.recoveryKey, instanceId });
504
600
  await this.store.transaction((tx) => {
505
601
  t1Header(tx, { account, device, certificate, instanceId, generation });
602
+ recordChainIn(tx, verified, this.now());
603
+ rootIdentityIn(tx, accountId, this.accountName, resolved.rootCertificate);
506
604
  t2PrepareActivation(tx, {
507
605
  certificate: toHex(certificate),
508
606
  vault: toHex(nextVault.wire),
@@ -538,7 +636,7 @@ class ClientImpl implements Client {
538
636
  tx.delete("activation", "pending");
539
637
  t9Vault(tx, { revision: big(input.vaultRevision), hash: toHex(input.vault.hash), wire: toHex(input.vault.wire), pending: false, dirty: false });
540
638
  for (const e of input.entries) {
541
- const owned = equalBytes(e.ownerIdentityId, pin.identityId);
639
+ const owned = equalBytes(e.ownerIdentityId, pin.accountId);
542
640
  const c = pinCircle(e.circleId, e.ownerIdentityId, e.sharingEnabled, owned ? "member" : "requested");
543
641
  tx.put("circles", toHex(e.circleId), circleToRow(c));
544
642
  }
@@ -549,7 +647,7 @@ class ClientImpl implements Client {
549
647
  this.device = device;
550
648
  this.selfPin = pin;
551
649
  try {
552
- await this.provider.save(this.accountName, pin.identityId, input.recoveryKey);
650
+ await this.provider?.save(this.accountName, pin.identityId, input.recoveryKey);
553
651
  } catch {
554
652
  /* reported by recoveryStatus() */
555
653
  }
@@ -568,13 +666,14 @@ class ClientImpl implements Client {
568
666
  const row = await this.store.read((tx) => tx.get("activation", "pending"));
569
667
  if (!row) throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its prepared bytes");
570
668
  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);
669
+ const [self, meta] = await this.store.read((tx) => [tx.get("self", "self"), tx.get("meta", META)] as const);
670
+ if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId || !self || !meta) throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its roots");
671
+ const accountId = fromHex(meta.accountId ?? meta.identityId);
672
+ const account = openAccount(this.accountName, accountSeed, accountId);
574
673
  const device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
575
674
  const certificate = fromHex(row.certificate);
576
675
  const prepared = expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage);
577
- const pin = pinFromCertificate(prepared);
676
+ const pin = pinFromCertificate(prepared, undefined, accountId);
578
677
  const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
579
678
  if (!me) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
580
679
  const theirs = expectType<AccountDevice>(verifyDocument(me.certificate), DocumentType.AccountDevice, stage);
@@ -603,7 +702,7 @@ class ClientImpl implements Client {
603
702
  * asks the provider and saves again when it has nothing for this identity.
604
703
  */
605
704
  private async ensureBackup(): Promise<void> {
606
- if (this.backupChecked || !this.selfPin) return;
705
+ if (this.backupChecked || !this.selfPin || !this.provider) return;
607
706
  this.backupChecked = true;
608
707
  try {
609
708
  const key = await this.secrets.get("recoveryKey");
@@ -646,7 +745,7 @@ class ClientImpl implements Client {
646
745
 
647
746
  /** After commit: the device's own record is stored like any author's, so one subscription covers self and others. */
648
747
  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 });
748
+ if (!r.duplicate && this.selfPin) this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.accountId, recordId: r.recordId, tookSlot: r.own });
650
749
  return { recordId: r.recordId, duplicate: r.duplicate };
651
750
  }
652
751
 
@@ -656,7 +755,11 @@ class ClientImpl implements Client {
656
755
  const { pin } = this.requireAccount("audience");
657
756
  const retired = new Set(this.trust.state.retired);
658
757
  return this.store.read((tx) => {
758
+ // A reset retires the whole account, however many successions it went through.
759
+ for (const [, r] of tx.list("identities")) if (r.resetAt !== undefined) retired.add(r.accountId);
659
760
  const pins = new Map(tx.list("pins").map(([k, p]) => [k, rowToPin(p)]));
761
+ // An account whose chain this device has not finished verifying is out of every audience until it has.
762
+ for (const [, p] of tx.list("chainProgress")) if (!p.complete) pins.delete(p.accountId);
660
763
  const circles = tx.list("circles").map(([, c]) => rowToCircle(c));
661
764
  return resolveAudience(pin, circles, pins, retired);
662
765
  });
@@ -692,7 +795,7 @@ class ClientImpl implements Client {
692
795
  } else {
693
796
  const revision = revisionRow ? unbig(revisionRow.desiredRevision) : 1n;
694
797
  const fresh = prepareEpoch({
695
- author: { identityId: pin.identityId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
798
+ author: { identityId: pin.accountId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
696
799
  epoch: stream.highestCommittedEpoch + 1,
697
800
  createdAt: this.now(),
698
801
  audience,
@@ -744,13 +847,30 @@ class ClientImpl implements Client {
744
847
 
745
848
  private async runSync(options: SyncOptions): Promise<SyncReport> {
746
849
  const report: SyncReport = {
747
- metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0 },
850
+ metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
748
851
  epoch: "unchanged",
749
852
  outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
750
853
  inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
751
854
  purged: 0,
752
855
  errors: []
753
856
  };
857
+ this.syncBudget = {
858
+ remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
859
+ deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
860
+ };
861
+ if (!this.selfPin) {
862
+ // A prepared replacement whose commit this device never saw: the chain says whether it landed.
863
+ const row = await this.store.read((tx) => tx.get("replacement", "pending"));
864
+ if (row && !row.stale) {
865
+ try {
866
+ const link = await this.findOwnLink(row);
867
+ const roots = await this.secrets.get("pendingReplacement");
868
+ if (link && roots && roots.length === RESET_ROOTS_BYTES) await this.activateReplacement(row, roots, link.wire);
869
+ } catch (e) {
870
+ report.errors.push(syncError("resumeReplacement", e));
871
+ }
872
+ }
873
+ }
754
874
  if (!this.account || !this.selfPin) return report;
755
875
  const meta = await this.store.read((tx) => tx.get("meta", META));
756
876
  if (!meta || meta.state === "RETIRED") return report;
@@ -776,11 +896,23 @@ class ClientImpl implements Client {
776
896
  }
777
897
  } else if (meta.state !== "ACTIVE") return report;
778
898
  await this.ensureBackup();
899
+ if (meta.bootstrap && !meta.bootstrap.complete) {
900
+ try {
901
+ report.bootstrap = await this.bootstrap(report, meta.bootstrap);
902
+ } catch (e) {
903
+ report.errors.push(syncError("bootstrap", e));
904
+ }
905
+ }
779
906
  try {
780
907
  await this.syncMetadata(report);
781
908
  } catch (e) {
782
909
  report.errors.push(syncError("metadata", e));
783
910
  }
911
+ try {
912
+ await this.pushPreferences(report);
913
+ } catch (e) {
914
+ report.errors.push(syncError("preference", e));
915
+ }
784
916
  const after = await this.store.read((tx) => tx.get("meta", META));
785
917
  if (after?.state === "RETIRED") return report;
786
918
  try {
@@ -802,10 +934,8 @@ class ClientImpl implements Client {
802
934
  // Each stream on its own: a failed download or a refused record names its stream in the report
803
935
  // and leaves the other streams to be read; a stream that failed is not counted as synced.
804
936
  // 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
- };
937
+ const budget: ReadBudget = this.syncBudget;
938
+ if (budget.remainingRecords < 1) budget.remainingRecords = 1; // every stream reads at least one page
809
939
  for (const s of subs) {
810
940
  try {
811
941
  const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
@@ -833,7 +963,16 @@ class ClientImpl implements Client {
833
963
  if (me) {
834
964
  const theirs = expectType<AccountDevice>(verifyDocument(me.certificate), DocumentType.AccountDevice, "sync");
835
965
  if (!equalBytes(theirs.accountKey, pin.accountKey)) {
836
- // Our own key changed at the host: only a receipt we produced explains that; otherwise ignore.
966
+ // Our own key changed at the host. A succession from our identity, attested by an authority
967
+ // we trust, retires this device (its key is no longer the account's); a bare key change or a
968
+ // reset we did not make is ignored here, as before.
969
+ if (await this.supersededBySuccession(pin, me, report)) {
970
+ await this.retire();
971
+ // The key itself is retired, not only the device: nothing of it stays here.
972
+ await this.secrets.delete("accountSeed");
973
+ this.account = undefined;
974
+ return;
975
+ }
837
976
  } else if (!equalBytes(theirs.bodyHash, pin.certificateHash) && theirs.generation > pin.generation) {
838
977
  await this.retire();
839
978
  return;
@@ -856,7 +995,7 @@ class ClientImpl implements Client {
856
995
  // Unconfirmed writes of our own first: a successor snapshot the host never took, a vault update
857
996
  // it never saw. Until they land, peers read stale membership and a replacement phone a stale
858
997
  // 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)));
998
+ 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
999
  for (const circleId of pendingOwned) {
861
1000
  try {
862
1001
  if ((await this.publishPendingSnapshot(circleId)) === "COMMITTED") report.metadata.snapshots++;
@@ -874,15 +1013,15 @@ class ClientImpl implements Client {
874
1013
  const unknownOwners: Uint8Array[] = [];
875
1014
  for (const c of circles) {
876
1015
  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);
1016
+ if (!equalBytes(c.ownerIdentityId, pin.accountId) && !(await this.pinFor(c.ownerIdentityId))) unknownOwners.push(c.ownerIdentityId);
878
1017
  }
879
1018
  if (unknownOwners.length > 0) {
880
- for (const r of await this.transport.directory.resolveIdentities(unknownOwners)) await this.acceptMemberCertificate(r.certificate, r.resetReceipt, report);
1019
+ for (const r of await this.transport.directory.resolveIdentities(unknownOwners)) await this.acceptMemberCertificate(r, report);
881
1020
  }
882
1021
  const memberIds = new Map<string, Uint8Array>();
883
1022
  for (const c of circles) {
884
1023
  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);
1024
+ const ownerPin = equalBytes(c.ownerIdentityId, pin.accountId) ? pin : await this.pinFor(c.ownerIdentityId);
886
1025
  if (!ownerPin) {
887
1026
  memberIds.set(toHex(c.ownerIdentityId), c.ownerIdentityId);
888
1027
  continue;
@@ -890,7 +1029,7 @@ class ClientImpl implements Client {
890
1029
  const wire = await this.transport.directory.getCircle(c.circleId);
891
1030
  if (wire) {
892
1031
  try {
893
- const r = acceptSnapshot(c, wire, ownerPin.accountKey, pin.identityId, now);
1032
+ const r = await this.acceptSnapshotFrom(c, wire, ownerPin, now, report);
894
1033
  if (r.outcome === "ACCEPTED") {
895
1034
  // The row may have moved since it was read: a sharing preference, a leave or a close
896
1035
  // made meanwhile wins over the fetched snapshot, and a newer snapshot already stored
@@ -920,46 +1059,299 @@ class ClientImpl implements Client {
920
1059
  for (const m of c.members) memberIds.set(toHex(m), m);
921
1060
  }
922
1061
  } else for (const m of c.members) memberIds.set(toHex(m), m);
923
- if (equalBytes(c.ownerIdentityId, pin.identityId) && c.status === "member") {
1062
+ if (equalBytes(c.ownerIdentityId, pin.accountId) && c.status === "member") {
924
1063
  const requests = await this.transport.directory.readRequests(c.circleId);
925
1064
  const joins = requests.filter((r) => r.action === "join").map((r) => r.accountName);
926
1065
  if (joins.length) this.emit({ kind: "joinRequests", circleId: c.circleId, accountNames: joins });
927
1066
  for (const r of requests.filter((r) => r.action === "leave")) {
928
1067
  const [acct] = await this.transport.directory.resolveAccounts([r.accountName]);
929
1068
  if (!acct) continue;
930
- const leaver = pinFromCertificate(expectType<AccountDevice>(verifyDocument(acct.certificate), DocumentType.AccountDevice));
931
1069
  try {
932
- await this.changeMembership(c.circleId, { remove: leaver.identityId });
1070
+ await this.changeMembership(c.circleId, { remove: resolvedAccountId(acct) });
933
1071
  } catch (e) {
934
1072
  report.errors.push(syncError("removeLeaver", e));
935
1073
  }
936
1074
  }
937
1075
  }
938
1076
  }
939
- memberIds.delete(toHex(pin.identityId));
1077
+ memberIds.delete(toHex(pin.accountId));
940
1078
  if (memberIds.size > 0) {
941
1079
  const resolved = await this.transport.directory.resolveIdentities([...memberIds.values()]);
942
1080
  const byIdentity = new Map<string, { accountName: string; accountKey: Uint8Array }>();
943
1081
  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);
1082
+ expectType<AccountDevice>(verifyDocument(r.certificate), DocumentType.AccountDevice, "sync");
1083
+ byIdentity.set(toHex(resolvedAccountId(r)), { accountName: r.accountName, accountKey: r.accountKey });
1084
+ await this.acceptMemberCertificate(r, report);
948
1085
  }
949
1086
  checkMemberResolution(byIdentity);
950
1087
  }
951
1088
  }
952
1089
 
953
- private async pinFor(identityId: Uint8Array): Promise<AccountPin | undefined> {
954
- const row = await this.store.read((tx) => tx.get("pins", toHex(identityId)));
1090
+ /**
1091
+ * Has the host's chain for our own account moved past our key? A valid succession from our
1092
+ * current identity means an authority replaced this account's key; the device holding the old
1093
+ * key has nothing left to say. Deferred (no fresh list, untrusted clock) and invalid
1094
+ * attestations leave the device as it is.
1095
+ */
1096
+ private async supersededBySuccession(pin: AccountPin, me: ResolvedAccount, report: SyncReport): Promise<boolean> {
1097
+ if (!me.successions?.length) return false;
1098
+ try {
1099
+ const r = await this.followChain({ accountId: pin.accountId, accountName: pin.accountName, current: pin, firstPage: me.successions, next: me.successionsNext, budget: this.syncBudget });
1100
+ return r.pin !== undefined && !equalBytes(r.pin.identityId, pin.identityId);
1101
+ } catch (e) {
1102
+ // A chain this device cannot follow for its own account is a diagnostic the product must
1103
+ // see: the host has already fenced the device, and only this report says why it still runs.
1104
+ report.errors.push(syncError("ownSuccession", e));
1105
+ return false;
1106
+ }
1107
+ }
1108
+
1109
+ /**
1110
+ * Verify a snapshot under the owner's current key, or, when the owner's key changed through a
1111
+ * succession and the snapshot predates it, under the retired key it was signed with. The
1112
+ * successor republishes at the next revision; until then the older snapshot stays valid.
1113
+ */
1114
+ private async acceptSnapshotFrom(c: CircleState, wire: Uint8Array, ownerPin: AccountPin, now: bigint, report?: SyncReport, resolved = false): Promise<ReturnType<typeof acceptSnapshot>> {
1115
+ const { pin } = this.requireAccount("sync");
1116
+ // The very bytes this device already verified (a bootstrap snapshot under a key since retired)
1117
+ // are a duplicate whatever key signed them; only new bytes are verified.
1118
+ if (c.snapshot && equalBytes(c.snapshot, wire)) return { outcome: "DUPLICATE", state: c };
1119
+ try {
1120
+ return acceptSnapshot(c, wire, ownerPin.accountKey, pin.accountId, now);
1121
+ } catch (e) {
1122
+ if (!isKavachError(e, "BAD_SIGNATURE")) throw e;
1123
+ // A retired key may only vouch for a circle this device holds at revision 0 (bootstrap, first
1124
+ // contact): an established circle advances under the current owner key alone.
1125
+ const ownerHex = toHex(ownerPin.accountId);
1126
+ 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)] : []))) : [];
1127
+ for (const key of retiredKeys) {
1128
+ try {
1129
+ return acceptSnapshot(c, wire, key, pin.accountId, now);
1130
+ } catch (inner) {
1131
+ if (!isKavachError(inner, "BAD_SIGNATURE")) throw inner;
1132
+ }
1133
+ }
1134
+ // Signed by a key we do not know: the owner may have succeeded since we last resolved it.
1135
+ // The chain is verified first; only a key it produced may sign this circle.
1136
+ if (!resolved && report && !equalBytes(ownerPin.accountId, pin.accountId)) {
1137
+ const [r] = await this.transport.directory.resolveIdentities([ownerPin.accountId]);
1138
+ if (r && !equalBytes(r.accountKey, ownerPin.accountKey)) {
1139
+ await this.acceptMemberCertificate(r, report);
1140
+ const fresh = await this.pinFor(ownerPin.accountId);
1141
+ if (fresh && !equalBytes(fresh.accountKey, ownerPin.accountKey)) return this.acceptSnapshotFrom(c, wire, fresh, now, report, true);
1142
+ }
1143
+ }
1144
+ throw e;
1145
+ }
1146
+ }
1147
+
1148
+ private async pinFor(accountId: Uint8Array): Promise<AccountPin | undefined> {
1149
+ const row = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
955
1150
  return row ? rowToPin(row) : undefined;
956
1151
  }
957
1152
 
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> {
1153
+ /**
1154
+ * The one path every succession takes (section 4, "Chains"): the policy gate first, then the
1155
+ * key list, then each link verified in order from the start identity (the pinned identity for
1156
+ * an established contact, the account id at first contact or for a restoring device), page by
1157
+ * page within the sync's budget, with the verified prefix persisted so that the next sync
1158
+ * resumes instead of starting over. Only when the chain is complete does anything move: for a
1159
+ * pinned contact every link is applied in one transaction, so no epoch is ever sealed to an
1160
+ * intermediate successor; for a first contact or a restore the verified links are returned and
1161
+ * the caller records them with the pin or the header. Any failure leaves the pin where it was.
1162
+ */
1163
+ 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[] }> {
1164
+ const stage = "chain";
1165
+ if (!acceptsSuccession(this.policy)) throw new KavachError("UNSUPPORTED_TYPE", stage, "this deployment does not accept successions");
1166
+ const accountHex = toHex(input.accountId);
1167
+ const startIdentity = input.current?.identityId ?? input.accountId;
1168
+ await this.ensureResetList();
1169
+ // The state the chain is validated against: the pin with what this store retired, or the root.
1170
+ const current = input.current;
1171
+ let state = current ? await this.store.read((tx) => pinnedState(tx, current, this.trust)) : rootState(input.accountId, input.accountName, this.trust);
1172
+ // Reconcile first: the host serves the chain from the root, and a pinned contact already holds
1173
+ // a prefix of it. Links whose transition is on record are dropped (the same transition at a
1174
+ // recorded state; a different one is the authority signing two successors), so verification
1175
+ // starts at the pinned identity with only the transitions this device has not seen.
1176
+ const recorded = current ? await this.store.read((tx) => tx.list("successions", accountHex + ":").map(([, r]) => r)) : [];
1177
+ // The bound, applied to every page alike: links past `untilGeneration` are not this call's to
1178
+ // follow (a replacing device stops at the state its own succession left from).
1179
+ const within = (l: SuccessionLink): boolean => input.untilGeneration === undefined || expectType<AccountSuccession>(parseDocument(l.wire), DocumentType.AccountSuccession, stage).newGeneration <= input.untilGeneration;
1180
+ const unseen = (links: readonly SuccessionLink[]): SuccessionLink[] =>
1181
+ links.filter(within).filter((l) => {
1182
+ const doc = expectType<AccountSuccession>(parseDocument(l.wire), DocumentType.AccountSuccession, stage);
1183
+ const t = transitionOf(doc);
1184
+ const row = recorded.find((r) => unbig(r.previousGeneration) === t.previousGeneration && r.previousIdentityId === t.previousIdentityId);
1185
+ if (!row) return true;
1186
+ 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)) {
1187
+ // A different transition at a recorded state is evidence against the authority only if
1188
+ // the authority signed it: unauthenticated bytes are a bad signature, nothing more.
1189
+ authenticateSuccession(l.wire, this.trust, this.now());
1190
+ throw new KavachError("EQUIVOCATION", stage, "two successors for one account state");
1191
+ }
1192
+ return false;
1193
+ });
1194
+ const progress = await this.store.read((tx) => tx.get("chainProgress", accountHex));
1195
+ const verified: VerifiedLink[] = [];
1196
+ let cursor: bigint | undefined;
1197
+ let pending: readonly SuccessionLink[];
1198
+ // Only links this device has not recorded are verified, and only they count against the budget.
1199
+ const verifyMore = (links: readonly SuccessionLink[]): void => {
1200
+ const fresh = unseen(links);
1201
+ const r = verifyChain(fresh, { state, trust: this.trust, now: this.now() });
1202
+ verified.push(...r.links);
1203
+ state = r.state;
1204
+ if (input.budget) input.budget.remainingRecords -= fresh.length;
1205
+ };
1206
+ if (progress && progress.startIdentityId === toHex(startIdentity)) {
1207
+ // Resume at the saved cursor, however long the verified prefix is. The prefix's transitions
1208
+ // were verified when their attestations were allowed, so they are replayed through the
1209
+ // transition rules alone; their bytes are authenticated again below, and refreshed from the
1210
+ // host where a key rotation since made them stale.
1211
+ for (const l of progress.links) {
1212
+ const doc = expectType<AccountSuccession>(parseDocument(fromHex(l.wire)), DocumentType.AccountSuccession, stage);
1213
+ state = validateTransition(doc, fromHex(l.certificate), state).next;
1214
+ verified.push({ doc, wire: fromHex(l.wire), certificate: fromHex(l.certificate) });
1215
+ }
1216
+ cursor = unbig(progress.nextGeneration);
1217
+ pending = [];
1218
+ } else {
1219
+ pending = input.firstPage;
1220
+ cursor = input.next;
1221
+ }
1222
+ const persist = (): Promise<void> =>
1223
+ this.store.transaction((tx) => {
1224
+ if (cursor === undefined) tx.delete("chainProgress", accountHex);
1225
+ 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 });
1226
+ });
1227
+ const reached = (): boolean => input.untilGeneration !== undefined && state.generation >= input.untilGeneration;
1228
+ verifyMore(pending);
1229
+ if (reached()) cursor = undefined;
1230
+ while (cursor !== undefined) {
1231
+ if (input.budget && (input.budget.remainingRecords <= 0 || this.now() >= input.budget.deadline)) {
1232
+ await persist();
1233
+ throw new KavachError("METADATA_REQUIRED", stage, "the chain continues past this sync's budget");
1234
+ }
1235
+ let page;
1236
+ try {
1237
+ page = await this.transport.directory.getSuccessions(input.accountId, cursor, 32);
1238
+ } catch (e) {
1239
+ await persist();
1240
+ throw e;
1241
+ }
1242
+ if (page.successions.length === 0) {
1243
+ await persist();
1244
+ throw new KavachError("METADATA_REQUIRED", stage, "the host did not finish serving the chain");
1245
+ }
1246
+ verifyMore(page.successions);
1247
+ cursor = reached() ? undefined : page.nextGeneration;
1248
+ if (cursor !== undefined) await persist();
1249
+ }
1250
+ if (input.finalIdentityId && state.identityId !== toHex(input.finalIdentityId)) throw new KavachError("SUCCESSION_BINDING", stage, "the chain does not end at the served key");
1251
+ // Every attestation under the current list before anything is recorded. A link whose signer
1252
+ // left the list since it was verified is fetched again: the host re-attests a legitimate
1253
+ // transition under a current key, and the transition must be the same one.
1254
+ for (let i = 0; i < verified.length; i++) {
1255
+ const link = verified[i];
1256
+ if (!link) continue;
1257
+ try {
1258
+ authenticateSuccession(link.wire, this.trust, this.now());
1259
+ } catch (e) {
1260
+ if (!isKavachError(e, "RESET_KEY_REVOKED")) throw e;
1261
+ const page = await this.transport.directory.getSuccessions(input.accountId, link.doc.newGeneration - 1n, 1);
1262
+ const again = page.successions[0];
1263
+ if (!again) throw e;
1264
+ const doc = authenticateSuccession(again.wire, this.trust, this.now());
1265
+ 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");
1266
+ verified[i] = { doc, wire: again.wire, certificate: link.certificate };
1267
+ }
1268
+ }
1269
+ if (!current) {
1270
+ if (progress) await this.store.transaction((tx) => tx.delete("chainProgress", accountHex));
1271
+ return { pin: undefined, verified, accepted: [] };
1272
+ }
1273
+ const applied = await this.store.transaction((tx) => {
1274
+ const effects: SuccessionEffect[] = [];
1275
+ let pin = current;
1276
+ for (const link of verified) {
1277
+ // A link behind the pinned key that is already on record: the pin moved past it earlier.
1278
+ if (link.doc.previousGeneration < pin.generation && tx.list("successions").some(([, s]) => s.accountId === accountHex && unbig(s.previousGeneration) === link.doc.previousGeneration)) continue;
1279
+ const e = applySuccession(tx, { wire: link.wire, newCertificate: link.certificate, policy: this.policy, trust: this.trust, now: this.now() });
1280
+ effects.push(e);
1281
+ pin = e.pin;
1282
+ }
1283
+ this.persistTrustIn(tx);
1284
+ tx.delete("chainProgress", accountHex);
1285
+ return { effects, pin };
1286
+ });
1287
+ return { pin: applied.pin, verified, accepted: applied.effects };
1288
+ }
1289
+
1290
+ /** The report side of an applied chain: counters and events for every accepted link. */
1291
+ private reportAccepted(accepted: readonly SuccessionEffect[], report: SyncReport): void {
1292
+ for (const effect of accepted) {
1293
+ if (effect.outcome !== "ACCEPTED") continue;
1294
+ report.metadata.successions++;
1295
+ report.metadata.rotations++;
1296
+ this.emit({ kind: "accountKeyChanged", accountName: effect.accountName, quarantined: false });
1297
+ this.emit({ kind: "deviceChanged", identityId: effect.pin.accountId, generation: effect.pin.generation });
1298
+ }
1299
+ }
1300
+
1301
+ private securityCodeOf(e: unknown): ErrorCode | undefined {
1302
+ 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;
1303
+ }
1304
+
1305
+ /** Accept a member's certificate; a changed account key is quarantined unless a succession chain or a reset receipt explains it. */
1306
+ private async acceptMemberCertificate(resolved: ResolvedAccount, report: SyncReport): Promise<void> {
1307
+ const wire = resolved.certificate;
1308
+ const resetReceipt = resolved.resetReceipt;
960
1309
  const doc = expectType<AccountDevice>(verifyDocument(wire), DocumentType.AccountDevice, "sync");
961
1310
  const existing = await this.store.read((tx) => tx.list("pins").find(([, p]) => p.accountName === doc.accountName)?.[1]);
962
- const current = existing ? rowToPin(existing) : undefined;
1311
+ let current = existing ? rowToPin(existing) : undefined;
1312
+ const accountId = current?.accountId ?? resolvedAccountId(resolved);
1313
+ const chain = resolved.successions ?? [];
1314
+ // A served chain is followed whenever there is one, even when the served key equals the pinned
1315
+ // one: a certificate under a retired key at a higher generation must be refused as a return to
1316
+ // that identity, never accepted as a plain device change. Links already on record cost nothing.
1317
+ if (current && chain.length > 0) {
1318
+ try {
1319
+ const r = await this.followChain({ accountId, accountName: doc.accountName, current, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget });
1320
+ this.reportAccepted(r.accepted, report);
1321
+ current = r.pin;
1322
+ } catch (e) {
1323
+ report.errors.push(syncError("succession", e));
1324
+ // A deployment that refuses successions quarantines the key change below, as a bare
1325
+ // directory key change. Anything else leaves the pin where it was until the next sync.
1326
+ if (!isKavachError(e, "UNSUPPORTED_TYPE")) {
1327
+ const code = this.securityCodeOf(e);
1328
+ if (code) this.emit({ kind: "securityError", code, detail: doc.accountName });
1329
+ return;
1330
+ }
1331
+ }
1332
+ if (!current) return;
1333
+ }
1334
+ let verified: VerifiedLink[] = [];
1335
+ if (!current && chain.length > 0) {
1336
+ // A first contact with an account that went through successions: the chain is verified from
1337
+ // the root to the served key before any pin is written, under the same policy gate as for an
1338
+ // established contact. A deployment that refuses successions pins nothing and quarantines.
1339
+ try {
1340
+ verified = (await this.followChain({ accountId, accountName: doc.accountName, finalIdentityId: pinFromCertificate(doc).identityId, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget })).verified;
1341
+ } catch (e) {
1342
+ report.errors.push(syncError("succession", e));
1343
+ if (isKavachError(e, "UNSUPPORTED_TYPE")) {
1344
+ const already = await this.store.read((tx) => tx.get("quarantine", doc.accountName));
1345
+ await this.store.transaction((tx) => tx.put("quarantine", doc.accountName, { accountName: doc.accountName, certificate: toHex(wire), reason: "succession refused", at: big(this.now()) }));
1346
+ report.metadata.quarantined++;
1347
+ if (!already) this.emit({ kind: "accountKeyChanged", accountName: doc.accountName, quarantined: true });
1348
+ } else {
1349
+ const code = this.securityCodeOf(e);
1350
+ if (code) this.emit({ kind: "securityError", code, detail: doc.accountName });
1351
+ }
1352
+ return;
1353
+ }
1354
+ }
963
1355
  let outcome;
964
1356
  try {
965
1357
  outcome = acceptCertificate(current, wire);
@@ -976,21 +1368,30 @@ class ClientImpl implements Client {
976
1368
  throw e;
977
1369
  }
978
1370
  if (outcome.outcome === "DUPLICATE") return;
979
- const p = outcome.pin;
1371
+ const p = current ? outcome.pin : { ...outcome.pin, accountId };
980
1372
  await this.store.transaction((tx) => {
981
- tx.put("pins", toHex(p.identityId), pinToRow(p));
1373
+ tx.put("pins", toHex(p.accountId), pinToRow(p));
1374
+ if (verified.length) {
1375
+ recordChainIn(tx, verified, this.now());
1376
+ rootIdentityIn(tx, p.accountId, p.accountName, resolved.rootCertificate);
1377
+ }
1378
+ if (!tx.get("identities", toHex(p.identityId))) {
1379
+ const last = resolved.successions?.at(-1);
1380
+ const since = last ? expectType<AccountSuccession>(parseDocument(last.wire), DocumentType.AccountSuccession, "sync").newGeneration : 1n;
1381
+ tx.put("identities", toHex(p.identityId), { accountId: toHex(p.accountId), accountName: p.accountName, accountKey: toHex(p.accountKey), sinceGeneration: big(since), retired: false });
1382
+ }
982
1383
  if (outcome.previous) {
983
1384
  const old = tx.get("certificates", toHex(outcome.previous.certificateHash));
984
1385
  if (old) tx.put("certificates", toHex(outcome.previous.certificateHash), { ...old, current: false });
985
1386
  }
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 });
1387
+ 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
1388
  if (outcome.rotate) {
988
- const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.identityId, certificateHash: p.certificateHash });
1389
+ const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.accountId, certificateHash: p.certificateHash });
989
1390
  if (ch.changed) report.metadata.rotations++;
990
1391
  }
991
1392
  });
992
1393
  report.metadata.certificates++;
993
- if (outcome.rotate) this.emit({ kind: "deviceChanged", identityId: p.identityId, generation: p.generation });
1394
+ if (outcome.rotate) this.emit({ kind: "deviceChanged", identityId: p.accountId, generation: p.generation });
994
1395
  }
995
1396
 
996
1397
  private async onKeyChanged(current: AccountPin, wire: Uint8Array, receipt: Uint8Array | undefined, report: SyncReport): Promise<void> {
@@ -1025,17 +1426,21 @@ class ClientImpl implements Client {
1025
1426
 
1026
1427
  /** Peers on a validated reset: stop sharing to the old identity, close its circles, rotate. */
1027
1428
  private applyResetIn(tx: Tx, old: AccountPin, newIdentityId: Uint8Array): void {
1028
- tx.delete("pins", toHex(old.identityId));
1429
+ const accountHex = toHex(old.accountId);
1430
+ tx.delete("pins", accountHex);
1029
1431
  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 });
1432
+ for (const [k, c] of tx.list("certificates")) if (c.identityId === accountHex) tx.put("certificates", k, { ...c, current: false });
1433
+ // Every identity of the chain is retired; the root remembers the reset, so no successor of
1434
+ // the old account is ever a recipient again.
1435
+ 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
1436
  for (const [k, c] of tx.list("circles")) {
1032
- if (c.ownerIdentityId === toHex(old.identityId)) {
1437
+ if (c.ownerIdentityId === accountHex) {
1033
1438
  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 });
1439
+ } else if (c.members.includes(accountHex)) {
1440
+ t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.accountId, certificateHash: newIdentityId });
1036
1441
  }
1037
1442
  }
1038
- forgetAuthorIn(tx, old.identityId);
1443
+ forgetAuthorIn(tx, old.accountId);
1039
1444
  }
1040
1445
 
1041
1446
  private persistTrustIn(tx: Tx): void {
@@ -1056,12 +1461,13 @@ class ClientImpl implements Client {
1056
1461
  const { pin } = this.requireAccount("left");
1057
1462
  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
1463
  for (const m of c.members) {
1059
- if (equalBytes(m, pin.identityId) || others.includes(toHex(m))) continue;
1464
+ if (equalBytes(m, pin.accountId) || others.includes(toHex(m))) continue;
1060
1465
  await forgetAuthor(this.store, m);
1061
1466
  }
1062
1467
  }
1063
1468
 
1064
1469
  private async retire(): Promise<void> {
1470
+ this.retired = true;
1065
1471
  await this.store.transaction((tx) => {
1066
1472
  const meta = tx.get("meta", META);
1067
1473
  if (meta) tx.put("meta", META, { ...meta, state: "RETIRED" });
@@ -1094,12 +1500,36 @@ class ClientImpl implements Client {
1094
1500
  return newestSlot(this.store, identityId, kind, this.now());
1095
1501
  }
1096
1502
 
1503
+ keyChanges(options: { accountId?: Uint8Array; limit?: number } = {}): Promise<KeyChangeEntry[]> {
1504
+ const limit = Math.max(1, Math.min(500, Math.floor(options.limit ?? 50)));
1505
+ const who = options.accountId ? toHex(options.accountId) : undefined;
1506
+ return this.store.read((tx) =>
1507
+ tx
1508
+ .list("keyChanges")
1509
+ .map(([, r]) => r)
1510
+ .filter((r) => who === undefined || r.accountId === who)
1511
+ // Newest first; two changes accepted in one transaction order by generation.
1512
+ .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))
1513
+ .slice(0, limit)
1514
+ .map((r) => ({
1515
+ accountId: fromHex(r.accountId),
1516
+ accountName: r.accountName,
1517
+ previousIdentityId: fromHex(r.previousIdentityId),
1518
+ newIdentityId: fromHex(r.newIdentityId),
1519
+ generation: unbig(r.generation),
1520
+ reason: r.reason,
1521
+ successionId: r.successionId === undefined ? undefined : fromHex(r.successionId),
1522
+ acceptedAt: unbig(r.acceptedAt)
1523
+ }))
1524
+ );
1525
+ }
1526
+
1097
1527
 
1098
1528
 
1099
1529
  // Circles ---------------------------------------------------------------------------------------
1100
1530
 
1101
1531
  private async listCircles(): Promise<CircleSummary[]> {
1102
- const self = this.selfPin?.identityId;
1532
+ const self = this.selfPin?.accountId;
1103
1533
  const now = this.now();
1104
1534
  return this.store.read((tx) =>
1105
1535
  tx.list("circles").map(([, r]) => {
@@ -1117,6 +1547,7 @@ class ClientImpl implements Client {
1117
1547
  * the state, so a change that landed in the rows reaches the vault however the process dies.
1118
1548
  */
1119
1549
  private async syncVault(): Promise<void> {
1550
+ if (!this.vaultEnabled) return;
1120
1551
  const { account, pin } = this.requireAccount("vault");
1121
1552
  const recoveryKey = await this.secrets.get("recoveryKey");
1122
1553
  const accountSeed = await this.secrets.get("accountSeed");
@@ -1195,10 +1626,13 @@ class ClientImpl implements Client {
1195
1626
  private async createCircle(): Promise<{ circleId: Uint8Array }> {
1196
1627
  const { account, pin } = this.requireAccount("createCircle");
1197
1628
  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());
1629
+ const genesis = createCircleSnapshot({ identityId: pin.accountId, accountSecretKey: account.account.secretKey }, circleId);
1630
+ const state = acceptSnapshot(pinCircle(circleId, pin.accountId, true, "member"), genesis, pin.accountKey, pin.accountId, this.now());
1200
1631
  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" } }));
1632
+ await this.store.transaction((tx) => {
1633
+ t3Circle(tx, { ...circleToRow(state.state), pending: { snapshot: toHex(genesis), revision: "1" } });
1634
+ this.recordPreferenceIn(tx, circleId, true);
1635
+ });
1202
1636
  const published = await this.publishPendingSnapshot(circleId);
1203
1637
  if (published !== "COMMITTED") throw new KavachError("STORAGE_FAILURE", "createCircle", published);
1204
1638
  await this.store.transaction((tx) => {
@@ -1206,6 +1640,7 @@ class ClientImpl implements Client {
1206
1640
  t9MarkVault(tx);
1207
1641
  });
1208
1642
  await this.syncVault();
1643
+ await this.pushPreferencesQuietly();
1209
1644
  this.emit({ kind: "circleChanged", circleId, revision: 1n, status: "member" });
1210
1645
  return { circleId };
1211
1646
  }
@@ -1214,6 +1649,7 @@ class ClientImpl implements Client {
1214
1649
  this.requireAccount("requestJoin");
1215
1650
  await this.store.transaction((tx) => {
1216
1651
  const existing = tx.get("circles", toHex(circleId));
1652
+ if (!existing || existing.status === "left") this.recordPreferenceIn(tx, circleId, shareOnApproval);
1217
1653
  if (!existing) t3Circle(tx, circleToRow(pinCircle(circleId, ownerIdentityId, shareOnApproval, "requested")));
1218
1654
  else if (existing.status === "left") {
1219
1655
  // An explicit rejoin: the pinned owner and the revision reached stay, so no older snapshot
@@ -1226,17 +1662,28 @@ class ClientImpl implements Client {
1226
1662
  await this.syncVault();
1227
1663
  const outcome = await this.transport.directory.requestJoin(circleId);
1228
1664
  if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") throw new KavachError("STORAGE_FAILURE", "requestJoin", outcome.status);
1665
+ await this.pushPreferencesQuietly();
1666
+ }
1667
+
1668
+ /** The host's copy of a preference, best effort now and by sync later. */
1669
+ private async pushPreferencesQuietly(): Promise<void> {
1670
+ if (!acceptsSuccession(this.policy)) return;
1671
+ try {
1672
+ await this.pushPreferences();
1673
+ } catch {
1674
+ /* retried by sync */
1675
+ }
1229
1676
  }
1230
1677
 
1231
1678
  private async approve(circleId: Uint8Array, accountName: string): Promise<{ revision: bigint }> {
1232
1679
  const [resolved] = await this.transport.directory.resolveAccounts([accountName]);
1233
1680
  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 });
1681
+ 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: [] };
1682
+ await this.acceptMemberCertificate(resolved, report);
1683
+ const requester = resolvedAccountId(resolved);
1684
+ const pinned = await this.pinFor(requester);
1685
+ 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");
1686
+ return this.changeMembership(circleId, { admit: requester });
1240
1687
  }
1241
1688
 
1242
1689
  private async changeMembership(circleId: Uint8Array, change: { admit: Uint8Array } | { remove: Uint8Array } | { close: true }): Promise<{ revision: bigint }> {
@@ -1251,7 +1698,7 @@ class ClientImpl implements Client {
1251
1698
  }
1252
1699
  const state = rowToCircle(row);
1253
1700
  if (state.status === "closed") return { revision: state.revision };
1254
- const successor = successorSnapshot(state, change, { identityId: pin.identityId, accountSecretKey: account.account.secretKey });
1701
+ const successor = successorSnapshot(state, change, { identityId: pin.accountId, accountSecretKey: account.account.secretKey });
1255
1702
  if (!successor) {
1256
1703
  if (!("close" in change)) return { revision: state.revision };
1257
1704
  // Nobody but the owner to tell: the close is local, and the vault entry goes with it.
@@ -1289,7 +1736,7 @@ class ClientImpl implements Client {
1289
1736
  const expectedRevision = unbig(pending.revision) - 1n;
1290
1737
  const outcome = await this.transport.directory.publishOwnedCircle({ circleId, expectedRevision, expectedHash: expectedRevision === state.revision ? state.bodyHash : undefined, newSnapshot: successor });
1291
1738
  if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
1292
- const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.identityId, this.now());
1739
+ const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.accountId, this.now());
1293
1740
  if (accepted.outcome !== "ACCEPTED" && accepted.outcome !== "DUPLICATE") throw new KavachError("STORAGE_FAILURE", stage, "the successor was refused locally");
1294
1741
  let next = pending.close ? { ...accepted.state, status: "closed" as const } : accepted.state;
1295
1742
  await this.store.transaction((tx) => {
@@ -1301,7 +1748,7 @@ class ClientImpl implements Client {
1301
1748
  });
1302
1749
  const after = new Set(next.members.map(toHex));
1303
1750
  for (const m of state.members) {
1304
- if (after.has(toHex(m)) || equalBytes(m, pin.identityId)) continue;
1751
+ if (after.has(toHex(m)) || equalBytes(m, pin.accountId)) continue;
1305
1752
  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
1753
  if (!stillElsewhere) await forgetAuthor(this.store, m);
1307
1754
  }
@@ -1310,7 +1757,7 @@ class ClientImpl implements Client {
1310
1757
  return "COMMITTED";
1311
1758
  }
1312
1759
  if (outcome.status === "CONFLICT" && outcome.current) {
1313
- const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.identityId, this.now());
1760
+ const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.accountId, this.now());
1314
1761
  await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(accepted.outcome === "ACCEPTED" ? accepted.state : state), pending: undefined }));
1315
1762
  return "CONFLICT";
1316
1763
  }
@@ -1321,7 +1768,7 @@ class ClientImpl implements Client {
1321
1768
  const { pin } = this.requireAccount("leave");
1322
1769
  const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1323
1770
  if (!row) return;
1324
- if (row.ownerIdentityId === toHex(pin.identityId)) throw new KavachError("NOT_AUTHORIZED", "leave", "the owner closes instead");
1771
+ if (row.ownerIdentityId === toHex(pin.accountId)) throw new KavachError("NOT_AUTHORIZED", "leave", "the owner closes instead");
1325
1772
  const state = rowToCircle(row);
1326
1773
  await this.store.transaction((tx) => {
1327
1774
  t3Circle(tx, circleToRow({ ...state, enabled: false, status: "left" }), { kind: "leave", circleId });
@@ -1341,8 +1788,57 @@ class ClientImpl implements Client {
1341
1788
  }
1342
1789
  await this.store.transaction((tx) => {
1343
1790
  t3Circle(tx, { ...row, enabled }, { kind: enabled ? "enable" : "disable", circleId });
1791
+ this.recordPreferenceIn(tx, circleId, enabled);
1344
1792
  });
1345
1793
  await this.syncVault();
1794
+ await this.pushPreferencesQuietly();
1795
+ }
1796
+
1797
+ /** The user's latest action on a circle's preference, with the local sequence that a host response must name to clear it. */
1798
+ private recordPreferenceIn(tx: Tx, circleId: Uint8Array, enabled: boolean): void {
1799
+ const key = toHex(circleId);
1800
+ const row = tx.get("preferences", key) ?? { circleId: key, enabled, revision: "0", pending: undefined, nextSequence: "1" };
1801
+ const sequence = row.nextSequence;
1802
+ tx.put("preferences", key, { ...row, enabled, pending: { enabled, sequence }, nextSequence: big(unbig(sequence) + 1n) });
1803
+ }
1804
+
1805
+ /**
1806
+ * Send pending preference changes to the host (amendment A1, section 7). On CONFLICT the
1807
+ * device's latest persisted action wins, read from the store at retry time, and is written again
1808
+ * at the served revision; a response clears only the sequence it answered.
1809
+ */
1810
+ private async pushPreferences(report?: SyncReport): Promise<void> {
1811
+ if (!acceptsSuccession(this.policy) || !this.selfPin) return;
1812
+ const pending = await this.store.read((tx) => tx.list("preferences").filter(([, p]) => p.pending !== undefined).map(([, p]) => p.circleId));
1813
+ for (const key of pending) {
1814
+ for (let attempt = 0; attempt < 3; attempt++) {
1815
+ const row = await this.store.read((tx) => tx.get("preferences", key));
1816
+ if (!row?.pending) break;
1817
+ const sent = row.pending;
1818
+ const outcome = await this.transport.directory.setSharingPreference(fromHex(key), sent.enabled, unbig(row.revision));
1819
+ if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
1820
+ await this.store.transaction((tx) => {
1821
+ const live = tx.get("preferences", key);
1822
+ if (!live) return;
1823
+ const cleared = live.pending?.sequence === sent.sequence ? undefined : live.pending;
1824
+ // A late success for an older request never moves the revision back.
1825
+ const revision = outcome.revision > unbig(live.revision) ? big(outcome.revision) : live.revision;
1826
+ tx.put("preferences", key, { ...live, revision, pending: cleared });
1827
+ });
1828
+ break;
1829
+ }
1830
+ if (outcome.status === "CONFLICT") {
1831
+ // Adopt the served revision; the latest persisted action is re-sent against it.
1832
+ await this.store.transaction((tx) => {
1833
+ const live = tx.get("preferences", key);
1834
+ if (live) tx.put("preferences", key, { ...live, revision: big(outcome.revision) });
1835
+ });
1836
+ continue;
1837
+ }
1838
+ if (report) report.errors.push({ operation: "preference", code: outcome.status === "UNAUTHORIZED" ? "NOT_AUTHORIZED" : "NETWORK", stage: "preference", retryable: outcome.status !== "UNAUTHORIZED" });
1839
+ break;
1840
+ }
1841
+ }
1346
1842
  }
1347
1843
 
1348
1844
  /** Close: the successor names only the owner; its publication forgets the members and removes the vault entry. */
@@ -1350,6 +1846,301 @@ class ClientImpl implements Client {
1350
1846
  await this.changeMembership(circleId, { close: true });
1351
1847
  }
1352
1848
 
1849
+ // Account recovery (amendment A1) ---------------------------------------------------------------
1850
+
1851
+ async prepareReplacement(): Promise<ReplacementProposal> {
1852
+ const stage = "prepareReplacement";
1853
+ if (!acceptsSuccession(this.policy)) throw new KavachError("NOT_AUTHORIZED", stage, "this deployment does not use account recovery");
1854
+ const opened = await openStore(this.store, this.secrets, this.accountName);
1855
+ if (opened.kind !== "fresh") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
1856
+ const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
1857
+ const roots = await this.secrets.get("pendingReplacement");
1858
+ if (existing && !existing.stale && roots && roots.length === RESET_ROOTS_BYTES) return proposalOf(existing);
1859
+ const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
1860
+ if (!resolved) throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
1861
+ const current = expectType<AccountDevice>(verifyDocument(resolved.certificate), DocumentType.AccountDevice, stage);
1862
+ const accountId = resolvedAccountId(resolved);
1863
+ // Fresh roots: nothing from the old device, nothing from the host. The certificate is at the
1864
+ // next generation under the new key; the vault is fresh at revision 1 so that identity
1865
+ // continuity works on the next replacement.
1866
+ const accountSeed = newAccountSeed();
1867
+ const device = newDevice();
1868
+ const recoveryKey = newRecoveryKey();
1869
+ const instanceId = randomBytes(16);
1870
+ const successionId = randomBytes(16);
1871
+ const account = openAccount(this.accountName, accountSeed, accountId);
1872
+ const generation = current.generation + 1n;
1873
+ const certificate = certify(account, device, generation);
1874
+ const pin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage), undefined, accountId);
1875
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: generation, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
1876
+ // T11: roots in the secret store, the exact bytes in the store, before anything is sent.
1877
+ await this.secrets.set("pendingReplacement", concat(accountSeed, device.deviceSeed, recoveryKey, instanceId, device.deviceId));
1878
+ const row: ReplacementRow = {
1879
+ successionId: toHex(successionId),
1880
+ accountName: this.accountName,
1881
+ accountId: toHex(accountId),
1882
+ expectedCertificateHash: toHex(current.bodyHash),
1883
+ expectedGeneration: big(current.generation),
1884
+ certificate: toHex(certificate),
1885
+ vault: vault ? toHex(vault.wire) : undefined,
1886
+ vaultHash: vault ? toHex(vault.hash) : undefined,
1887
+ deviceId: toHex(device.deviceId),
1888
+ stale: false,
1889
+ conflictGeneration: undefined
1890
+ };
1891
+ await this.store.transaction((tx) => tx.put("replacement", "pending", row));
1892
+ await this.emitState();
1893
+ return proposalOf(row);
1894
+ }
1895
+
1896
+ async completeReplacement(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }> {
1897
+ const stage = "completeReplacement";
1898
+ // Already activated (by an earlier call, or by a sync that found the commit on the chain): idempotent.
1899
+ if (this.selfPin && this.account && !this.retired) return { identityId: this.selfPin.identityId, generation: this.selfPin.generation };
1900
+ const row = await this.store.read((tx) => tx.get("replacement", "pending"));
1901
+ if (!row || row.successionId !== toHex(input.successionId)) throw new KavachError("MALFORMED", stage, "no such prepared replacement on this device");
1902
+ if (row.stale) throw new KavachError("REPLACEMENT_CONFLICT", stage, row.conflictGeneration === undefined ? "the account moved" : `the account is at generation ${row.conflictGeneration}`);
1903
+ const roots = await this.secrets.get("pendingReplacement");
1904
+ if (!roots || roots.length !== RESET_ROOTS_BYTES) throw new KavachError("RECOVERY_REQUIRED", stage, "replacement prepared without its roots");
1905
+ let outcome: Awaited<ReturnType<Directory["succeedAccount"]>>;
1906
+ try {
1907
+ outcome = await this.transport.directory.succeedAccount({
1908
+ accountName: this.accountName,
1909
+ expectedCertificateHash: fromHex(row.expectedCertificateHash),
1910
+ newCertificate: fromHex(row.certificate),
1911
+ newVault: row.vault === undefined ? undefined : fromHex(row.vault),
1912
+ successionId: input.successionId,
1913
+ authorization: input.authorization
1914
+ });
1915
+ } catch (e) {
1916
+ if (!isKavachError(e, "NETWORK")) throw e;
1917
+ // Unknown: the chain says whether ours committed. Never a second identity.
1918
+ const link = await this.findOwnLink(row).catch(() => undefined);
1919
+ if (!link) throw new KavachError("SUCCESSION_PENDING", stage, "the commit's outcome is unknown; call again with the same proposal");
1920
+ outcome = { status: "COMMITTED", succession: link.wire };
1921
+ }
1922
+ if (outcome.status === "CONFLICT") {
1923
+ // Definitive: the current certificate moved under the proposal, and the succession id is
1924
+ // not on the chain (that answers first). The proposal can never commit: it is marked stale
1925
+ // and its keys, never activated anywhere, are discarded.
1926
+ let current: bigint | undefined;
1927
+ try {
1928
+ current = outcome.current ? expectType<AccountDevice>(verifyDocument(outcome.current), DocumentType.AccountDevice, stage).generation : undefined;
1929
+ } catch {
1930
+ current = undefined;
1931
+ }
1932
+ await this.store.transaction((tx) => tx.put("replacement", "pending", { ...row, stale: true, conflictGeneration: current === undefined ? undefined : big(current) }));
1933
+ await this.secrets.delete("pendingReplacement");
1934
+ await this.emitState();
1935
+ throw new KavachError("REPLACEMENT_CONFLICT", stage, current === undefined ? "the account moved" : `the account is at generation ${current}`);
1936
+ }
1937
+ if (outcome.status === "UNAUTHORIZED") throw new KavachError("AUTHORIZATION_REQUIRED", stage, "the host refused the authorization; obtain a fresh one for the same proposal");
1938
+ if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.succession) {
1939
+ throw new KavachError("SUCCESSION_PENDING", stage, `host answered ${outcome.status}`);
1940
+ }
1941
+ return this.activateReplacement(row, roots, outcome.succession);
1942
+ }
1943
+
1944
+ /** Our own link on the host's chain, if the proposal committed: the one naming our succession id and certificate. */
1945
+ private async findOwnLink(row: ReplacementRow): Promise<SuccessionLink | undefined> {
1946
+ const page = await this.transport.directory.getSuccessions(fromHex(row.accountId), unbig(row.expectedGeneration), 8);
1947
+ for (const l of page.successions) {
1948
+ try {
1949
+ const doc = expectType<AccountSuccession>(parseDocument(l.wire), DocumentType.AccountSuccession, "resume");
1950
+ if (toHex(doc.successionId) === row.successionId && equalBytes(l.certificate, fromHex(row.certificate))) return l;
1951
+ } catch {
1952
+ /* not ours */
1953
+ }
1954
+ }
1955
+ return undefined;
1956
+ }
1957
+
1958
+ /** The reset-key list, fetched when the one held is not fresh. */
1959
+ private async ensureResetList(): Promise<void> {
1960
+ const now = this.now();
1961
+ if (this.trust.fresh(now)) return;
1962
+ const wire = await this.transport.directory.getResetKeyList();
1963
+ if (!wire) return;
1964
+ this.trust.install(wire, now);
1965
+ await this.persistTrust();
1966
+ }
1967
+
1968
+ /**
1969
+ * Activate: verify the returned succession exactly as a peer would, against the previous state
1970
+ * pinned from the host, then the T1 header under the new identity with the account id carried
1971
+ * from the proposal. A succession that does not verify leaves the proposal intact.
1972
+ */
1973
+ private async activateReplacement(row: ReplacementRow, roots: Uint8Array, succession: Uint8Array): Promise<{ identityId: Uint8Array; generation: bigint }> {
1974
+ const stage = "completeReplacement";
1975
+ await this.ensureResetList();
1976
+ const previousWire = await this.transport.directory.getCertificate(fromHex(row.expectedCertificateHash));
1977
+ if (!previousWire) throw new KavachError("METADATA_REQUIRED", stage, "the previous certificate is not served");
1978
+ const previous = expectType<AccountDevice>(verifyDocument(previousWire), DocumentType.AccountDevice, stage);
1979
+ if (toHex(previous.bodyHash) !== row.expectedCertificateHash) throw new KavachError("SUCCESSION_BINDING", stage, "the served certificate is not the expected one");
1980
+ const accountId = fromHex(row.accountId);
1981
+ const previousPin = pinFromCertificate(previous, undefined, accountId);
1982
+ // The account's earlier successions, if any: the chain from the root to the previous identity,
1983
+ // verified through the one chain path and kept, so this device holds every key the account
1984
+ // has signed with (an owned circle's snapshot from before an earlier replacement) and can
1985
+ // follow its own chain later (to learn of its retirement).
1986
+ const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
1987
+ // The evidence must be there before anything is written: without the account's resolution the
1988
+ // history is unknown, not empty, and the proposal stays for the next call. An empty history is
1989
+ // right only when the previous identity is the account root.
1990
+ if (!me) throw new KavachError("METADATA_REQUIRED", stage, "the account's history is not served; the proposal stays");
1991
+ const served = me.successions ?? [];
1992
+ const ownSuccession = authenticateSuccession(succession, this.trust, this.now());
1993
+ 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");
1994
+ // Bounded by the generation this device's own succession left from, on every page alike.
1995
+ 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 : [];
1996
+ const accountSeed = roots.subarray(0, 32);
1997
+ const deviceSeed = roots.subarray(32, 64);
1998
+ const recoveryKey = roots.subarray(64, 96);
1999
+ const instanceId = roots.subarray(96, 112);
2000
+ const deviceId = roots.subarray(112, 128);
2001
+ const account = openAccount(this.accountName, accountSeed, accountId);
2002
+ const device = deviceFromSeed(deviceSeed, deviceId);
2003
+ const certificate = fromHex(row.certificate);
2004
+ const doc = expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage);
2005
+ const pin = pinFromCertificate(doc, undefined, accountId);
2006
+ // Roots first, then the store: a crash in between leaves a device the next call finishes.
2007
+ await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
2008
+ await this.store.transaction((tx) => {
2009
+ // The previous state as a first contact pins it, then the succession as a peer verifies it:
2010
+ // nothing is written unless the document binds this account, this generation and this key.
2011
+ tx.put("pins", toHex(accountId), pinToRow(previousPin));
2012
+ 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 });
2013
+ recordChainIn(tx, history, this.now());
2014
+ rootIdentityIn(tx, accountId, this.accountName, me?.rootCertificate);
2015
+ const effect = applySuccession(tx, { wire: succession, newCertificate: certificate, policy: this.policy, trust: this.trust, now: this.now() });
2016
+ 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");
2017
+ this.persistTrustIn(tx);
2018
+ t1Header(tx, { account, device, certificate, instanceId, generation: doc.generation });
2019
+ if (row.vault !== undefined && row.vaultHash !== undefined) t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
2020
+ t3ApplyChange(tx, { kind: "ownActivation" });
2021
+ t1Activate(tx);
2022
+ const meta = tx.get("meta", META);
2023
+ if (meta) tx.put("meta", META, { ...meta, bootstrap: { complete: false } });
2024
+ tx.delete("replacement", "pending");
2025
+ });
2026
+ await this.secrets.delete("pendingReplacement");
2027
+ this.account = account;
2028
+ this.device = device;
2029
+ this.selfPin = pin;
2030
+ this.retired = false;
2031
+ try {
2032
+ await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
2033
+ } catch {
2034
+ /* reported by recoveryStatus() */
2035
+ }
2036
+ await this.emitState();
2037
+ return { identityId: pin.identityId, generation: pin.generation };
2038
+ }
2039
+
2040
+ /**
2041
+ * Discovery after account recovery (amendment A1, section 5): the host lists the account's
2042
+ * circles page by page; each is pinned only when its snapshot verifies under the owner's
2043
+ * verified chain and names this account as the host says. Discovery is not permission to share:
2044
+ * the circle is enabled only if the served preference says so. Never writes membership; an
2045
+ * owner's successor republishes the served snapshot at the next revision.
2046
+ */
2047
+ private async bootstrap(report: SyncReport, state: NonNullable<MetaRow["bootstrap"]>): Promise<{ circles: number; pending: number; failed: number }> {
2048
+ const out = { circles: 0, pending: 0, failed: 0 };
2049
+ // A circle that could not be adopted is kept with what the host served and retried by every
2050
+ // sync; a failure never advances past it silently, and never undoes another circle.
2051
+ let retry: BootstrapRetryRow[] = state.retry ?? [];
2052
+ let cursor = state.cursor;
2053
+ let pagesDone = state.pagesDone === true;
2054
+ const adopt = async (c: CircleListing): Promise<boolean> => {
2055
+ try {
2056
+ const r = await this.adoptListing(c, report);
2057
+ if (r === "member") out.circles++;
2058
+ else if (r === "pending") out.pending++;
2059
+ return true;
2060
+ } catch (e) {
2061
+ out.failed++;
2062
+ report.errors.push(syncError("bootstrap", e, { circleId: c.circleId }));
2063
+ return false;
2064
+ }
2065
+ };
2066
+ const save = (done: boolean): Promise<void> =>
2067
+ this.store.transaction((tx) => {
2068
+ const meta = tx.get("meta", META);
2069
+ if (!meta) return;
2070
+ const bootstrap = done && retry.length === 0 ? { complete: true } : { ...(cursor === undefined ? {} : { cursor }), complete: false, retry, pagesDone: done };
2071
+ tx.put("meta", META, { ...meta, bootstrap });
2072
+ t9MarkVault(tx);
2073
+ });
2074
+ const stillFailing: BootstrapRetryRow[] = [];
2075
+ for (const r of retry) {
2076
+ 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) };
2077
+ if (!(await adopt(listing))) stillFailing.push(r);
2078
+ }
2079
+ retry = stillFailing;
2080
+ for (let pages = 0; !pagesDone && pages < 64; pages++) {
2081
+ let page;
2082
+ try {
2083
+ page = await this.transport.directory.listCircles(cursor);
2084
+ } catch (e) {
2085
+ // What landed stays; the cursor says where to resume on the next sync.
2086
+ report.errors.push(syncError("bootstrap", e));
2087
+ await save(false);
2088
+ return out;
2089
+ }
2090
+ for (const c of page.circles) {
2091
+ 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) });
2092
+ }
2093
+ cursor = page.cursor;
2094
+ if (cursor === undefined) pagesDone = true;
2095
+ await save(pagesDone);
2096
+ }
2097
+ await save(pagesDone);
2098
+ return out;
2099
+ }
2100
+
2101
+ private async adoptListing(c: CircleListing, report: SyncReport): Promise<"member" | "pending" | "skipped"> {
2102
+ const stage = "bootstrap";
2103
+ const { pin, account } = this.requireAccount(stage);
2104
+ const key = toHex(c.circleId);
2105
+ const existing = await this.store.read((tx) => tx.get("circles", key));
2106
+ if (existing && (existing.revision !== "0" || existing.status === "requested")) return "skipped"; // a restart between pages
2107
+ if (!c.snapshot) return "skipped"; // closed: nothing to pin, nothing to reopen
2108
+ const ownerIsMe = equalBytes(c.ownerAccountId, pin.accountId);
2109
+ let ownerPin = ownerIsMe ? pin : await this.pinFor(c.ownerAccountId);
2110
+ if (!ownerPin) {
2111
+ const [r] = await this.transport.directory.resolveIdentities([c.ownerAccountId]);
2112
+ if (r) await this.acceptMemberCertificate(r, report);
2113
+ ownerPin = await this.pinFor(c.ownerAccountId);
2114
+ }
2115
+ if (!ownerPin) throw new KavachError("METADATA_REQUIRED", stage, "the circle's owner cannot be resolved");
2116
+ const enabled = c.enabled === true;
2117
+ const base = pinCircle(c.circleId, c.ownerAccountId, enabled, c.role === "pending" ? "requested" : "member");
2118
+ const accepted = await this.acceptSnapshotFrom(base, c.snapshot, ownerPin, this.now());
2119
+ if (accepted.outcome !== "ACCEPTED") throw new KavachError("MALFORMED", stage, "an empty snapshot");
2120
+ let state = accepted.state;
2121
+ const named = c.role === "owner" ? equalBytes(state.ownerIdentityId, pin.accountId) : c.role === "member" ? state.members.some((m) => equalBytes(m, pin.accountId)) : true;
2122
+ if (!named) throw new KavachError("NOT_AUTHORIZED", stage, "the snapshot does not name this account as the host says");
2123
+ if (c.role === "pending") state = { ...state, status: "requested" };
2124
+ // The successor owner republishes what it was served at the next revision; the host's
2125
+ // compare-and-swap decides.
2126
+ const republish = ownerIsMe ? buildCircleSnapshot({ circleId: c.circleId, ownerIdentityId: pin.accountId, revision: state.revision + 1n, members: state.members }, account.account.secretKey) : undefined;
2127
+ await this.store.transaction((tx) => {
2128
+ const row = circleToRow(state);
2129
+ t3Circle(tx, republish ? { ...row, pending: { snapshot: toHex(republish), revision: big(state.revision + 1n) } } : row, { kind: "snapshot", circleId: c.circleId, revision: state.revision });
2130
+ if (state.status === "member") t3ApplyChange(tx, { kind: "join", circleId: c.circleId });
2131
+ tx.put("preferences", key, { circleId: key, enabled, revision: big(c.preferenceRevision), pending: undefined, nextSequence: "1" });
2132
+ });
2133
+ this.emit({ kind: "circleChanged", circleId: c.circleId, revision: state.revision, status: state.status });
2134
+ if (republish) {
2135
+ try {
2136
+ await this.publishPendingSnapshot(c.circleId);
2137
+ } catch (e) {
2138
+ report.errors.push(syncError("publishSnapshot", e));
2139
+ }
2140
+ }
2141
+ return state.status === "requested" ? "pending" : "member";
2142
+ }
2143
+
1353
2144
  // Reset -----------------------------------------------------------------------------------------
1354
2145
 
1355
2146
  async resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
@@ -1363,13 +2154,13 @@ class ClientImpl implements Client {
1363
2154
  const certificate = certify(account, device, 1n);
1364
2155
  const newPin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice));
1365
2156
  const recoveryKey = newRecoveryKey();
1366
- const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey);
2157
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey) : undefined;
1367
2158
  const resetId = randomBytes(16);
1368
2159
  const instanceId = randomBytes(16);
1369
2160
  // Persisted before it is sent: the host may commit the new identity and the response be lost,
1370
2161
  // and the only keys that can continue are these. Roots in the secret store, wires in the store.
1371
2162
  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) }));
2163
+ 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
2164
  return this.resumeReset(stage, options.reauthProof);
1374
2165
  }
1375
2166
 
@@ -1380,7 +2171,7 @@ class ClientImpl implements Client {
1380
2171
  if (!row || !roots || roots.length !== RESET_ROOTS_BYTES) throw new KavachError("RECOVERY_REQUIRED", stage, "reset pending without its roots");
1381
2172
  const certificate = fromHex(row.certificate);
1382
2173
  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() });
2174
+ 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
2175
  if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.receipt) throw new KavachError("NOT_AUTHORIZED", stage, `host refused reset: ${outcome.status}`);
1385
2176
  return this.finishReset(stage, row, roots);
1386
2177
  }
@@ -1402,11 +2193,11 @@ class ClientImpl implements Client {
1402
2193
  const newPin = pinFromCertificate(expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice, stage));
1403
2194
  await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
1404
2195
  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) {
2196
+ 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
2197
  for (const [k] of tx.list(table)) tx.delete(table, k);
1407
2198
  }
1408
2199
  t1Header(tx, { account, device, certificate, instanceId, generation: 1n });
1409
- t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
2200
+ if (row.vault !== undefined && row.vaultHash !== undefined) t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
1410
2201
  t1Activate(tx);
1411
2202
  });
1412
2203
  await this.secrets.delete("pendingReset");
@@ -1414,7 +2205,7 @@ class ClientImpl implements Client {
1414
2205
  this.device = device;
1415
2206
  this.selfPin = newPin;
1416
2207
  try {
1417
- await this.provider.save(this.accountName, newPin.identityId, recoveryKey);
2208
+ await this.provider?.save(this.accountName, newPin.identityId, recoveryKey);
1418
2209
  } catch {
1419
2210
  /* reported by recoveryStatus() */
1420
2211
  }