@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.
- package/README.md +1 -1
- package/dist/adapters/index.d.ts +3 -3
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +1 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/audience.d.ts +1 -1
- package/dist/audience.d.ts.map +1 -1
- package/dist/audience.js +7 -6
- package/dist/audience.js.map +1 -1
- package/dist/client.d.ts +78 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +903 -85
- package/dist/client.js.map +1 -1
- package/dist/codec.d.ts +25 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +44 -0
- package/dist/codec.js.map +1 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js.map +1 -1
- package/dist/errors.d.ts +9 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -1
- package/dist/errors.js.map +1 -1
- package/dist/identity.d.ts +8 -2
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +7 -3
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/policy.d.ts +13 -0
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +12 -1
- package/dist/policy.js.map +1 -1
- package/dist/rows.d.ts.map +1 -1
- package/dist/rows.js +2 -0
- package/dist/rows.js.map +1 -1
- package/dist/store.d.ts +114 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/succession.d.ts +108 -0
- package/dist/succession.d.ts.map +1 -0
- package/dist/succession.js +205 -0
- package/dist/succession.js.map +1 -0
- package/dist/testing/clock.d.ts.map +1 -1
- package/dist/testing/clock.js +1 -0
- package/dist/testing/clock.js.map +1 -1
- package/dist/testing/fakeHost.d.ts +60 -1
- package/dist/testing/fakeHost.d.ts.map +1 -1
- package/dist/testing/fakeHost.js +201 -31
- package/dist/testing/fakeHost.js.map +1 -1
- package/dist/testing/faults.d.ts +5 -3
- package/dist/testing/faults.d.ts.map +1 -1
- package/dist/testing/faults.js +24 -3
- package/dist/testing/faults.js.map +1 -1
- package/dist/transactions.d.ts +6 -0
- package/dist/transactions.d.ts.map +1 -1
- package/dist/transactions.js +11 -5
- package/dist/transactions.js.map +1 -1
- package/dist/transport.d.ts +66 -2
- package/dist/transport.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/index.ts +13 -0
- package/src/audience.ts +7 -6
- package/src/client.ts +881 -90
- package/src/codec.ts +64 -1
- package/src/crypto.ts +1 -0
- package/src/errors.ts +12 -1
- package/src/identity.ts +13 -3
- package/src/index.ts +5 -3
- package/src/policy.ts +23 -1
- package/src/rows.ts +2 -0
- package/src/store.ts +110 -3
- package/src/succession.ts +270 -0
- package/src/testing/clock.ts +1 -0
- package/src/testing/fakeHost.ts +204 -28
- package/src/testing/faults.ts +28 -7
- package/src/transactions.ts +13 -6
- 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
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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?.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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 =
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
954
|
-
|
|
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
|
-
/**
|
|
959
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 ===
|
|
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 ===
|
|
1437
|
+
if (c.ownerIdentityId === accountHex) {
|
|
1033
1438
|
t3Circle(tx, { ...c, status: "closed" }, { kind: "leave", circleId: fromHex(k) });
|
|
1034
|
-
} else if (c.members.includes(
|
|
1035
|
-
t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.
|
|
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.
|
|
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.
|
|
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?.
|
|
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.
|
|
1199
|
-
const state = acceptSnapshot(pinCircle(circleId, pin.
|
|
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) =>
|
|
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
|
|
1236
|
-
const requester =
|
|
1237
|
-
const pinned = await this.pinFor(requester
|
|
1238
|
-
if (!pinned) throw new KavachError("ACCOUNT_KEY_CHANGED", "approve", "requester's key is quarantined");
|
|
1239
|
-
return this.changeMembership(circleId, { admit: requester
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
2208
|
+
await this.provider?.save(this.accountName, newPin.identityId, recoveryKey);
|
|
1418
2209
|
} catch {
|
|
1419
2210
|
/* reported by recoveryStatus() */
|
|
1420
2211
|
}
|