@plane31/blind-relay 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/adapters/index.d.ts +3 -3
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +1 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/audience.d.ts +1 -1
- package/dist/audience.d.ts.map +1 -1
- package/dist/audience.js +7 -6
- package/dist/audience.js.map +1 -1
- package/dist/client.d.ts +78 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +903 -85
- package/dist/client.js.map +1 -1
- package/dist/codec.d.ts +25 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +44 -0
- package/dist/codec.js.map +1 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js.map +1 -1
- package/dist/errors.d.ts +9 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -1
- package/dist/errors.js.map +1 -1
- package/dist/identity.d.ts +8 -2
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +7 -3
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/policy.d.ts +13 -0
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +12 -1
- package/dist/policy.js.map +1 -1
- package/dist/rows.d.ts.map +1 -1
- package/dist/rows.js +2 -0
- package/dist/rows.js.map +1 -1
- package/dist/store.d.ts +114 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/succession.d.ts +108 -0
- package/dist/succession.d.ts.map +1 -0
- package/dist/succession.js +205 -0
- package/dist/succession.js.map +1 -0
- package/dist/testing/clock.d.ts.map +1 -1
- package/dist/testing/clock.js +1 -0
- package/dist/testing/clock.js.map +1 -1
- package/dist/testing/fakeHost.d.ts +60 -1
- package/dist/testing/fakeHost.d.ts.map +1 -1
- package/dist/testing/fakeHost.js +201 -31
- package/dist/testing/fakeHost.js.map +1 -1
- package/dist/testing/faults.d.ts +5 -3
- package/dist/testing/faults.d.ts.map +1 -1
- package/dist/testing/faults.js +24 -3
- package/dist/testing/faults.js.map +1 -1
- package/dist/transactions.d.ts +6 -0
- package/dist/transactions.d.ts.map +1 -1
- package/dist/transactions.js +11 -5
- package/dist/transactions.js.map +1 -1
- package/dist/transport.d.ts +66 -2
- package/dist/transport.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/index.ts +13 -0
- package/src/audience.ts +7 -6
- package/src/client.ts +881 -90
- package/src/codec.ts +64 -1
- package/src/crypto.ts +1 -0
- package/src/errors.ts +12 -1
- package/src/identity.ts +13 -3
- package/src/index.ts +5 -3
- package/src/policy.ts +23 -1
- package/src/rows.ts +2 -0
- package/src/store.ts +110 -3
- package/src/succession.ts +270 -0
- package/src/testing/clock.ts +1 -0
- package/src/testing/fakeHost.ts +204 -28
- package/src/testing/faults.ts +28 -7
- package/src/transactions.ts +13 -6
- package/src/transport.ts +69 -2
package/dist/client.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { concat, equalBytes, fromHex, toHex } from "./bytes.js";
|
|
7
7
|
import { resolveAudience } from "./audience.js";
|
|
8
8
|
import { acceptSnapshot, checkMemberResolution, createCircleSnapshot, newCircleId, pinCircle, successorSnapshot } from "./circle.js";
|
|
9
|
-
import { DocumentType, expectType, parseVaultHeader, verifyDocument } from "./codec.js";
|
|
9
|
+
import { DocumentType, buildCircleSnapshot, expectType, parseDocument, parseVaultHeader, verifyDocument } from "./codec.js";
|
|
10
10
|
import { randomBytes, ready } from "./crypto.js";
|
|
11
11
|
import { prepareEpoch, resolvePublish } from "./epoch.js";
|
|
12
12
|
import { KavachError, isKavachError } from "./errors.js";
|
|
@@ -14,10 +14,11 @@ import { acceptCertificate, certify, deviceFromSeed, newAccountSeed, newDevice,
|
|
|
14
14
|
import { ensureHistoryIndex } from "./historyIndex.js";
|
|
15
15
|
import { ensureSlots, forgetAuthor, forgetAuthorIn, history, historyPage, newest, newestSlot, purgeHistory, receive as receiveRecord, syncStream } from "./inbox.js";
|
|
16
16
|
import { drain, enqueue, outboxCount, outboxCounts, purgeOutbox } from "./outbox.js";
|
|
17
|
-
import { kindOf, validatePolicy } from "./policy.js";
|
|
17
|
+
import { acceptsSuccession, allowsContinuity, kindOf, validatePolicy } from "./policy.js";
|
|
18
18
|
import { ResetTrust } from "./reset.js";
|
|
19
19
|
import { big, circleToRow, pinToRow, rowToCircle, rowToPin, rowToPrepared, unbig } from "./rows.js";
|
|
20
20
|
import { META, REVISION, STREAM, VAULT, openStore, t1Activate, t1Header, t1Secrets, t2PrepareActivation, t3ApplyChange, t3Circle, t4PersistPrepared, t5Committed, t8OwnerSnapshot, t9MarkVault, t9Vault } from "./transactions.js";
|
|
21
|
+
import { applySuccession, authenticateSuccession, pinnedState, recordChainIn, rootIdentityIn, rootState, sameTransition, transitionOf, validateTransition, verifyChain } from "./succession.js";
|
|
21
22
|
import { buildVault, encodeRecoveryCode, newRecoveryKey, openVault, recoverAccount, removeEntry, upsertEntry } from "./vault.js";
|
|
22
23
|
/** accountSeed, deviceSeed, recoveryKey, instanceId and deviceId of a pending reset, in the secret store. */
|
|
23
24
|
const RESET_ROOTS_BYTES = 32 * 3 + 16 + 16;
|
|
@@ -37,14 +38,27 @@ function sameEntries(a, b) {
|
|
|
37
38
|
const key = (es) => es.map((e) => `${toHex(e.circleId)}:${toHex(e.ownerIdentityId)}:${e.sharingEnabled ? 1 : 0}`).sort().join(",");
|
|
38
39
|
return key(a) === key(b);
|
|
39
40
|
}
|
|
41
|
+
function proposalOf(row) {
|
|
42
|
+
return { successionId: fromHex(row.successionId), accountName: row.accountName, accountId: fromHex(row.accountId), expectedCertificateHash: fromHex(row.expectedCertificateHash), expectedGeneration: unbig(row.expectedGeneration), certificate: fromHex(row.certificate) };
|
|
43
|
+
}
|
|
44
|
+
/** The account id a directory answer names: the root of its succession chain, else the identity of its key. */
|
|
45
|
+
export function resolvedAccountId(r) {
|
|
46
|
+
const first = r.successions?.[0];
|
|
47
|
+
if (first)
|
|
48
|
+
return expectType(parseDocument(first.wire), DocumentType.AccountSuccession, "resolve").accountId;
|
|
49
|
+
const doc = expectType(verifyDocument(r.certificate), DocumentType.AccountDevice, "resolve");
|
|
50
|
+
return pinFromCertificate(doc).identityId;
|
|
51
|
+
}
|
|
40
52
|
export const DEFAULT_CATCH_UP = { maxRecords: 500, maxMs: 5_000 };
|
|
41
|
-
function syncError(operation, e) {
|
|
42
|
-
return e instanceof KavachError ? { operation, code: e.code, stage: e.stage, retryable: e.retryable } : { operation, code: "UNEXPECTED", stage: operation, retryable: false };
|
|
53
|
+
function syncError(operation, e, extra = {}) {
|
|
54
|
+
return e instanceof KavachError ? { operation, code: e.code, stage: e.stage, retryable: e.retryable, ...extra } : { operation, code: "UNEXPECTED", stage: operation, retryable: false, ...extra };
|
|
43
55
|
}
|
|
44
56
|
const RESET_LIST_RECHECK_MS = 86400000n;
|
|
45
57
|
export async function open(options) {
|
|
46
58
|
await ready();
|
|
47
59
|
const policy = validatePolicy(options.retentionPolicy);
|
|
60
|
+
if (!options.recoveryProvider && allowsContinuity(policy))
|
|
61
|
+
throw new KavachError("MALFORMED", "open", "recoveryProvider is required when the profile enables identity continuity");
|
|
48
62
|
const impl = new ClientImpl(options, policy);
|
|
49
63
|
await impl.load();
|
|
50
64
|
return impl;
|
|
@@ -57,13 +71,21 @@ class ClientImpl {
|
|
|
57
71
|
secrets;
|
|
58
72
|
transport;
|
|
59
73
|
provider;
|
|
74
|
+
/** A vault exists only where a recovery provider does: a recovery-only profile without one has no path that reads it. */
|
|
75
|
+
get vaultEnabled() {
|
|
76
|
+
return this.provider !== undefined;
|
|
77
|
+
}
|
|
60
78
|
now;
|
|
61
79
|
listeners = new Set();
|
|
62
80
|
account;
|
|
81
|
+
/** Retired by a succession or a later device: nothing is written from here again. */
|
|
82
|
+
retired = false;
|
|
63
83
|
device;
|
|
64
84
|
selfPin;
|
|
65
85
|
trust;
|
|
66
86
|
syncing;
|
|
87
|
+
/** The current sync's reading budget: records, and the links of a succession chain, count against it. */
|
|
88
|
+
syncBudget;
|
|
67
89
|
backupChecked = false;
|
|
68
90
|
circles;
|
|
69
91
|
constructor(options, policy) {
|
|
@@ -93,14 +115,16 @@ class ClientImpl {
|
|
|
93
115
|
const opened = await openStore(this.store, this.secrets, this.accountName);
|
|
94
116
|
if (opened.kind === "fresh")
|
|
95
117
|
return;
|
|
118
|
+
this.retired = opened.kind === "RETIRED";
|
|
96
119
|
await ensureHistoryIndex(this.store);
|
|
97
120
|
await ensureSlots(this.store, this.policy);
|
|
98
121
|
this.account = opened.account;
|
|
99
122
|
const deviceSeed = await this.secrets.get("deviceSeed");
|
|
100
123
|
const self = await this.store.read((tx) => tx.get("self", "self"));
|
|
101
|
-
if (deviceSeed && self) {
|
|
124
|
+
if (deviceSeed && self && opened.account) {
|
|
125
|
+
const accountId = opened.account.accountId;
|
|
102
126
|
this.device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
|
|
103
|
-
const pinRow = await this.store.read((tx) => tx.get("pins", toHex(
|
|
127
|
+
const pinRow = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
|
|
104
128
|
if (pinRow)
|
|
105
129
|
this.selfPin = rowToPin(pinRow);
|
|
106
130
|
}
|
|
@@ -140,13 +164,15 @@ class ClientImpl {
|
|
|
140
164
|
return () => this.listeners.delete(listener);
|
|
141
165
|
}
|
|
142
166
|
requireAccount(stage) {
|
|
167
|
+
if (this.retired)
|
|
168
|
+
throw new KavachError("SUPERSEDED_DEVICE", stage, "this device is retired");
|
|
143
169
|
if (!this.account || !this.device || !this.selfPin)
|
|
144
170
|
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no account on this device");
|
|
145
171
|
return { account: this.account, device: this.device, pin: this.selfPin };
|
|
146
172
|
}
|
|
147
173
|
selfView() {
|
|
148
174
|
const { device, pin } = this.requireAccount("self");
|
|
149
|
-
return { identityId: pin.
|
|
175
|
+
return { identityId: pin.accountId, certificateHash: pin.certificateHash, box: device.box };
|
|
150
176
|
}
|
|
151
177
|
outboxDeps() {
|
|
152
178
|
const { device, pin } = this.requireAccount("outbox");
|
|
@@ -155,7 +181,7 @@ class ClientImpl {
|
|
|
155
181
|
relay: this.transport.relay,
|
|
156
182
|
policy: this.policy,
|
|
157
183
|
now: this.now,
|
|
158
|
-
author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.
|
|
184
|
+
author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.accountId }),
|
|
159
185
|
events: {
|
|
160
186
|
queued: (recordId, recordKind) => this.emit({ kind: "queued", recordId, recordKind }),
|
|
161
187
|
accepted: (recordId) => this.emit({ kind: "accepted", recordId }),
|
|
@@ -185,9 +211,11 @@ class ClientImpl {
|
|
|
185
211
|
// State -----------------------------------------------------------------------------------------
|
|
186
212
|
async getState() {
|
|
187
213
|
const meta = await this.store.read((tx) => tx.get("meta", META));
|
|
188
|
-
const base = { identityId: this.selfPin?.identityId, certificateHash: this.selfPin?.certificateHash, generation: this.selfPin?.generation, outbox: 0, waitingFor: [], circles: [] };
|
|
189
|
-
if (!meta || !this.account)
|
|
190
|
-
|
|
214
|
+
const base = { identityId: this.selfPin?.accountId, keyIdentityId: this.selfPin?.identityId, certificateHash: this.selfPin?.certificateHash, generation: this.selfPin?.generation, outbox: 0, waitingFor: [], circles: [] };
|
|
215
|
+
if (!meta || (!this.account && meta.state !== "RETIRED")) {
|
|
216
|
+
const proposal = await this.store.read((tx) => tx.get("replacement", "pending"));
|
|
217
|
+
return { status: proposal && !proposal.stale ? "replacementPrepared" : "fresh", paused: undefined, ...base };
|
|
218
|
+
}
|
|
191
219
|
const circles = await this.listCircles();
|
|
192
220
|
const outbox = await outboxCount(this.store);
|
|
193
221
|
const waitingFor = await this.waitingFor();
|
|
@@ -209,7 +237,7 @@ class ClientImpl {
|
|
|
209
237
|
async waitingFor() {
|
|
210
238
|
if (!this.selfPin)
|
|
211
239
|
return [];
|
|
212
|
-
const selfHex = toHex(this.selfPin.
|
|
240
|
+
const selfHex = toHex(this.selfPin.accountId);
|
|
213
241
|
return this.store.read((tx) => {
|
|
214
242
|
const authors = new Set();
|
|
215
243
|
for (const [, c] of tx.list("circles"))
|
|
@@ -235,9 +263,15 @@ class ClientImpl {
|
|
|
235
263
|
const stream = tx.get("stream", STREAM);
|
|
236
264
|
const revision = tx.get("revision", REVISION);
|
|
237
265
|
const vault = tx.get("vault", VAULT);
|
|
266
|
+
const meta = tx.get("meta", META);
|
|
267
|
+
const proposal = tx.get("replacement", "pending");
|
|
238
268
|
const pendingEpoch = tx.list("epochs").find(([, e]) => e.status === "prepared" || e.status === "committed")?.[1].epoch;
|
|
239
269
|
return {
|
|
240
|
-
identityId: this.selfPin?.
|
|
270
|
+
identityId: this.selfPin?.accountId,
|
|
271
|
+
keyIdentityId: this.selfPin?.identityId,
|
|
272
|
+
replacementModel: this.policy.replacement,
|
|
273
|
+
replacement: proposal ? { successionId: fromHex(proposal.successionId), expectedGeneration: unbig(proposal.expectedGeneration), stale: proposal.stale, conflictGeneration: proposal.conflictGeneration === undefined ? undefined : unbig(proposal.conflictGeneration) } : undefined,
|
|
274
|
+
bootstrap: meta?.bootstrap ? { cursor: meta.bootstrap.cursor, complete: meta.bootstrap.complete } : undefined,
|
|
241
275
|
certificateHash: this.selfPin?.certificateHash,
|
|
242
276
|
generation: this.selfPin?.generation,
|
|
243
277
|
desiredRevision: revision ? unbig(revision.desiredRevision) : 1n,
|
|
@@ -272,8 +306,8 @@ class ClientImpl {
|
|
|
272
306
|
const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
|
|
273
307
|
const iid = instanceId;
|
|
274
308
|
await this.store.transaction((tx) => t1Header(tx, { account, device, certificate, instanceId: iid, generation: 1n }));
|
|
275
|
-
const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey);
|
|
276
|
-
const outcome = await this.transport.directory.registerAccount({ certificate, vault: vault
|
|
309
|
+
const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
|
|
310
|
+
const outcome = await this.transport.directory.registerAccount({ certificate, vault: vault?.wire });
|
|
277
311
|
if (outcome.status === "CONFLICT") {
|
|
278
312
|
// A restart or a lost response after the host committed: the name is ours if the host holds
|
|
279
313
|
// exactly the certificate this device saved; anything else is somebody else's account.
|
|
@@ -285,7 +319,8 @@ class ClientImpl {
|
|
|
285
319
|
throw new KavachError("NOT_AUTHORIZED", stage, `host refused registration: ${outcome.status}`);
|
|
286
320
|
}
|
|
287
321
|
await this.store.transaction((tx) => {
|
|
288
|
-
|
|
322
|
+
if (vault)
|
|
323
|
+
t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
|
|
289
324
|
t1Activate(tx);
|
|
290
325
|
});
|
|
291
326
|
this.account = account;
|
|
@@ -293,7 +328,7 @@ class ClientImpl {
|
|
|
293
328
|
this.selfPin = pin;
|
|
294
329
|
// The recovery key goes to the provider; enrollment never blocks on it or on the code.
|
|
295
330
|
try {
|
|
296
|
-
await this.provider
|
|
331
|
+
await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
|
|
297
332
|
}
|
|
298
333
|
catch {
|
|
299
334
|
/* status is reported by recoveryStatus() */
|
|
@@ -302,9 +337,15 @@ class ClientImpl {
|
|
|
302
337
|
return { identityId: pin.identityId };
|
|
303
338
|
}
|
|
304
339
|
async recoveryStatus() {
|
|
340
|
+
if (!this.provider)
|
|
341
|
+
return { status: "unsupported", backupState: "unavailable", reason: "no recovery provider on a recovery-only profile" };
|
|
305
342
|
return this.provider.status();
|
|
306
343
|
}
|
|
307
344
|
async recoveryCode() {
|
|
345
|
+
// A code opens a vault for identity continuity; where the profile disables that, or where no
|
|
346
|
+
// vault exists, the code would open nothing and is not issued.
|
|
347
|
+
if (!allowsContinuity(this.policy) || !this.vaultEnabled)
|
|
348
|
+
throw new KavachError("NOT_AUTHORIZED", "recoveryCode", "this deployment does not restore accounts by recovery code");
|
|
308
349
|
const key = await this.secrets.get("recoveryKey");
|
|
309
350
|
if (!key)
|
|
310
351
|
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", "recoveryCode");
|
|
@@ -324,6 +365,8 @@ class ClientImpl {
|
|
|
324
365
|
// moved under us): prepare again with the same device keys. Nothing was allocated remotely.
|
|
325
366
|
pending = resumed;
|
|
326
367
|
}
|
|
368
|
+
if (!allowsContinuity(this.policy))
|
|
369
|
+
throw new KavachError("NOT_AUTHORIZED", stage, "this deployment replaces devices through successions, not the recovery key");
|
|
327
370
|
const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
|
|
328
371
|
if (!resolved)
|
|
329
372
|
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
|
|
@@ -331,7 +374,13 @@ class ClientImpl {
|
|
|
331
374
|
const vaultWire = await this.transport.directory.getVault(this.accountName);
|
|
332
375
|
if (!vaultWire)
|
|
333
376
|
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no vault");
|
|
377
|
+
const accountId = resolvedAccountId(resolved);
|
|
334
378
|
const identity = pinFromCertificate(current).identityId;
|
|
379
|
+
if (!this.provider)
|
|
380
|
+
throw new KavachError("NOT_AUTHORIZED", stage, "identity continuity needs a recovery provider");
|
|
381
|
+
// An account that succeeded: the served chain is verified link by link under the current list,
|
|
382
|
+
// from the root to the key this device is about to hold, before anything is written.
|
|
383
|
+
const verified = resolved.successions?.length ? (await this.followChain({ accountId, accountName: this.accountName, finalIdentityId: identity, firstPage: resolved.successions, next: resolved.successionsNext })).verified : [];
|
|
335
384
|
const recovered = await recoverAccount({
|
|
336
385
|
accountName: this.accountName,
|
|
337
386
|
identityId: identity,
|
|
@@ -340,12 +389,12 @@ class ClientImpl {
|
|
|
340
389
|
provider: this.provider,
|
|
341
390
|
...(recoveryCode === undefined ? {} : { recoveryCode })
|
|
342
391
|
});
|
|
343
|
-
const account = openAccount(this.accountName, recovered.vault.accountSeed);
|
|
392
|
+
const account = openAccount(this.accountName, recovered.vault.accountSeed, accountId);
|
|
344
393
|
// Fresh device keys and a fresh local store. Nothing from the old device is copied.
|
|
345
394
|
const device = pending?.device ?? newDevice();
|
|
346
395
|
const generation = current.generation + 1n;
|
|
347
396
|
const certificate = certify(account, device, generation);
|
|
348
|
-
const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
|
|
397
|
+
const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice), undefined, accountId);
|
|
349
398
|
const header = parseVaultHeader(vaultWire);
|
|
350
399
|
const nextVault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: header.vaultRevision + 1n }, { accountSeed: recovered.vault.accountSeed, lastDeviceGeneration: generation, lastCertificateHash: pin.certificateHash, entries: recovered.vault.entries }, recovered.recoveryKey);
|
|
351
400
|
const instanceId = pending?.instanceId ?? randomBytes(16);
|
|
@@ -353,6 +402,8 @@ class ClientImpl {
|
|
|
353
402
|
await t1Secrets(this.secrets, { accountSeed: recovered.vault.accountSeed, deviceSeed: device.deviceSeed, recoveryKey: recovered.recoveryKey, instanceId });
|
|
354
403
|
await this.store.transaction((tx) => {
|
|
355
404
|
t1Header(tx, { account, device, certificate, instanceId, generation });
|
|
405
|
+
recordChainIn(tx, verified, this.now());
|
|
406
|
+
rootIdentityIn(tx, accountId, this.accountName, resolved.rootCertificate);
|
|
356
407
|
t2PrepareActivation(tx, {
|
|
357
408
|
certificate: toHex(certificate),
|
|
358
409
|
vault: toHex(nextVault.wire),
|
|
@@ -387,7 +438,7 @@ class ClientImpl {
|
|
|
387
438
|
tx.delete("activation", "pending");
|
|
388
439
|
t9Vault(tx, { revision: big(input.vaultRevision), hash: toHex(input.vault.hash), wire: toHex(input.vault.wire), pending: false, dirty: false });
|
|
389
440
|
for (const e of input.entries) {
|
|
390
|
-
const owned = equalBytes(e.ownerIdentityId, pin.
|
|
441
|
+
const owned = equalBytes(e.ownerIdentityId, pin.accountId);
|
|
391
442
|
const c = pinCircle(e.circleId, e.ownerIdentityId, e.sharingEnabled, owned ? "member" : "requested");
|
|
392
443
|
tx.put("circles", toHex(e.circleId), circleToRow(c));
|
|
393
444
|
}
|
|
@@ -398,7 +449,7 @@ class ClientImpl {
|
|
|
398
449
|
this.device = device;
|
|
399
450
|
this.selfPin = pin;
|
|
400
451
|
try {
|
|
401
|
-
await this.provider
|
|
452
|
+
await this.provider?.save(this.accountName, pin.identityId, input.recoveryKey);
|
|
402
453
|
}
|
|
403
454
|
catch {
|
|
404
455
|
/* reported by recoveryStatus() */
|
|
@@ -418,14 +469,15 @@ class ClientImpl {
|
|
|
418
469
|
if (!row)
|
|
419
470
|
throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its prepared bytes");
|
|
420
471
|
const [accountSeed, deviceSeed, recoveryKey, instanceId] = await Promise.all([this.secrets.get("accountSeed"), this.secrets.get("deviceSeed"), this.secrets.get("recoveryKey"), this.secrets.get("instanceId")]);
|
|
421
|
-
const self = await this.store.read((tx) => tx.get("self", "self"));
|
|
422
|
-
if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId || !self)
|
|
472
|
+
const [self, meta] = await this.store.read((tx) => [tx.get("self", "self"), tx.get("meta", META)]);
|
|
473
|
+
if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId || !self || !meta)
|
|
423
474
|
throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its roots");
|
|
424
|
-
const
|
|
475
|
+
const accountId = fromHex(meta.accountId ?? meta.identityId);
|
|
476
|
+
const account = openAccount(this.accountName, accountSeed, accountId);
|
|
425
477
|
const device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
|
|
426
478
|
const certificate = fromHex(row.certificate);
|
|
427
479
|
const prepared = expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage);
|
|
428
|
-
const pin = pinFromCertificate(prepared);
|
|
480
|
+
const pin = pinFromCertificate(prepared, undefined, accountId);
|
|
429
481
|
const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
|
|
430
482
|
if (!me)
|
|
431
483
|
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
|
|
@@ -457,7 +509,7 @@ class ClientImpl {
|
|
|
457
509
|
* asks the provider and saves again when it has nothing for this identity.
|
|
458
510
|
*/
|
|
459
511
|
async ensureBackup() {
|
|
460
|
-
if (this.backupChecked || !this.selfPin)
|
|
512
|
+
if (this.backupChecked || !this.selfPin || !this.provider)
|
|
461
513
|
return;
|
|
462
514
|
this.backupChecked = true;
|
|
463
515
|
try {
|
|
@@ -503,7 +555,7 @@ class ClientImpl {
|
|
|
503
555
|
/** After commit: the device's own record is stored like any author's, so one subscription covers self and others. */
|
|
504
556
|
enqueued(kind, r) {
|
|
505
557
|
if (!r.duplicate && this.selfPin)
|
|
506
|
-
this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.
|
|
558
|
+
this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.accountId, recordId: r.recordId, tookSlot: r.own });
|
|
507
559
|
return { recordId: r.recordId, duplicate: r.duplicate };
|
|
508
560
|
}
|
|
509
561
|
// Epochs ----------------------------------------------------------------------------------------
|
|
@@ -511,7 +563,15 @@ class ClientImpl {
|
|
|
511
563
|
const { pin } = this.requireAccount("audience");
|
|
512
564
|
const retired = new Set(this.trust.state.retired);
|
|
513
565
|
return this.store.read((tx) => {
|
|
566
|
+
// A reset retires the whole account, however many successions it went through.
|
|
567
|
+
for (const [, r] of tx.list("identities"))
|
|
568
|
+
if (r.resetAt !== undefined)
|
|
569
|
+
retired.add(r.accountId);
|
|
514
570
|
const pins = new Map(tx.list("pins").map(([k, p]) => [k, rowToPin(p)]));
|
|
571
|
+
// An account whose chain this device has not finished verifying is out of every audience until it has.
|
|
572
|
+
for (const [, p] of tx.list("chainProgress"))
|
|
573
|
+
if (!p.complete)
|
|
574
|
+
pins.delete(p.accountId);
|
|
515
575
|
const circles = tx.list("circles").map(([, c]) => rowToCircle(c));
|
|
516
576
|
return resolveAudience(pin, circles, pins, retired);
|
|
517
577
|
});
|
|
@@ -552,7 +612,7 @@ class ClientImpl {
|
|
|
552
612
|
else {
|
|
553
613
|
const revision = revisionRow ? unbig(revisionRow.desiredRevision) : 1n;
|
|
554
614
|
const fresh = prepareEpoch({
|
|
555
|
-
author: { identityId: pin.
|
|
615
|
+
author: { identityId: pin.accountId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
|
|
556
616
|
epoch: stream.highestCommittedEpoch + 1,
|
|
557
617
|
createdAt: this.now(),
|
|
558
618
|
audience,
|
|
@@ -605,13 +665,32 @@ class ClientImpl {
|
|
|
605
665
|
}
|
|
606
666
|
async runSync(options) {
|
|
607
667
|
const report = {
|
|
608
|
-
metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0 },
|
|
668
|
+
metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
|
|
609
669
|
epoch: "unchanged",
|
|
610
670
|
outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
|
|
611
671
|
inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
|
|
612
672
|
purged: 0,
|
|
613
673
|
errors: []
|
|
614
674
|
};
|
|
675
|
+
this.syncBudget = {
|
|
676
|
+
remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
|
|
677
|
+
deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
|
|
678
|
+
};
|
|
679
|
+
if (!this.selfPin) {
|
|
680
|
+
// A prepared replacement whose commit this device never saw: the chain says whether it landed.
|
|
681
|
+
const row = await this.store.read((tx) => tx.get("replacement", "pending"));
|
|
682
|
+
if (row && !row.stale) {
|
|
683
|
+
try {
|
|
684
|
+
const link = await this.findOwnLink(row);
|
|
685
|
+
const roots = await this.secrets.get("pendingReplacement");
|
|
686
|
+
if (link && roots && roots.length === RESET_ROOTS_BYTES)
|
|
687
|
+
await this.activateReplacement(row, roots, link.wire);
|
|
688
|
+
}
|
|
689
|
+
catch (e) {
|
|
690
|
+
report.errors.push(syncError("resumeReplacement", e));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
615
694
|
if (!this.account || !this.selfPin)
|
|
616
695
|
return report;
|
|
617
696
|
const meta = await this.store.read((tx) => tx.get("meta", META));
|
|
@@ -643,12 +722,26 @@ class ClientImpl {
|
|
|
643
722
|
else if (meta.state !== "ACTIVE")
|
|
644
723
|
return report;
|
|
645
724
|
await this.ensureBackup();
|
|
725
|
+
if (meta.bootstrap && !meta.bootstrap.complete) {
|
|
726
|
+
try {
|
|
727
|
+
report.bootstrap = await this.bootstrap(report, meta.bootstrap);
|
|
728
|
+
}
|
|
729
|
+
catch (e) {
|
|
730
|
+
report.errors.push(syncError("bootstrap", e));
|
|
731
|
+
}
|
|
732
|
+
}
|
|
646
733
|
try {
|
|
647
734
|
await this.syncMetadata(report);
|
|
648
735
|
}
|
|
649
736
|
catch (e) {
|
|
650
737
|
report.errors.push(syncError("metadata", e));
|
|
651
738
|
}
|
|
739
|
+
try {
|
|
740
|
+
await this.pushPreferences(report);
|
|
741
|
+
}
|
|
742
|
+
catch (e) {
|
|
743
|
+
report.errors.push(syncError("preference", e));
|
|
744
|
+
}
|
|
652
745
|
const after = await this.store.read((tx) => tx.get("meta", META));
|
|
653
746
|
if (after?.state === "RETIRED")
|
|
654
747
|
return report;
|
|
@@ -674,10 +767,9 @@ class ClientImpl {
|
|
|
674
767
|
// Each stream on its own: a failed download or a refused record names its stream in the report
|
|
675
768
|
// and leaves the other streams to be read; a stream that failed is not counted as synced.
|
|
676
769
|
// One budget spans the streams; a stream it stops early reports hasMore.
|
|
677
|
-
const budget =
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
};
|
|
770
|
+
const budget = this.syncBudget;
|
|
771
|
+
if (budget.remainingRecords < 1)
|
|
772
|
+
budget.remainingRecords = 1; // every stream reads at least one page
|
|
681
773
|
for (const s of subs) {
|
|
682
774
|
try {
|
|
683
775
|
const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
|
|
@@ -708,7 +800,16 @@ class ClientImpl {
|
|
|
708
800
|
if (me) {
|
|
709
801
|
const theirs = expectType(verifyDocument(me.certificate), DocumentType.AccountDevice, "sync");
|
|
710
802
|
if (!equalBytes(theirs.accountKey, pin.accountKey)) {
|
|
711
|
-
// Our own key changed at the host
|
|
803
|
+
// Our own key changed at the host. A succession from our identity, attested by an authority
|
|
804
|
+
// we trust, retires this device (its key is no longer the account's); a bare key change or a
|
|
805
|
+
// reset we did not make is ignored here, as before.
|
|
806
|
+
if (await this.supersededBySuccession(pin, me, report)) {
|
|
807
|
+
await this.retire();
|
|
808
|
+
// The key itself is retired, not only the device: nothing of it stays here.
|
|
809
|
+
await this.secrets.delete("accountSeed");
|
|
810
|
+
this.account = undefined;
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
712
813
|
}
|
|
713
814
|
else if (!equalBytes(theirs.bodyHash, pin.certificateHash) && theirs.generation > pin.generation) {
|
|
714
815
|
await this.retire();
|
|
@@ -733,7 +834,7 @@ class ClientImpl {
|
|
|
733
834
|
// Unconfirmed writes of our own first: a successor snapshot the host never took, a vault update
|
|
734
835
|
// it never saw. Until they land, peers read stale membership and a replacement phone a stale
|
|
735
836
|
// preference.
|
|
736
|
-
const pendingOwned = await this.store.read((tx) => tx.list("circles").filter(([, r]) => r.pending && r.ownerIdentityId === toHex(pin.
|
|
837
|
+
const pendingOwned = await this.store.read((tx) => tx.list("circles").filter(([, r]) => r.pending && r.ownerIdentityId === toHex(pin.accountId)).map(([, r]) => fromHex(r.circleId)));
|
|
737
838
|
for (const circleId of pendingOwned) {
|
|
738
839
|
try {
|
|
739
840
|
if ((await this.publishPendingSnapshot(circleId)) === "COMMITTED")
|
|
@@ -755,18 +856,18 @@ class ClientImpl {
|
|
|
755
856
|
for (const c of circles) {
|
|
756
857
|
if (c.status === "closed" || c.status === "quarantined" || c.status === "left")
|
|
757
858
|
continue;
|
|
758
|
-
if (!equalBytes(c.ownerIdentityId, pin.
|
|
859
|
+
if (!equalBytes(c.ownerIdentityId, pin.accountId) && !(await this.pinFor(c.ownerIdentityId)))
|
|
759
860
|
unknownOwners.push(c.ownerIdentityId);
|
|
760
861
|
}
|
|
761
862
|
if (unknownOwners.length > 0) {
|
|
762
863
|
for (const r of await this.transport.directory.resolveIdentities(unknownOwners))
|
|
763
|
-
await this.acceptMemberCertificate(r
|
|
864
|
+
await this.acceptMemberCertificate(r, report);
|
|
764
865
|
}
|
|
765
866
|
const memberIds = new Map();
|
|
766
867
|
for (const c of circles) {
|
|
767
868
|
if (c.status === "closed" || c.status === "quarantined" || c.status === "left")
|
|
768
869
|
continue;
|
|
769
|
-
const ownerPin = equalBytes(c.ownerIdentityId, pin.
|
|
870
|
+
const ownerPin = equalBytes(c.ownerIdentityId, pin.accountId) ? pin : await this.pinFor(c.ownerIdentityId);
|
|
770
871
|
if (!ownerPin) {
|
|
771
872
|
memberIds.set(toHex(c.ownerIdentityId), c.ownerIdentityId);
|
|
772
873
|
continue;
|
|
@@ -774,7 +875,7 @@ class ClientImpl {
|
|
|
774
875
|
const wire = await this.transport.directory.getCircle(c.circleId);
|
|
775
876
|
if (wire) {
|
|
776
877
|
try {
|
|
777
|
-
const r =
|
|
878
|
+
const r = await this.acceptSnapshotFrom(c, wire, ownerPin, now, report);
|
|
778
879
|
if (r.outcome === "ACCEPTED") {
|
|
779
880
|
// The row may have moved since it was read: a sharing preference, a leave or a close
|
|
780
881
|
// made meanwhile wins over the fetched snapshot, and a newer snapshot already stored
|
|
@@ -821,7 +922,7 @@ class ClientImpl {
|
|
|
821
922
|
else
|
|
822
923
|
for (const m of c.members)
|
|
823
924
|
memberIds.set(toHex(m), m);
|
|
824
|
-
if (equalBytes(c.ownerIdentityId, pin.
|
|
925
|
+
if (equalBytes(c.ownerIdentityId, pin.accountId) && c.status === "member") {
|
|
825
926
|
const requests = await this.transport.directory.readRequests(c.circleId);
|
|
826
927
|
const joins = requests.filter((r) => r.action === "join").map((r) => r.accountName);
|
|
827
928
|
if (joins.length)
|
|
@@ -830,9 +931,8 @@ class ClientImpl {
|
|
|
830
931
|
const [acct] = await this.transport.directory.resolveAccounts([r.accountName]);
|
|
831
932
|
if (!acct)
|
|
832
933
|
continue;
|
|
833
|
-
const leaver = pinFromCertificate(expectType(verifyDocument(acct.certificate), DocumentType.AccountDevice));
|
|
834
934
|
try {
|
|
835
|
-
await this.changeMembership(c.circleId, { remove:
|
|
935
|
+
await this.changeMembership(c.circleId, { remove: resolvedAccountId(acct) });
|
|
836
936
|
}
|
|
837
937
|
catch (e) {
|
|
838
938
|
report.errors.push(syncError("removeLeaver", e));
|
|
@@ -840,28 +940,308 @@ class ClientImpl {
|
|
|
840
940
|
}
|
|
841
941
|
}
|
|
842
942
|
}
|
|
843
|
-
memberIds.delete(toHex(pin.
|
|
943
|
+
memberIds.delete(toHex(pin.accountId));
|
|
844
944
|
if (memberIds.size > 0) {
|
|
845
945
|
const resolved = await this.transport.directory.resolveIdentities([...memberIds.values()]);
|
|
846
946
|
const byIdentity = new Map();
|
|
847
947
|
for (const r of resolved) {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
await this.acceptMemberCertificate(r.certificate, r.resetReceipt, report);
|
|
948
|
+
expectType(verifyDocument(r.certificate), DocumentType.AccountDevice, "sync");
|
|
949
|
+
byIdentity.set(toHex(resolvedAccountId(r)), { accountName: r.accountName, accountKey: r.accountKey });
|
|
950
|
+
await this.acceptMemberCertificate(r, report);
|
|
852
951
|
}
|
|
853
952
|
checkMemberResolution(byIdentity);
|
|
854
953
|
}
|
|
855
954
|
}
|
|
856
|
-
|
|
857
|
-
|
|
955
|
+
/**
|
|
956
|
+
* Has the host's chain for our own account moved past our key? A valid succession from our
|
|
957
|
+
* current identity means an authority replaced this account's key; the device holding the old
|
|
958
|
+
* key has nothing left to say. Deferred (no fresh list, untrusted clock) and invalid
|
|
959
|
+
* attestations leave the device as it is.
|
|
960
|
+
*/
|
|
961
|
+
async supersededBySuccession(pin, me, report) {
|
|
962
|
+
if (!me.successions?.length)
|
|
963
|
+
return false;
|
|
964
|
+
try {
|
|
965
|
+
const r = await this.followChain({ accountId: pin.accountId, accountName: pin.accountName, current: pin, firstPage: me.successions, next: me.successionsNext, budget: this.syncBudget });
|
|
966
|
+
return r.pin !== undefined && !equalBytes(r.pin.identityId, pin.identityId);
|
|
967
|
+
}
|
|
968
|
+
catch (e) {
|
|
969
|
+
// A chain this device cannot follow for its own account is a diagnostic the product must
|
|
970
|
+
// see: the host has already fenced the device, and only this report says why it still runs.
|
|
971
|
+
report.errors.push(syncError("ownSuccession", e));
|
|
972
|
+
return false;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Verify a snapshot under the owner's current key, or, when the owner's key changed through a
|
|
977
|
+
* succession and the snapshot predates it, under the retired key it was signed with. The
|
|
978
|
+
* successor republishes at the next revision; until then the older snapshot stays valid.
|
|
979
|
+
*/
|
|
980
|
+
async acceptSnapshotFrom(c, wire, ownerPin, now, report, resolved = false) {
|
|
981
|
+
const { pin } = this.requireAccount("sync");
|
|
982
|
+
// The very bytes this device already verified (a bootstrap snapshot under a key since retired)
|
|
983
|
+
// are a duplicate whatever key signed them; only new bytes are verified.
|
|
984
|
+
if (c.snapshot && equalBytes(c.snapshot, wire))
|
|
985
|
+
return { outcome: "DUPLICATE", state: c };
|
|
986
|
+
try {
|
|
987
|
+
return acceptSnapshot(c, wire, ownerPin.accountKey, pin.accountId, now);
|
|
988
|
+
}
|
|
989
|
+
catch (e) {
|
|
990
|
+
if (!isKavachError(e, "BAD_SIGNATURE"))
|
|
991
|
+
throw e;
|
|
992
|
+
// A retired key may only vouch for a circle this device holds at revision 0 (bootstrap, first
|
|
993
|
+
// contact): an established circle advances under the current owner key alone.
|
|
994
|
+
const ownerHex = toHex(ownerPin.accountId);
|
|
995
|
+
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)] : []))) : [];
|
|
996
|
+
for (const key of retiredKeys) {
|
|
997
|
+
try {
|
|
998
|
+
return acceptSnapshot(c, wire, key, pin.accountId, now);
|
|
999
|
+
}
|
|
1000
|
+
catch (inner) {
|
|
1001
|
+
if (!isKavachError(inner, "BAD_SIGNATURE"))
|
|
1002
|
+
throw inner;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
// Signed by a key we do not know: the owner may have succeeded since we last resolved it.
|
|
1006
|
+
// The chain is verified first; only a key it produced may sign this circle.
|
|
1007
|
+
if (!resolved && report && !equalBytes(ownerPin.accountId, pin.accountId)) {
|
|
1008
|
+
const [r] = await this.transport.directory.resolveIdentities([ownerPin.accountId]);
|
|
1009
|
+
if (r && !equalBytes(r.accountKey, ownerPin.accountKey)) {
|
|
1010
|
+
await this.acceptMemberCertificate(r, report);
|
|
1011
|
+
const fresh = await this.pinFor(ownerPin.accountId);
|
|
1012
|
+
if (fresh && !equalBytes(fresh.accountKey, ownerPin.accountKey))
|
|
1013
|
+
return this.acceptSnapshotFrom(c, wire, fresh, now, report, true);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
throw e;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
async pinFor(accountId) {
|
|
1020
|
+
const row = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
|
|
858
1021
|
return row ? rowToPin(row) : undefined;
|
|
859
1022
|
}
|
|
860
|
-
/**
|
|
861
|
-
|
|
1023
|
+
/**
|
|
1024
|
+
* The one path every succession takes (section 4, "Chains"): the policy gate first, then the
|
|
1025
|
+
* key list, then each link verified in order from the start identity (the pinned identity for
|
|
1026
|
+
* an established contact, the account id at first contact or for a restoring device), page by
|
|
1027
|
+
* page within the sync's budget, with the verified prefix persisted so that the next sync
|
|
1028
|
+
* resumes instead of starting over. Only when the chain is complete does anything move: for a
|
|
1029
|
+
* pinned contact every link is applied in one transaction, so no epoch is ever sealed to an
|
|
1030
|
+
* intermediate successor; for a first contact or a restore the verified links are returned and
|
|
1031
|
+
* the caller records them with the pin or the header. Any failure leaves the pin where it was.
|
|
1032
|
+
*/
|
|
1033
|
+
async followChain(input) {
|
|
1034
|
+
const stage = "chain";
|
|
1035
|
+
if (!acceptsSuccession(this.policy))
|
|
1036
|
+
throw new KavachError("UNSUPPORTED_TYPE", stage, "this deployment does not accept successions");
|
|
1037
|
+
const accountHex = toHex(input.accountId);
|
|
1038
|
+
const startIdentity = input.current?.identityId ?? input.accountId;
|
|
1039
|
+
await this.ensureResetList();
|
|
1040
|
+
// The state the chain is validated against: the pin with what this store retired, or the root.
|
|
1041
|
+
const current = input.current;
|
|
1042
|
+
let state = current ? await this.store.read((tx) => pinnedState(tx, current, this.trust)) : rootState(input.accountId, input.accountName, this.trust);
|
|
1043
|
+
// Reconcile first: the host serves the chain from the root, and a pinned contact already holds
|
|
1044
|
+
// a prefix of it. Links whose transition is on record are dropped (the same transition at a
|
|
1045
|
+
// recorded state; a different one is the authority signing two successors), so verification
|
|
1046
|
+
// starts at the pinned identity with only the transitions this device has not seen.
|
|
1047
|
+
const recorded = current ? await this.store.read((tx) => tx.list("successions", accountHex + ":").map(([, r]) => r)) : [];
|
|
1048
|
+
// The bound, applied to every page alike: links past `untilGeneration` are not this call's to
|
|
1049
|
+
// follow (a replacing device stops at the state its own succession left from).
|
|
1050
|
+
const within = (l) => input.untilGeneration === undefined || expectType(parseDocument(l.wire), DocumentType.AccountSuccession, stage).newGeneration <= input.untilGeneration;
|
|
1051
|
+
const unseen = (links) => links.filter(within).filter((l) => {
|
|
1052
|
+
const doc = expectType(parseDocument(l.wire), DocumentType.AccountSuccession, stage);
|
|
1053
|
+
const t = transitionOf(doc);
|
|
1054
|
+
const row = recorded.find((r) => unbig(r.previousGeneration) === t.previousGeneration && r.previousIdentityId === t.previousIdentityId);
|
|
1055
|
+
if (!row)
|
|
1056
|
+
return true;
|
|
1057
|
+
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)) {
|
|
1058
|
+
// A different transition at a recorded state is evidence against the authority only if
|
|
1059
|
+
// the authority signed it: unauthenticated bytes are a bad signature, nothing more.
|
|
1060
|
+
authenticateSuccession(l.wire, this.trust, this.now());
|
|
1061
|
+
throw new KavachError("EQUIVOCATION", stage, "two successors for one account state");
|
|
1062
|
+
}
|
|
1063
|
+
return false;
|
|
1064
|
+
});
|
|
1065
|
+
const progress = await this.store.read((tx) => tx.get("chainProgress", accountHex));
|
|
1066
|
+
const verified = [];
|
|
1067
|
+
let cursor;
|
|
1068
|
+
let pending;
|
|
1069
|
+
// Only links this device has not recorded are verified, and only they count against the budget.
|
|
1070
|
+
const verifyMore = (links) => {
|
|
1071
|
+
const fresh = unseen(links);
|
|
1072
|
+
const r = verifyChain(fresh, { state, trust: this.trust, now: this.now() });
|
|
1073
|
+
verified.push(...r.links);
|
|
1074
|
+
state = r.state;
|
|
1075
|
+
if (input.budget)
|
|
1076
|
+
input.budget.remainingRecords -= fresh.length;
|
|
1077
|
+
};
|
|
1078
|
+
if (progress && progress.startIdentityId === toHex(startIdentity)) {
|
|
1079
|
+
// Resume at the saved cursor, however long the verified prefix is. The prefix's transitions
|
|
1080
|
+
// were verified when their attestations were allowed, so they are replayed through the
|
|
1081
|
+
// transition rules alone; their bytes are authenticated again below, and refreshed from the
|
|
1082
|
+
// host where a key rotation since made them stale.
|
|
1083
|
+
for (const l of progress.links) {
|
|
1084
|
+
const doc = expectType(parseDocument(fromHex(l.wire)), DocumentType.AccountSuccession, stage);
|
|
1085
|
+
state = validateTransition(doc, fromHex(l.certificate), state).next;
|
|
1086
|
+
verified.push({ doc, wire: fromHex(l.wire), certificate: fromHex(l.certificate) });
|
|
1087
|
+
}
|
|
1088
|
+
cursor = unbig(progress.nextGeneration);
|
|
1089
|
+
pending = [];
|
|
1090
|
+
}
|
|
1091
|
+
else {
|
|
1092
|
+
pending = input.firstPage;
|
|
1093
|
+
cursor = input.next;
|
|
1094
|
+
}
|
|
1095
|
+
const persist = () => this.store.transaction((tx) => {
|
|
1096
|
+
if (cursor === undefined)
|
|
1097
|
+
tx.delete("chainProgress", accountHex);
|
|
1098
|
+
else
|
|
1099
|
+
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 });
|
|
1100
|
+
});
|
|
1101
|
+
const reached = () => input.untilGeneration !== undefined && state.generation >= input.untilGeneration;
|
|
1102
|
+
verifyMore(pending);
|
|
1103
|
+
if (reached())
|
|
1104
|
+
cursor = undefined;
|
|
1105
|
+
while (cursor !== undefined) {
|
|
1106
|
+
if (input.budget && (input.budget.remainingRecords <= 0 || this.now() >= input.budget.deadline)) {
|
|
1107
|
+
await persist();
|
|
1108
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the chain continues past this sync's budget");
|
|
1109
|
+
}
|
|
1110
|
+
let page;
|
|
1111
|
+
try {
|
|
1112
|
+
page = await this.transport.directory.getSuccessions(input.accountId, cursor, 32);
|
|
1113
|
+
}
|
|
1114
|
+
catch (e) {
|
|
1115
|
+
await persist();
|
|
1116
|
+
throw e;
|
|
1117
|
+
}
|
|
1118
|
+
if (page.successions.length === 0) {
|
|
1119
|
+
await persist();
|
|
1120
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the host did not finish serving the chain");
|
|
1121
|
+
}
|
|
1122
|
+
verifyMore(page.successions);
|
|
1123
|
+
cursor = reached() ? undefined : page.nextGeneration;
|
|
1124
|
+
if (cursor !== undefined)
|
|
1125
|
+
await persist();
|
|
1126
|
+
}
|
|
1127
|
+
if (input.finalIdentityId && state.identityId !== toHex(input.finalIdentityId))
|
|
1128
|
+
throw new KavachError("SUCCESSION_BINDING", stage, "the chain does not end at the served key");
|
|
1129
|
+
// Every attestation under the current list before anything is recorded. A link whose signer
|
|
1130
|
+
// left the list since it was verified is fetched again: the host re-attests a legitimate
|
|
1131
|
+
// transition under a current key, and the transition must be the same one.
|
|
1132
|
+
for (let i = 0; i < verified.length; i++) {
|
|
1133
|
+
const link = verified[i];
|
|
1134
|
+
if (!link)
|
|
1135
|
+
continue;
|
|
1136
|
+
try {
|
|
1137
|
+
authenticateSuccession(link.wire, this.trust, this.now());
|
|
1138
|
+
}
|
|
1139
|
+
catch (e) {
|
|
1140
|
+
if (!isKavachError(e, "RESET_KEY_REVOKED"))
|
|
1141
|
+
throw e;
|
|
1142
|
+
const page = await this.transport.directory.getSuccessions(input.accountId, link.doc.newGeneration - 1n, 1);
|
|
1143
|
+
const again = page.successions[0];
|
|
1144
|
+
if (!again)
|
|
1145
|
+
throw e;
|
|
1146
|
+
const doc = authenticateSuccession(again.wire, this.trust, this.now());
|
|
1147
|
+
if (!sameTransition(transitionOf(doc), transitionOf(link.doc)) || !equalBytes(again.certificate, link.certificate))
|
|
1148
|
+
throw new KavachError("EQUIVOCATION", stage, "the re-attested link is a different transition");
|
|
1149
|
+
verified[i] = { doc, wire: again.wire, certificate: link.certificate };
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (!current) {
|
|
1153
|
+
if (progress)
|
|
1154
|
+
await this.store.transaction((tx) => tx.delete("chainProgress", accountHex));
|
|
1155
|
+
return { pin: undefined, verified, accepted: [] };
|
|
1156
|
+
}
|
|
1157
|
+
const applied = await this.store.transaction((tx) => {
|
|
1158
|
+
const effects = [];
|
|
1159
|
+
let pin = current;
|
|
1160
|
+
for (const link of verified) {
|
|
1161
|
+
// A link behind the pinned key that is already on record: the pin moved past it earlier.
|
|
1162
|
+
if (link.doc.previousGeneration < pin.generation && tx.list("successions").some(([, s]) => s.accountId === accountHex && unbig(s.previousGeneration) === link.doc.previousGeneration))
|
|
1163
|
+
continue;
|
|
1164
|
+
const e = applySuccession(tx, { wire: link.wire, newCertificate: link.certificate, policy: this.policy, trust: this.trust, now: this.now() });
|
|
1165
|
+
effects.push(e);
|
|
1166
|
+
pin = e.pin;
|
|
1167
|
+
}
|
|
1168
|
+
this.persistTrustIn(tx);
|
|
1169
|
+
tx.delete("chainProgress", accountHex);
|
|
1170
|
+
return { effects, pin };
|
|
1171
|
+
});
|
|
1172
|
+
return { pin: applied.pin, verified, accepted: applied.effects };
|
|
1173
|
+
}
|
|
1174
|
+
/** The report side of an applied chain: counters and events for every accepted link. */
|
|
1175
|
+
reportAccepted(accepted, report) {
|
|
1176
|
+
for (const effect of accepted) {
|
|
1177
|
+
if (effect.outcome !== "ACCEPTED")
|
|
1178
|
+
continue;
|
|
1179
|
+
report.metadata.successions++;
|
|
1180
|
+
report.metadata.rotations++;
|
|
1181
|
+
this.emit({ kind: "accountKeyChanged", accountName: effect.accountName, quarantined: false });
|
|
1182
|
+
this.emit({ kind: "deviceChanged", identityId: effect.pin.accountId, generation: effect.pin.generation });
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
securityCodeOf(e) {
|
|
1186
|
+
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;
|
|
1187
|
+
}
|
|
1188
|
+
/** Accept a member's certificate; a changed account key is quarantined unless a succession chain or a reset receipt explains it. */
|
|
1189
|
+
async acceptMemberCertificate(resolved, report) {
|
|
1190
|
+
const wire = resolved.certificate;
|
|
1191
|
+
const resetReceipt = resolved.resetReceipt;
|
|
862
1192
|
const doc = expectType(verifyDocument(wire), DocumentType.AccountDevice, "sync");
|
|
863
1193
|
const existing = await this.store.read((tx) => tx.list("pins").find(([, p]) => p.accountName === doc.accountName)?.[1]);
|
|
864
|
-
|
|
1194
|
+
let current = existing ? rowToPin(existing) : undefined;
|
|
1195
|
+
const accountId = current?.accountId ?? resolvedAccountId(resolved);
|
|
1196
|
+
const chain = resolved.successions ?? [];
|
|
1197
|
+
// A served chain is followed whenever there is one, even when the served key equals the pinned
|
|
1198
|
+
// one: a certificate under a retired key at a higher generation must be refused as a return to
|
|
1199
|
+
// that identity, never accepted as a plain device change. Links already on record cost nothing.
|
|
1200
|
+
if (current && chain.length > 0) {
|
|
1201
|
+
try {
|
|
1202
|
+
const r = await this.followChain({ accountId, accountName: doc.accountName, current, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget });
|
|
1203
|
+
this.reportAccepted(r.accepted, report);
|
|
1204
|
+
current = r.pin;
|
|
1205
|
+
}
|
|
1206
|
+
catch (e) {
|
|
1207
|
+
report.errors.push(syncError("succession", e));
|
|
1208
|
+
// A deployment that refuses successions quarantines the key change below, as a bare
|
|
1209
|
+
// directory key change. Anything else leaves the pin where it was until the next sync.
|
|
1210
|
+
if (!isKavachError(e, "UNSUPPORTED_TYPE")) {
|
|
1211
|
+
const code = this.securityCodeOf(e);
|
|
1212
|
+
if (code)
|
|
1213
|
+
this.emit({ kind: "securityError", code, detail: doc.accountName });
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (!current)
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
let verified = [];
|
|
1221
|
+
if (!current && chain.length > 0) {
|
|
1222
|
+
// A first contact with an account that went through successions: the chain is verified from
|
|
1223
|
+
// the root to the served key before any pin is written, under the same policy gate as for an
|
|
1224
|
+
// established contact. A deployment that refuses successions pins nothing and quarantines.
|
|
1225
|
+
try {
|
|
1226
|
+
verified = (await this.followChain({ accountId, accountName: doc.accountName, finalIdentityId: pinFromCertificate(doc).identityId, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget })).verified;
|
|
1227
|
+
}
|
|
1228
|
+
catch (e) {
|
|
1229
|
+
report.errors.push(syncError("succession", e));
|
|
1230
|
+
if (isKavachError(e, "UNSUPPORTED_TYPE")) {
|
|
1231
|
+
const already = await this.store.read((tx) => tx.get("quarantine", doc.accountName));
|
|
1232
|
+
await this.store.transaction((tx) => tx.put("quarantine", doc.accountName, { accountName: doc.accountName, certificate: toHex(wire), reason: "succession refused", at: big(this.now()) }));
|
|
1233
|
+
report.metadata.quarantined++;
|
|
1234
|
+
if (!already)
|
|
1235
|
+
this.emit({ kind: "accountKeyChanged", accountName: doc.accountName, quarantined: true });
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
const code = this.securityCodeOf(e);
|
|
1239
|
+
if (code)
|
|
1240
|
+
this.emit({ kind: "securityError", code, detail: doc.accountName });
|
|
1241
|
+
}
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
865
1245
|
let outcome;
|
|
866
1246
|
try {
|
|
867
1247
|
outcome = acceptCertificate(current, wire);
|
|
@@ -881,24 +1261,33 @@ class ClientImpl {
|
|
|
881
1261
|
}
|
|
882
1262
|
if (outcome.outcome === "DUPLICATE")
|
|
883
1263
|
return;
|
|
884
|
-
const p = outcome.pin;
|
|
1264
|
+
const p = current ? outcome.pin : { ...outcome.pin, accountId };
|
|
885
1265
|
await this.store.transaction((tx) => {
|
|
886
|
-
tx.put("pins", toHex(p.
|
|
1266
|
+
tx.put("pins", toHex(p.accountId), pinToRow(p));
|
|
1267
|
+
if (verified.length) {
|
|
1268
|
+
recordChainIn(tx, verified, this.now());
|
|
1269
|
+
rootIdentityIn(tx, p.accountId, p.accountName, resolved.rootCertificate);
|
|
1270
|
+
}
|
|
1271
|
+
if (!tx.get("identities", toHex(p.identityId))) {
|
|
1272
|
+
const last = resolved.successions?.at(-1);
|
|
1273
|
+
const since = last ? expectType(parseDocument(last.wire), DocumentType.AccountSuccession, "sync").newGeneration : 1n;
|
|
1274
|
+
tx.put("identities", toHex(p.identityId), { accountId: toHex(p.accountId), accountName: p.accountName, accountKey: toHex(p.accountKey), sinceGeneration: big(since), retired: false });
|
|
1275
|
+
}
|
|
887
1276
|
if (outcome.previous) {
|
|
888
1277
|
const old = tx.get("certificates", toHex(outcome.previous.certificateHash));
|
|
889
1278
|
if (old)
|
|
890
1279
|
tx.put("certificates", toHex(outcome.previous.certificateHash), { ...old, current: false });
|
|
891
1280
|
}
|
|
892
|
-
tx.put("certificates", toHex(p.certificateHash), { identityId: toHex(p.
|
|
1281
|
+
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 });
|
|
893
1282
|
if (outcome.rotate) {
|
|
894
|
-
const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.
|
|
1283
|
+
const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.accountId, certificateHash: p.certificateHash });
|
|
895
1284
|
if (ch.changed)
|
|
896
1285
|
report.metadata.rotations++;
|
|
897
1286
|
}
|
|
898
1287
|
});
|
|
899
1288
|
report.metadata.certificates++;
|
|
900
1289
|
if (outcome.rotate)
|
|
901
|
-
this.emit({ kind: "deviceChanged", identityId: p.
|
|
1290
|
+
this.emit({ kind: "deviceChanged", identityId: p.accountId, generation: p.generation });
|
|
902
1291
|
}
|
|
903
1292
|
async onKeyChanged(current, wire, receipt, report) {
|
|
904
1293
|
if (receipt) {
|
|
@@ -934,20 +1323,26 @@ class ClientImpl {
|
|
|
934
1323
|
}
|
|
935
1324
|
/** Peers on a validated reset: stop sharing to the old identity, close its circles, rotate. */
|
|
936
1325
|
applyResetIn(tx, old, newIdentityId) {
|
|
937
|
-
|
|
1326
|
+
const accountHex = toHex(old.accountId);
|
|
1327
|
+
tx.delete("pins", accountHex);
|
|
938
1328
|
tx.delete("quarantine", old.accountName);
|
|
939
1329
|
for (const [k, c] of tx.list("certificates"))
|
|
940
|
-
if (c.identityId ===
|
|
1330
|
+
if (c.identityId === accountHex)
|
|
941
1331
|
tx.put("certificates", k, { ...c, current: false });
|
|
1332
|
+
// Every identity of the chain is retired; the root remembers the reset, so no successor of
|
|
1333
|
+
// the old account is ever a recipient again.
|
|
1334
|
+
for (const [k, r] of tx.list("identities"))
|
|
1335
|
+
if (r.accountId === accountHex)
|
|
1336
|
+
tx.put("identities", k, k === accountHex ? { ...r, retired: true, resetAt: big(this.now()) } : { ...r, retired: true });
|
|
942
1337
|
for (const [k, c] of tx.list("circles")) {
|
|
943
|
-
if (c.ownerIdentityId ===
|
|
1338
|
+
if (c.ownerIdentityId === accountHex) {
|
|
944
1339
|
t3Circle(tx, { ...c, status: "closed" }, { kind: "leave", circleId: fromHex(k) });
|
|
945
1340
|
}
|
|
946
|
-
else if (c.members.includes(
|
|
947
|
-
t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.
|
|
1341
|
+
else if (c.members.includes(accountHex)) {
|
|
1342
|
+
t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.accountId, certificateHash: newIdentityId });
|
|
948
1343
|
}
|
|
949
1344
|
}
|
|
950
|
-
forgetAuthorIn(tx, old.
|
|
1345
|
+
forgetAuthorIn(tx, old.accountId);
|
|
951
1346
|
}
|
|
952
1347
|
persistTrustIn(tx) {
|
|
953
1348
|
const rows = this.trust.toRows();
|
|
@@ -968,12 +1363,13 @@ class ClientImpl {
|
|
|
968
1363
|
const { pin } = this.requireAccount("left");
|
|
969
1364
|
const others = await this.store.read((tx) => tx.list("circles").filter(([, x]) => x.circleId !== toHex(c.circleId) && x.status === "member").flatMap(([, x]) => x.members));
|
|
970
1365
|
for (const m of c.members) {
|
|
971
|
-
if (equalBytes(m, pin.
|
|
1366
|
+
if (equalBytes(m, pin.accountId) || others.includes(toHex(m)))
|
|
972
1367
|
continue;
|
|
973
1368
|
await forgetAuthor(this.store, m);
|
|
974
1369
|
}
|
|
975
1370
|
}
|
|
976
1371
|
async retire() {
|
|
1372
|
+
this.retired = true;
|
|
977
1373
|
await this.store.transaction((tx) => {
|
|
978
1374
|
const meta = tx.get("meta", META);
|
|
979
1375
|
if (meta)
|
|
@@ -1002,9 +1398,30 @@ class ClientImpl {
|
|
|
1002
1398
|
newest(identityId, kind) {
|
|
1003
1399
|
return newestSlot(this.store, identityId, kind, this.now());
|
|
1004
1400
|
}
|
|
1401
|
+
keyChanges(options = {}) {
|
|
1402
|
+
const limit = Math.max(1, Math.min(500, Math.floor(options.limit ?? 50)));
|
|
1403
|
+
const who = options.accountId ? toHex(options.accountId) : undefined;
|
|
1404
|
+
return this.store.read((tx) => tx
|
|
1405
|
+
.list("keyChanges")
|
|
1406
|
+
.map(([, r]) => r)
|
|
1407
|
+
.filter((r) => who === undefined || r.accountId === who)
|
|
1408
|
+
// Newest first; two changes accepted in one transaction order by generation.
|
|
1409
|
+
.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))
|
|
1410
|
+
.slice(0, limit)
|
|
1411
|
+
.map((r) => ({
|
|
1412
|
+
accountId: fromHex(r.accountId),
|
|
1413
|
+
accountName: r.accountName,
|
|
1414
|
+
previousIdentityId: fromHex(r.previousIdentityId),
|
|
1415
|
+
newIdentityId: fromHex(r.newIdentityId),
|
|
1416
|
+
generation: unbig(r.generation),
|
|
1417
|
+
reason: r.reason,
|
|
1418
|
+
successionId: r.successionId === undefined ? undefined : fromHex(r.successionId),
|
|
1419
|
+
acceptedAt: unbig(r.acceptedAt)
|
|
1420
|
+
})));
|
|
1421
|
+
}
|
|
1005
1422
|
// Circles ---------------------------------------------------------------------------------------
|
|
1006
1423
|
async listCircles() {
|
|
1007
|
-
const self = this.selfPin?.
|
|
1424
|
+
const self = this.selfPin?.accountId;
|
|
1008
1425
|
const now = this.now();
|
|
1009
1426
|
return this.store.read((tx) => tx.list("circles").map(([, r]) => {
|
|
1010
1427
|
const c = rowToCircle(r);
|
|
@@ -1019,6 +1436,8 @@ class ClientImpl {
|
|
|
1019
1436
|
* the state, so a change that landed in the rows reaches the vault however the process dies.
|
|
1020
1437
|
*/
|
|
1021
1438
|
async syncVault() {
|
|
1439
|
+
if (!this.vaultEnabled)
|
|
1440
|
+
return;
|
|
1022
1441
|
const { account, pin } = this.requireAccount("vault");
|
|
1023
1442
|
const recoveryKey = await this.secrets.get("recoveryKey");
|
|
1024
1443
|
const accountSeed = await this.secrets.get("accountSeed");
|
|
@@ -1101,11 +1520,14 @@ class ClientImpl {
|
|
|
1101
1520
|
async createCircle() {
|
|
1102
1521
|
const { account, pin } = this.requireAccount("createCircle");
|
|
1103
1522
|
const circleId = newCircleId();
|
|
1104
|
-
const genesis = createCircleSnapshot({ identityId: pin.
|
|
1105
|
-
const state = acceptSnapshot(pinCircle(circleId, pin.
|
|
1523
|
+
const genesis = createCircleSnapshot({ identityId: pin.accountId, accountSecretKey: account.account.secretKey }, circleId);
|
|
1524
|
+
const state = acceptSnapshot(pinCircle(circleId, pin.accountId, true, "member"), genesis, pin.accountKey, pin.accountId, this.now());
|
|
1106
1525
|
if (state.outcome !== "ACCEPTED")
|
|
1107
1526
|
throw new KavachError("STORAGE_FAILURE", "createCircle");
|
|
1108
|
-
await this.store.transaction((tx) =>
|
|
1527
|
+
await this.store.transaction((tx) => {
|
|
1528
|
+
t3Circle(tx, { ...circleToRow(state.state), pending: { snapshot: toHex(genesis), revision: "1" } });
|
|
1529
|
+
this.recordPreferenceIn(tx, circleId, true);
|
|
1530
|
+
});
|
|
1109
1531
|
const published = await this.publishPendingSnapshot(circleId);
|
|
1110
1532
|
if (published !== "COMMITTED")
|
|
1111
1533
|
throw new KavachError("STORAGE_FAILURE", "createCircle", published);
|
|
@@ -1114,6 +1536,7 @@ class ClientImpl {
|
|
|
1114
1536
|
t9MarkVault(tx);
|
|
1115
1537
|
});
|
|
1116
1538
|
await this.syncVault();
|
|
1539
|
+
await this.pushPreferencesQuietly();
|
|
1117
1540
|
this.emit({ kind: "circleChanged", circleId, revision: 1n, status: "member" });
|
|
1118
1541
|
return { circleId };
|
|
1119
1542
|
}
|
|
@@ -1121,6 +1544,8 @@ class ClientImpl {
|
|
|
1121
1544
|
this.requireAccount("requestJoin");
|
|
1122
1545
|
await this.store.transaction((tx) => {
|
|
1123
1546
|
const existing = tx.get("circles", toHex(circleId));
|
|
1547
|
+
if (!existing || existing.status === "left")
|
|
1548
|
+
this.recordPreferenceIn(tx, circleId, shareOnApproval);
|
|
1124
1549
|
if (!existing)
|
|
1125
1550
|
t3Circle(tx, circleToRow(pinCircle(circleId, ownerIdentityId, shareOnApproval, "requested")));
|
|
1126
1551
|
else if (existing.status === "left") {
|
|
@@ -1136,18 +1561,30 @@ class ClientImpl {
|
|
|
1136
1561
|
const outcome = await this.transport.directory.requestJoin(circleId);
|
|
1137
1562
|
if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED")
|
|
1138
1563
|
throw new KavachError("STORAGE_FAILURE", "requestJoin", outcome.status);
|
|
1564
|
+
await this.pushPreferencesQuietly();
|
|
1565
|
+
}
|
|
1566
|
+
/** The host's copy of a preference, best effort now and by sync later. */
|
|
1567
|
+
async pushPreferencesQuietly() {
|
|
1568
|
+
if (!acceptsSuccession(this.policy))
|
|
1569
|
+
return;
|
|
1570
|
+
try {
|
|
1571
|
+
await this.pushPreferences();
|
|
1572
|
+
}
|
|
1573
|
+
catch {
|
|
1574
|
+
/* retried by sync */
|
|
1575
|
+
}
|
|
1139
1576
|
}
|
|
1140
1577
|
async approve(circleId, accountName) {
|
|
1141
1578
|
const [resolved] = await this.transport.directory.resolveAccounts([accountName]);
|
|
1142
1579
|
if (!resolved)
|
|
1143
1580
|
throw new KavachError("METADATA_REQUIRED", "approve", "unknown account");
|
|
1144
|
-
const report = { 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: [] };
|
|
1145
|
-
await this.acceptMemberCertificate(resolved
|
|
1146
|
-
const requester =
|
|
1147
|
-
const pinned = await this.pinFor(requester
|
|
1148
|
-
if (!pinned)
|
|
1581
|
+
const report = { 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: [] };
|
|
1582
|
+
await this.acceptMemberCertificate(resolved, report);
|
|
1583
|
+
const requester = resolvedAccountId(resolved);
|
|
1584
|
+
const pinned = await this.pinFor(requester);
|
|
1585
|
+
if (!pinned || !equalBytes(pinned.certificateHash, expectType(verifyDocument(resolved.certificate), DocumentType.AccountDevice).bodyHash))
|
|
1149
1586
|
throw new KavachError("ACCOUNT_KEY_CHANGED", "approve", "requester's key is quarantined");
|
|
1150
|
-
return this.changeMembership(circleId, { admit: requester
|
|
1587
|
+
return this.changeMembership(circleId, { admit: requester });
|
|
1151
1588
|
}
|
|
1152
1589
|
async changeMembership(circleId, change) {
|
|
1153
1590
|
const { account, pin } = this.requireAccount("changeMembership");
|
|
@@ -1163,7 +1600,7 @@ class ClientImpl {
|
|
|
1163
1600
|
const state = rowToCircle(row);
|
|
1164
1601
|
if (state.status === "closed")
|
|
1165
1602
|
return { revision: state.revision };
|
|
1166
|
-
const successor = successorSnapshot(state, change, { identityId: pin.
|
|
1603
|
+
const successor = successorSnapshot(state, change, { identityId: pin.accountId, accountSecretKey: account.account.secretKey });
|
|
1167
1604
|
if (!successor) {
|
|
1168
1605
|
if (!("close" in change))
|
|
1169
1606
|
return { revision: state.revision };
|
|
@@ -1205,7 +1642,7 @@ class ClientImpl {
|
|
|
1205
1642
|
const expectedRevision = unbig(pending.revision) - 1n;
|
|
1206
1643
|
const outcome = await this.transport.directory.publishOwnedCircle({ circleId, expectedRevision, expectedHash: expectedRevision === state.revision ? state.bodyHash : undefined, newSnapshot: successor });
|
|
1207
1644
|
if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
|
|
1208
|
-
const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.
|
|
1645
|
+
const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.accountId, this.now());
|
|
1209
1646
|
if (accepted.outcome !== "ACCEPTED" && accepted.outcome !== "DUPLICATE")
|
|
1210
1647
|
throw new KavachError("STORAGE_FAILURE", stage, "the successor was refused locally");
|
|
1211
1648
|
let next = pending.close ? { ...accepted.state, status: "closed" } : accepted.state;
|
|
@@ -1219,7 +1656,7 @@ class ClientImpl {
|
|
|
1219
1656
|
});
|
|
1220
1657
|
const after = new Set(next.members.map(toHex));
|
|
1221
1658
|
for (const m of state.members) {
|
|
1222
|
-
if (after.has(toHex(m)) || equalBytes(m, pin.
|
|
1659
|
+
if (after.has(toHex(m)) || equalBytes(m, pin.accountId))
|
|
1223
1660
|
continue;
|
|
1224
1661
|
const stillElsewhere = await this.store.read((tx) => tx.list("circles").some(([, c]) => c.circleId !== toHex(circleId) && c.status === "member" && c.members.includes(toHex(m))));
|
|
1225
1662
|
if (!stillElsewhere)
|
|
@@ -1231,7 +1668,7 @@ class ClientImpl {
|
|
|
1231
1668
|
return "COMMITTED";
|
|
1232
1669
|
}
|
|
1233
1670
|
if (outcome.status === "CONFLICT" && outcome.current) {
|
|
1234
|
-
const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.
|
|
1671
|
+
const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.accountId, this.now());
|
|
1235
1672
|
await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(accepted.outcome === "ACCEPTED" ? accepted.state : state), pending: undefined }));
|
|
1236
1673
|
return "CONFLICT";
|
|
1237
1674
|
}
|
|
@@ -1242,7 +1679,7 @@ class ClientImpl {
|
|
|
1242
1679
|
const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
|
|
1243
1680
|
if (!row)
|
|
1244
1681
|
return;
|
|
1245
|
-
if (row.ownerIdentityId === toHex(pin.
|
|
1682
|
+
if (row.ownerIdentityId === toHex(pin.accountId))
|
|
1246
1683
|
throw new KavachError("NOT_AUTHORIZED", "leave", "the owner closes instead");
|
|
1247
1684
|
const state = rowToCircle(row);
|
|
1248
1685
|
await this.store.transaction((tx) => {
|
|
@@ -1263,13 +1700,393 @@ class ClientImpl {
|
|
|
1263
1700
|
}
|
|
1264
1701
|
await this.store.transaction((tx) => {
|
|
1265
1702
|
t3Circle(tx, { ...row, enabled }, { kind: enabled ? "enable" : "disable", circleId });
|
|
1703
|
+
this.recordPreferenceIn(tx, circleId, enabled);
|
|
1266
1704
|
});
|
|
1267
1705
|
await this.syncVault();
|
|
1706
|
+
await this.pushPreferencesQuietly();
|
|
1707
|
+
}
|
|
1708
|
+
/** The user's latest action on a circle's preference, with the local sequence that a host response must name to clear it. */
|
|
1709
|
+
recordPreferenceIn(tx, circleId, enabled) {
|
|
1710
|
+
const key = toHex(circleId);
|
|
1711
|
+
const row = tx.get("preferences", key) ?? { circleId: key, enabled, revision: "0", pending: undefined, nextSequence: "1" };
|
|
1712
|
+
const sequence = row.nextSequence;
|
|
1713
|
+
tx.put("preferences", key, { ...row, enabled, pending: { enabled, sequence }, nextSequence: big(unbig(sequence) + 1n) });
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Send pending preference changes to the host (amendment A1, section 7). On CONFLICT the
|
|
1717
|
+
* device's latest persisted action wins, read from the store at retry time, and is written again
|
|
1718
|
+
* at the served revision; a response clears only the sequence it answered.
|
|
1719
|
+
*/
|
|
1720
|
+
async pushPreferences(report) {
|
|
1721
|
+
if (!acceptsSuccession(this.policy) || !this.selfPin)
|
|
1722
|
+
return;
|
|
1723
|
+
const pending = await this.store.read((tx) => tx.list("preferences").filter(([, p]) => p.pending !== undefined).map(([, p]) => p.circleId));
|
|
1724
|
+
for (const key of pending) {
|
|
1725
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1726
|
+
const row = await this.store.read((tx) => tx.get("preferences", key));
|
|
1727
|
+
if (!row?.pending)
|
|
1728
|
+
break;
|
|
1729
|
+
const sent = row.pending;
|
|
1730
|
+
const outcome = await this.transport.directory.setSharingPreference(fromHex(key), sent.enabled, unbig(row.revision));
|
|
1731
|
+
if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
|
|
1732
|
+
await this.store.transaction((tx) => {
|
|
1733
|
+
const live = tx.get("preferences", key);
|
|
1734
|
+
if (!live)
|
|
1735
|
+
return;
|
|
1736
|
+
const cleared = live.pending?.sequence === sent.sequence ? undefined : live.pending;
|
|
1737
|
+
// A late success for an older request never moves the revision back.
|
|
1738
|
+
const revision = outcome.revision > unbig(live.revision) ? big(outcome.revision) : live.revision;
|
|
1739
|
+
tx.put("preferences", key, { ...live, revision, pending: cleared });
|
|
1740
|
+
});
|
|
1741
|
+
break;
|
|
1742
|
+
}
|
|
1743
|
+
if (outcome.status === "CONFLICT") {
|
|
1744
|
+
// Adopt the served revision; the latest persisted action is re-sent against it.
|
|
1745
|
+
await this.store.transaction((tx) => {
|
|
1746
|
+
const live = tx.get("preferences", key);
|
|
1747
|
+
if (live)
|
|
1748
|
+
tx.put("preferences", key, { ...live, revision: big(outcome.revision) });
|
|
1749
|
+
});
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
if (report)
|
|
1753
|
+
report.errors.push({ operation: "preference", code: outcome.status === "UNAUTHORIZED" ? "NOT_AUTHORIZED" : "NETWORK", stage: "preference", retryable: outcome.status !== "UNAUTHORIZED" });
|
|
1754
|
+
break;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1268
1757
|
}
|
|
1269
1758
|
/** Close: the successor names only the owner; its publication forgets the members and removes the vault entry. */
|
|
1270
1759
|
async closeCircle(circleId) {
|
|
1271
1760
|
await this.changeMembership(circleId, { close: true });
|
|
1272
1761
|
}
|
|
1762
|
+
// Account recovery (amendment A1) ---------------------------------------------------------------
|
|
1763
|
+
async prepareReplacement() {
|
|
1764
|
+
const stage = "prepareReplacement";
|
|
1765
|
+
if (!acceptsSuccession(this.policy))
|
|
1766
|
+
throw new KavachError("NOT_AUTHORIZED", stage, "this deployment does not use account recovery");
|
|
1767
|
+
const opened = await openStore(this.store, this.secrets, this.accountName);
|
|
1768
|
+
if (opened.kind !== "fresh")
|
|
1769
|
+
throw new KavachError("MALFORMED", stage, "an account already exists on this device");
|
|
1770
|
+
const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
|
|
1771
|
+
const roots = await this.secrets.get("pendingReplacement");
|
|
1772
|
+
if (existing && !existing.stale && roots && roots.length === RESET_ROOTS_BYTES)
|
|
1773
|
+
return proposalOf(existing);
|
|
1774
|
+
const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
|
|
1775
|
+
if (!resolved)
|
|
1776
|
+
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
|
|
1777
|
+
const current = expectType(verifyDocument(resolved.certificate), DocumentType.AccountDevice, stage);
|
|
1778
|
+
const accountId = resolvedAccountId(resolved);
|
|
1779
|
+
// Fresh roots: nothing from the old device, nothing from the host. The certificate is at the
|
|
1780
|
+
// next generation under the new key; the vault is fresh at revision 1 so that identity
|
|
1781
|
+
// continuity works on the next replacement.
|
|
1782
|
+
const accountSeed = newAccountSeed();
|
|
1783
|
+
const device = newDevice();
|
|
1784
|
+
const recoveryKey = newRecoveryKey();
|
|
1785
|
+
const instanceId = randomBytes(16);
|
|
1786
|
+
const successionId = randomBytes(16);
|
|
1787
|
+
const account = openAccount(this.accountName, accountSeed, accountId);
|
|
1788
|
+
const generation = current.generation + 1n;
|
|
1789
|
+
const certificate = certify(account, device, generation);
|
|
1790
|
+
const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage), undefined, accountId);
|
|
1791
|
+
const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: generation, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
|
|
1792
|
+
// T11: roots in the secret store, the exact bytes in the store, before anything is sent.
|
|
1793
|
+
await this.secrets.set("pendingReplacement", concat(accountSeed, device.deviceSeed, recoveryKey, instanceId, device.deviceId));
|
|
1794
|
+
const row = {
|
|
1795
|
+
successionId: toHex(successionId),
|
|
1796
|
+
accountName: this.accountName,
|
|
1797
|
+
accountId: toHex(accountId),
|
|
1798
|
+
expectedCertificateHash: toHex(current.bodyHash),
|
|
1799
|
+
expectedGeneration: big(current.generation),
|
|
1800
|
+
certificate: toHex(certificate),
|
|
1801
|
+
vault: vault ? toHex(vault.wire) : undefined,
|
|
1802
|
+
vaultHash: vault ? toHex(vault.hash) : undefined,
|
|
1803
|
+
deviceId: toHex(device.deviceId),
|
|
1804
|
+
stale: false,
|
|
1805
|
+
conflictGeneration: undefined
|
|
1806
|
+
};
|
|
1807
|
+
await this.store.transaction((tx) => tx.put("replacement", "pending", row));
|
|
1808
|
+
await this.emitState();
|
|
1809
|
+
return proposalOf(row);
|
|
1810
|
+
}
|
|
1811
|
+
async completeReplacement(input) {
|
|
1812
|
+
const stage = "completeReplacement";
|
|
1813
|
+
// Already activated (by an earlier call, or by a sync that found the commit on the chain): idempotent.
|
|
1814
|
+
if (this.selfPin && this.account && !this.retired)
|
|
1815
|
+
return { identityId: this.selfPin.identityId, generation: this.selfPin.generation };
|
|
1816
|
+
const row = await this.store.read((tx) => tx.get("replacement", "pending"));
|
|
1817
|
+
if (!row || row.successionId !== toHex(input.successionId))
|
|
1818
|
+
throw new KavachError("MALFORMED", stage, "no such prepared replacement on this device");
|
|
1819
|
+
if (row.stale)
|
|
1820
|
+
throw new KavachError("REPLACEMENT_CONFLICT", stage, row.conflictGeneration === undefined ? "the account moved" : `the account is at generation ${row.conflictGeneration}`);
|
|
1821
|
+
const roots = await this.secrets.get("pendingReplacement");
|
|
1822
|
+
if (!roots || roots.length !== RESET_ROOTS_BYTES)
|
|
1823
|
+
throw new KavachError("RECOVERY_REQUIRED", stage, "replacement prepared without its roots");
|
|
1824
|
+
let outcome;
|
|
1825
|
+
try {
|
|
1826
|
+
outcome = await this.transport.directory.succeedAccount({
|
|
1827
|
+
accountName: this.accountName,
|
|
1828
|
+
expectedCertificateHash: fromHex(row.expectedCertificateHash),
|
|
1829
|
+
newCertificate: fromHex(row.certificate),
|
|
1830
|
+
newVault: row.vault === undefined ? undefined : fromHex(row.vault),
|
|
1831
|
+
successionId: input.successionId,
|
|
1832
|
+
authorization: input.authorization
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
catch (e) {
|
|
1836
|
+
if (!isKavachError(e, "NETWORK"))
|
|
1837
|
+
throw e;
|
|
1838
|
+
// Unknown: the chain says whether ours committed. Never a second identity.
|
|
1839
|
+
const link = await this.findOwnLink(row).catch(() => undefined);
|
|
1840
|
+
if (!link)
|
|
1841
|
+
throw new KavachError("SUCCESSION_PENDING", stage, "the commit's outcome is unknown; call again with the same proposal");
|
|
1842
|
+
outcome = { status: "COMMITTED", succession: link.wire };
|
|
1843
|
+
}
|
|
1844
|
+
if (outcome.status === "CONFLICT") {
|
|
1845
|
+
// Definitive: the current certificate moved under the proposal, and the succession id is
|
|
1846
|
+
// not on the chain (that answers first). The proposal can never commit: it is marked stale
|
|
1847
|
+
// and its keys, never activated anywhere, are discarded.
|
|
1848
|
+
let current;
|
|
1849
|
+
try {
|
|
1850
|
+
current = outcome.current ? expectType(verifyDocument(outcome.current), DocumentType.AccountDevice, stage).generation : undefined;
|
|
1851
|
+
}
|
|
1852
|
+
catch {
|
|
1853
|
+
current = undefined;
|
|
1854
|
+
}
|
|
1855
|
+
await this.store.transaction((tx) => tx.put("replacement", "pending", { ...row, stale: true, conflictGeneration: current === undefined ? undefined : big(current) }));
|
|
1856
|
+
await this.secrets.delete("pendingReplacement");
|
|
1857
|
+
await this.emitState();
|
|
1858
|
+
throw new KavachError("REPLACEMENT_CONFLICT", stage, current === undefined ? "the account moved" : `the account is at generation ${current}`);
|
|
1859
|
+
}
|
|
1860
|
+
if (outcome.status === "UNAUTHORIZED")
|
|
1861
|
+
throw new KavachError("AUTHORIZATION_REQUIRED", stage, "the host refused the authorization; obtain a fresh one for the same proposal");
|
|
1862
|
+
if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.succession) {
|
|
1863
|
+
throw new KavachError("SUCCESSION_PENDING", stage, `host answered ${outcome.status}`);
|
|
1864
|
+
}
|
|
1865
|
+
return this.activateReplacement(row, roots, outcome.succession);
|
|
1866
|
+
}
|
|
1867
|
+
/** Our own link on the host's chain, if the proposal committed: the one naming our succession id and certificate. */
|
|
1868
|
+
async findOwnLink(row) {
|
|
1869
|
+
const page = await this.transport.directory.getSuccessions(fromHex(row.accountId), unbig(row.expectedGeneration), 8);
|
|
1870
|
+
for (const l of page.successions) {
|
|
1871
|
+
try {
|
|
1872
|
+
const doc = expectType(parseDocument(l.wire), DocumentType.AccountSuccession, "resume");
|
|
1873
|
+
if (toHex(doc.successionId) === row.successionId && equalBytes(l.certificate, fromHex(row.certificate)))
|
|
1874
|
+
return l;
|
|
1875
|
+
}
|
|
1876
|
+
catch {
|
|
1877
|
+
/* not ours */
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
return undefined;
|
|
1881
|
+
}
|
|
1882
|
+
/** The reset-key list, fetched when the one held is not fresh. */
|
|
1883
|
+
async ensureResetList() {
|
|
1884
|
+
const now = this.now();
|
|
1885
|
+
if (this.trust.fresh(now))
|
|
1886
|
+
return;
|
|
1887
|
+
const wire = await this.transport.directory.getResetKeyList();
|
|
1888
|
+
if (!wire)
|
|
1889
|
+
return;
|
|
1890
|
+
this.trust.install(wire, now);
|
|
1891
|
+
await this.persistTrust();
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Activate: verify the returned succession exactly as a peer would, against the previous state
|
|
1895
|
+
* pinned from the host, then the T1 header under the new identity with the account id carried
|
|
1896
|
+
* from the proposal. A succession that does not verify leaves the proposal intact.
|
|
1897
|
+
*/
|
|
1898
|
+
async activateReplacement(row, roots, succession) {
|
|
1899
|
+
const stage = "completeReplacement";
|
|
1900
|
+
await this.ensureResetList();
|
|
1901
|
+
const previousWire = await this.transport.directory.getCertificate(fromHex(row.expectedCertificateHash));
|
|
1902
|
+
if (!previousWire)
|
|
1903
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the previous certificate is not served");
|
|
1904
|
+
const previous = expectType(verifyDocument(previousWire), DocumentType.AccountDevice, stage);
|
|
1905
|
+
if (toHex(previous.bodyHash) !== row.expectedCertificateHash)
|
|
1906
|
+
throw new KavachError("SUCCESSION_BINDING", stage, "the served certificate is not the expected one");
|
|
1907
|
+
const accountId = fromHex(row.accountId);
|
|
1908
|
+
const previousPin = pinFromCertificate(previous, undefined, accountId);
|
|
1909
|
+
// The account's earlier successions, if any: the chain from the root to the previous identity,
|
|
1910
|
+
// verified through the one chain path and kept, so this device holds every key the account
|
|
1911
|
+
// has signed with (an owned circle's snapshot from before an earlier replacement) and can
|
|
1912
|
+
// follow its own chain later (to learn of its retirement).
|
|
1913
|
+
const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
|
|
1914
|
+
// The evidence must be there before anything is written: without the account's resolution the
|
|
1915
|
+
// history is unknown, not empty, and the proposal stays for the next call. An empty history is
|
|
1916
|
+
// right only when the previous identity is the account root.
|
|
1917
|
+
if (!me)
|
|
1918
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the account's history is not served; the proposal stays");
|
|
1919
|
+
const served = me.successions ?? [];
|
|
1920
|
+
const ownSuccession = authenticateSuccession(succession, this.trust, this.now());
|
|
1921
|
+
if (served.length === 0 && me.successionsNext === undefined && !equalBytes(previousPin.identityId, accountId))
|
|
1922
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the account's earlier successions are not served; the proposal stays");
|
|
1923
|
+
// Bounded by the generation this device's own succession left from, on every page alike.
|
|
1924
|
+
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 : [];
|
|
1925
|
+
const accountSeed = roots.subarray(0, 32);
|
|
1926
|
+
const deviceSeed = roots.subarray(32, 64);
|
|
1927
|
+
const recoveryKey = roots.subarray(64, 96);
|
|
1928
|
+
const instanceId = roots.subarray(96, 112);
|
|
1929
|
+
const deviceId = roots.subarray(112, 128);
|
|
1930
|
+
const account = openAccount(this.accountName, accountSeed, accountId);
|
|
1931
|
+
const device = deviceFromSeed(deviceSeed, deviceId);
|
|
1932
|
+
const certificate = fromHex(row.certificate);
|
|
1933
|
+
const doc = expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage);
|
|
1934
|
+
const pin = pinFromCertificate(doc, undefined, accountId);
|
|
1935
|
+
// Roots first, then the store: a crash in between leaves a device the next call finishes.
|
|
1936
|
+
await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
|
|
1937
|
+
await this.store.transaction((tx) => {
|
|
1938
|
+
// The previous state as a first contact pins it, then the succession as a peer verifies it:
|
|
1939
|
+
// nothing is written unless the document binds this account, this generation and this key.
|
|
1940
|
+
tx.put("pins", toHex(accountId), pinToRow(previousPin));
|
|
1941
|
+
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 });
|
|
1942
|
+
recordChainIn(tx, history, this.now());
|
|
1943
|
+
rootIdentityIn(tx, accountId, this.accountName, me?.rootCertificate);
|
|
1944
|
+
const effect = applySuccession(tx, { wire: succession, newCertificate: certificate, policy: this.policy, trust: this.trust, now: this.now() });
|
|
1945
|
+
if (!equalBytes(effect.newIdentityId, account.identityId) || toHex(effect.accountId) !== row.accountId)
|
|
1946
|
+
throw new KavachError("SUCCESSION_BINDING", stage, "the succession does not name this device's key");
|
|
1947
|
+
this.persistTrustIn(tx);
|
|
1948
|
+
t1Header(tx, { account, device, certificate, instanceId, generation: doc.generation });
|
|
1949
|
+
if (row.vault !== undefined && row.vaultHash !== undefined)
|
|
1950
|
+
t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
|
|
1951
|
+
t3ApplyChange(tx, { kind: "ownActivation" });
|
|
1952
|
+
t1Activate(tx);
|
|
1953
|
+
const meta = tx.get("meta", META);
|
|
1954
|
+
if (meta)
|
|
1955
|
+
tx.put("meta", META, { ...meta, bootstrap: { complete: false } });
|
|
1956
|
+
tx.delete("replacement", "pending");
|
|
1957
|
+
});
|
|
1958
|
+
await this.secrets.delete("pendingReplacement");
|
|
1959
|
+
this.account = account;
|
|
1960
|
+
this.device = device;
|
|
1961
|
+
this.selfPin = pin;
|
|
1962
|
+
this.retired = false;
|
|
1963
|
+
try {
|
|
1964
|
+
await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
|
|
1965
|
+
}
|
|
1966
|
+
catch {
|
|
1967
|
+
/* reported by recoveryStatus() */
|
|
1968
|
+
}
|
|
1969
|
+
await this.emitState();
|
|
1970
|
+
return { identityId: pin.identityId, generation: pin.generation };
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Discovery after account recovery (amendment A1, section 5): the host lists the account's
|
|
1974
|
+
* circles page by page; each is pinned only when its snapshot verifies under the owner's
|
|
1975
|
+
* verified chain and names this account as the host says. Discovery is not permission to share:
|
|
1976
|
+
* the circle is enabled only if the served preference says so. Never writes membership; an
|
|
1977
|
+
* owner's successor republishes the served snapshot at the next revision.
|
|
1978
|
+
*/
|
|
1979
|
+
async bootstrap(report, state) {
|
|
1980
|
+
const out = { circles: 0, pending: 0, failed: 0 };
|
|
1981
|
+
// A circle that could not be adopted is kept with what the host served and retried by every
|
|
1982
|
+
// sync; a failure never advances past it silently, and never undoes another circle.
|
|
1983
|
+
let retry = state.retry ?? [];
|
|
1984
|
+
let cursor = state.cursor;
|
|
1985
|
+
let pagesDone = state.pagesDone === true;
|
|
1986
|
+
const adopt = async (c) => {
|
|
1987
|
+
try {
|
|
1988
|
+
const r = await this.adoptListing(c, report);
|
|
1989
|
+
if (r === "member")
|
|
1990
|
+
out.circles++;
|
|
1991
|
+
else if (r === "pending")
|
|
1992
|
+
out.pending++;
|
|
1993
|
+
return true;
|
|
1994
|
+
}
|
|
1995
|
+
catch (e) {
|
|
1996
|
+
out.failed++;
|
|
1997
|
+
report.errors.push(syncError("bootstrap", e, { circleId: c.circleId }));
|
|
1998
|
+
return false;
|
|
1999
|
+
}
|
|
2000
|
+
};
|
|
2001
|
+
const save = (done) => this.store.transaction((tx) => {
|
|
2002
|
+
const meta = tx.get("meta", META);
|
|
2003
|
+
if (!meta)
|
|
2004
|
+
return;
|
|
2005
|
+
const bootstrap = done && retry.length === 0 ? { complete: true } : { ...(cursor === undefined ? {} : { cursor }), complete: false, retry, pagesDone: done };
|
|
2006
|
+
tx.put("meta", META, { ...meta, bootstrap });
|
|
2007
|
+
t9MarkVault(tx);
|
|
2008
|
+
});
|
|
2009
|
+
const stillFailing = [];
|
|
2010
|
+
for (const r of retry) {
|
|
2011
|
+
const listing = { 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) };
|
|
2012
|
+
if (!(await adopt(listing)))
|
|
2013
|
+
stillFailing.push(r);
|
|
2014
|
+
}
|
|
2015
|
+
retry = stillFailing;
|
|
2016
|
+
for (let pages = 0; !pagesDone && pages < 64; pages++) {
|
|
2017
|
+
let page;
|
|
2018
|
+
try {
|
|
2019
|
+
page = await this.transport.directory.listCircles(cursor);
|
|
2020
|
+
}
|
|
2021
|
+
catch (e) {
|
|
2022
|
+
// What landed stays; the cursor says where to resume on the next sync.
|
|
2023
|
+
report.errors.push(syncError("bootstrap", e));
|
|
2024
|
+
await save(false);
|
|
2025
|
+
return out;
|
|
2026
|
+
}
|
|
2027
|
+
for (const c of page.circles) {
|
|
2028
|
+
if (!(await adopt(c)))
|
|
2029
|
+
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) });
|
|
2030
|
+
}
|
|
2031
|
+
cursor = page.cursor;
|
|
2032
|
+
if (cursor === undefined)
|
|
2033
|
+
pagesDone = true;
|
|
2034
|
+
await save(pagesDone);
|
|
2035
|
+
}
|
|
2036
|
+
await save(pagesDone);
|
|
2037
|
+
return out;
|
|
2038
|
+
}
|
|
2039
|
+
async adoptListing(c, report) {
|
|
2040
|
+
const stage = "bootstrap";
|
|
2041
|
+
const { pin, account } = this.requireAccount(stage);
|
|
2042
|
+
const key = toHex(c.circleId);
|
|
2043
|
+
const existing = await this.store.read((tx) => tx.get("circles", key));
|
|
2044
|
+
if (existing && (existing.revision !== "0" || existing.status === "requested"))
|
|
2045
|
+
return "skipped"; // a restart between pages
|
|
2046
|
+
if (!c.snapshot)
|
|
2047
|
+
return "skipped"; // closed: nothing to pin, nothing to reopen
|
|
2048
|
+
const ownerIsMe = equalBytes(c.ownerAccountId, pin.accountId);
|
|
2049
|
+
let ownerPin = ownerIsMe ? pin : await this.pinFor(c.ownerAccountId);
|
|
2050
|
+
if (!ownerPin) {
|
|
2051
|
+
const [r] = await this.transport.directory.resolveIdentities([c.ownerAccountId]);
|
|
2052
|
+
if (r)
|
|
2053
|
+
await this.acceptMemberCertificate(r, report);
|
|
2054
|
+
ownerPin = await this.pinFor(c.ownerAccountId);
|
|
2055
|
+
}
|
|
2056
|
+
if (!ownerPin)
|
|
2057
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the circle's owner cannot be resolved");
|
|
2058
|
+
const enabled = c.enabled === true;
|
|
2059
|
+
const base = pinCircle(c.circleId, c.ownerAccountId, enabled, c.role === "pending" ? "requested" : "member");
|
|
2060
|
+
const accepted = await this.acceptSnapshotFrom(base, c.snapshot, ownerPin, this.now());
|
|
2061
|
+
if (accepted.outcome !== "ACCEPTED")
|
|
2062
|
+
throw new KavachError("MALFORMED", stage, "an empty snapshot");
|
|
2063
|
+
let state = accepted.state;
|
|
2064
|
+
const named = c.role === "owner" ? equalBytes(state.ownerIdentityId, pin.accountId) : c.role === "member" ? state.members.some((m) => equalBytes(m, pin.accountId)) : true;
|
|
2065
|
+
if (!named)
|
|
2066
|
+
throw new KavachError("NOT_AUTHORIZED", stage, "the snapshot does not name this account as the host says");
|
|
2067
|
+
if (c.role === "pending")
|
|
2068
|
+
state = { ...state, status: "requested" };
|
|
2069
|
+
// The successor owner republishes what it was served at the next revision; the host's
|
|
2070
|
+
// compare-and-swap decides.
|
|
2071
|
+
const republish = ownerIsMe ? buildCircleSnapshot({ circleId: c.circleId, ownerIdentityId: pin.accountId, revision: state.revision + 1n, members: state.members }, account.account.secretKey) : undefined;
|
|
2072
|
+
await this.store.transaction((tx) => {
|
|
2073
|
+
const row = circleToRow(state);
|
|
2074
|
+
t3Circle(tx, republish ? { ...row, pending: { snapshot: toHex(republish), revision: big(state.revision + 1n) } } : row, { kind: "snapshot", circleId: c.circleId, revision: state.revision });
|
|
2075
|
+
if (state.status === "member")
|
|
2076
|
+
t3ApplyChange(tx, { kind: "join", circleId: c.circleId });
|
|
2077
|
+
tx.put("preferences", key, { circleId: key, enabled, revision: big(c.preferenceRevision), pending: undefined, nextSequence: "1" });
|
|
2078
|
+
});
|
|
2079
|
+
this.emit({ kind: "circleChanged", circleId: c.circleId, revision: state.revision, status: state.status });
|
|
2080
|
+
if (republish) {
|
|
2081
|
+
try {
|
|
2082
|
+
await this.publishPendingSnapshot(c.circleId);
|
|
2083
|
+
}
|
|
2084
|
+
catch (e) {
|
|
2085
|
+
report.errors.push(syncError("publishSnapshot", e));
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
return state.status === "requested" ? "pending" : "member";
|
|
2089
|
+
}
|
|
1273
2090
|
// Reset -----------------------------------------------------------------------------------------
|
|
1274
2091
|
async resetAccount(options) {
|
|
1275
2092
|
const stage = "resetAccount";
|
|
@@ -1284,13 +2101,13 @@ class ClientImpl {
|
|
|
1284
2101
|
const certificate = certify(account, device, 1n);
|
|
1285
2102
|
const newPin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
|
|
1286
2103
|
const recoveryKey = newRecoveryKey();
|
|
1287
|
-
const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey);
|
|
2104
|
+
const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey) : undefined;
|
|
1288
2105
|
const resetId = randomBytes(16);
|
|
1289
2106
|
const instanceId = randomBytes(16);
|
|
1290
2107
|
// Persisted before it is sent: the host may commit the new identity and the response be lost,
|
|
1291
2108
|
// and the only keys that can continue are these. Roots in the secret store, wires in the store.
|
|
1292
2109
|
await this.secrets.set("pendingReset", concat(accountSeed, device.deviceSeed, recoveryKey, instanceId, device.deviceId));
|
|
1293
|
-
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) }));
|
|
2110
|
+
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 }));
|
|
1294
2111
|
return this.resumeReset(stage, options.reauthProof);
|
|
1295
2112
|
}
|
|
1296
2113
|
/** Send (or re-send) the persisted reset with its own reset id, and finish locally once the host holds it. */
|
|
@@ -1301,7 +2118,7 @@ class ClientImpl {
|
|
|
1301
2118
|
throw new KavachError("RECOVERY_REQUIRED", stage, "reset pending without its roots");
|
|
1302
2119
|
const certificate = fromHex(row.certificate);
|
|
1303
2120
|
expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage);
|
|
1304
|
-
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() });
|
|
2121
|
+
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() });
|
|
1305
2122
|
if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.receipt)
|
|
1306
2123
|
throw new KavachError("NOT_AUTHORIZED", stage, `host refused reset: ${outcome.status}`);
|
|
1307
2124
|
return this.finishReset(stage, row, roots);
|
|
@@ -1323,12 +2140,13 @@ class ClientImpl {
|
|
|
1323
2140
|
const newPin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage));
|
|
1324
2141
|
await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
|
|
1325
2142
|
await this.store.transaction((tx) => {
|
|
1326
|
-
for (const table of ["meta", "self", "pins", "certificates", "quarantine", "circles", "revision", "vault", "epochs", "stream", "outbox", "history", "historyIndex", "enqueueKeys", "newest", "readerEpochs", "subscriptions", "unresolved", "activation", "pendingReset"]) {
|
|
2143
|
+
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"]) {
|
|
1327
2144
|
for (const [k] of tx.list(table))
|
|
1328
2145
|
tx.delete(table, k);
|
|
1329
2146
|
}
|
|
1330
2147
|
t1Header(tx, { account, device, certificate, instanceId, generation: 1n });
|
|
1331
|
-
|
|
2148
|
+
if (row.vault !== undefined && row.vaultHash !== undefined)
|
|
2149
|
+
t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
|
|
1332
2150
|
t1Activate(tx);
|
|
1333
2151
|
});
|
|
1334
2152
|
await this.secrets.delete("pendingReset");
|
|
@@ -1336,7 +2154,7 @@ class ClientImpl {
|
|
|
1336
2154
|
this.device = device;
|
|
1337
2155
|
this.selfPin = newPin;
|
|
1338
2156
|
try {
|
|
1339
|
-
await this.provider
|
|
2157
|
+
await this.provider?.save(this.accountName, newPin.identityId, recoveryKey);
|
|
1340
2158
|
}
|
|
1341
2159
|
catch {
|
|
1342
2160
|
/* reported by recoveryStatus() */
|