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