@plane31/blind-relay 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/adapters/index.d.ts +4 -3
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +2 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/audience.d.ts +1 -1
- package/dist/audience.d.ts.map +1 -1
- package/dist/audience.js +7 -6
- package/dist/audience.js.map +1 -1
- package/dist/client.d.ts +159 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1112 -102
- package/dist/client.js.map +1 -1
- package/dist/codec.d.ts +25 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +44 -0
- package/dist/codec.js.map +1 -1
- package/dist/crypto/noble.d.ts.map +1 -1
- package/dist/crypto/noble.js +19 -1
- package/dist/crypto/noble.js.map +1 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js.map +1 -1
- package/dist/errors.d.ts +9 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -1
- package/dist/errors.js.map +1 -1
- package/dist/identity.d.ts +8 -2
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +7 -3
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/policy.d.ts +13 -0
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +12 -1
- package/dist/policy.js.map +1 -1
- package/dist/rows.d.ts.map +1 -1
- package/dist/rows.js +2 -0
- package/dist/rows.js.map +1 -1
- package/dist/store.d.ts +116 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2 -0
- package/dist/store.js.map +1 -1
- package/dist/succession.d.ts +108 -0
- package/dist/succession.d.ts.map +1 -0
- package/dist/succession.js +205 -0
- package/dist/succession.js.map +1 -0
- package/dist/testing/clock.d.ts.map +1 -1
- package/dist/testing/clock.js +1 -0
- package/dist/testing/clock.js.map +1 -1
- package/dist/testing/fakeHost.d.ts +60 -1
- package/dist/testing/fakeHost.d.ts.map +1 -1
- package/dist/testing/fakeHost.js +201 -31
- package/dist/testing/fakeHost.js.map +1 -1
- package/dist/testing/faults.d.ts +7 -3
- package/dist/testing/faults.d.ts.map +1 -1
- package/dist/testing/faults.js +27 -3
- package/dist/testing/faults.js.map +1 -1
- package/dist/transactions.d.ts +6 -0
- package/dist/transactions.d.ts.map +1 -1
- package/dist/transactions.js +11 -5
- package/dist/transactions.js.map +1 -1
- package/dist/transport.d.ts +66 -2
- package/dist/transport.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/index.ts +14 -0
- package/src/audience.ts +7 -6
- package/src/client.ts +1165 -108
- package/src/codec.ts +64 -1
- package/src/crypto/noble.ts +18 -1
- package/src/crypto.ts +1 -0
- package/src/errors.ts +13 -1
- package/src/identity.ts +13 -3
- package/src/index.ts +10 -3
- package/src/policy.ts +23 -1
- package/src/rows.ts +2 -0
- package/src/store.ts +113 -3
- package/src/succession.ts +270 -0
- package/src/testing/clock.ts +1 -0
- package/src/testing/fakeHost.ts +204 -28
- package/src/testing/faults.ts +32 -7
- package/src/transactions.ts +13 -6
- package/src/transport.ts +69 -2
package/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,24 @@ 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
|
+
closed = false;
|
|
88
|
+
/** Every public operation in flight, so `close()` can wait for them. */
|
|
89
|
+
running = new Set();
|
|
90
|
+
/** The current sync's reading budget: records, and the links of a succession chain, count against it. */
|
|
91
|
+
syncBudget;
|
|
67
92
|
backupChecked = false;
|
|
68
93
|
circles;
|
|
69
94
|
constructor(options, policy) {
|
|
@@ -77,30 +102,61 @@ class ClientImpl {
|
|
|
77
102
|
this.now = options.now ?? (() => BigInt(Date.now()));
|
|
78
103
|
this.trust = new ResetTrust(options.resetTrust);
|
|
79
104
|
this.circles = {
|
|
80
|
-
create: () => this.createCircle(),
|
|
81
|
-
requestJoin: (c, o, opts) => this.requestJoin(c, o, opts?.shareOnApproval ?? true),
|
|
82
|
-
approve: (c, n) => this.approve(c, n),
|
|
83
|
-
remove: (c, m) => this.changeMembership(c, { remove: m }),
|
|
84
|
-
leave: (c) => this.leave(c),
|
|
85
|
-
setEnabled: (c, e) => this.setEnabled(c, e),
|
|
86
|
-
closeCircle: (c) => this.closeCircle(c),
|
|
105
|
+
create: () => this.track("createCircle", () => this.createCircle()),
|
|
106
|
+
requestJoin: (c, o, opts) => this.track("requestJoin", () => this.requestJoin(c, o, opts?.shareOnApproval ?? true)),
|
|
107
|
+
approve: (c, n) => this.track("approve", () => this.approve(c, n)),
|
|
108
|
+
remove: (c, m) => this.track("changeMembership", () => this.changeMembership(c, { remove: m })),
|
|
109
|
+
leave: (c) => this.track("leave", () => this.leave(c)),
|
|
110
|
+
setEnabled: (c, e) => this.track("setEnabled", () => this.setEnabled(c, e)),
|
|
111
|
+
closeCircle: (c) => this.track("closeCircle", () => this.closeCircle(c)),
|
|
87
112
|
list: () => this.listCircles()
|
|
88
113
|
};
|
|
89
114
|
}
|
|
115
|
+
// Every public operation that writes enters through `track`, explicitly and typed, so `close()`
|
|
116
|
+
// refuses new ones and waits for those in flight. Reads stay untracked.
|
|
117
|
+
initializeAccount() {
|
|
118
|
+
return this.track("initializeAccount", () => this.initializeAccountNow());
|
|
119
|
+
}
|
|
120
|
+
restoreAccount(recoveryCode) {
|
|
121
|
+
return this.track("restoreAccount", () => this.restoreAccountNow(recoveryCode));
|
|
122
|
+
}
|
|
123
|
+
enqueue(kind, payload, options = {}) {
|
|
124
|
+
return this.track("enqueue", () => this.enqueueNow(kind, payload, options));
|
|
125
|
+
}
|
|
126
|
+
receive(wire) {
|
|
127
|
+
return this.track("receive", () => receiveRecord(this.inboxDeps(), wire));
|
|
128
|
+
}
|
|
129
|
+
prepareReplacement() {
|
|
130
|
+
return this.track("prepareReplacement", () => this.prepareReplacementNow());
|
|
131
|
+
}
|
|
132
|
+
completeReplacement(input) {
|
|
133
|
+
return this.track("completeReplacement", () => this.completeReplacementNow(input));
|
|
134
|
+
}
|
|
135
|
+
resetAccount(options) {
|
|
136
|
+
return this.track("resetAccount", () => this.resetAccountNow(options));
|
|
137
|
+
}
|
|
138
|
+
replaceDevice(options) {
|
|
139
|
+
return this.track("replaceDevice", () => this.replaceDeviceNow(options));
|
|
140
|
+
}
|
|
141
|
+
syncUntilDrained(options = {}) {
|
|
142
|
+
return this.track("syncUntilDrained", () => this.drainNow(options));
|
|
143
|
+
}
|
|
90
144
|
// Loading ---------------------------------------------------------------------------------------
|
|
91
145
|
async load() {
|
|
92
146
|
await this.finishInterruptedReset();
|
|
93
147
|
const opened = await openStore(this.store, this.secrets, this.accountName);
|
|
94
148
|
if (opened.kind === "fresh")
|
|
95
149
|
return;
|
|
150
|
+
this.retired = opened.kind === "RETIRED";
|
|
96
151
|
await ensureHistoryIndex(this.store);
|
|
97
152
|
await ensureSlots(this.store, this.policy);
|
|
98
153
|
this.account = opened.account;
|
|
99
154
|
const deviceSeed = await this.secrets.get("deviceSeed");
|
|
100
155
|
const self = await this.store.read((tx) => tx.get("self", "self"));
|
|
101
|
-
if (deviceSeed && self) {
|
|
156
|
+
if (deviceSeed && self && opened.account) {
|
|
157
|
+
const accountId = opened.account.accountId;
|
|
102
158
|
this.device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
|
|
103
|
-
const pinRow = await this.store.read((tx) => tx.get("pins", toHex(
|
|
159
|
+
const pinRow = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
|
|
104
160
|
if (pinRow)
|
|
105
161
|
this.selfPin = rowToPin(pinRow);
|
|
106
162
|
}
|
|
@@ -119,8 +175,19 @@ class ClientImpl {
|
|
|
119
175
|
retired: rows.retired
|
|
120
176
|
});
|
|
121
177
|
}
|
|
122
|
-
/**
|
|
178
|
+
/** Refuse after `close()`, and keep the operation's promise until it settles. */
|
|
179
|
+
track(stage, fn) {
|
|
180
|
+
if (this.closed)
|
|
181
|
+
return Promise.reject(new KavachError("CLOSED", stage, "the client is closed"));
|
|
182
|
+
const p = fn();
|
|
183
|
+
this.running.add(p);
|
|
184
|
+
void p.finally(() => this.running.delete(p)).catch(() => undefined);
|
|
185
|
+
return p;
|
|
186
|
+
}
|
|
187
|
+
/** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. Nothing after `close()`. */
|
|
123
188
|
emit(event) {
|
|
189
|
+
if (this.closed)
|
|
190
|
+
return;
|
|
124
191
|
for (const l of this.listeners) {
|
|
125
192
|
try {
|
|
126
193
|
l(event);
|
|
@@ -140,13 +207,15 @@ class ClientImpl {
|
|
|
140
207
|
return () => this.listeners.delete(listener);
|
|
141
208
|
}
|
|
142
209
|
requireAccount(stage) {
|
|
210
|
+
if (this.retired)
|
|
211
|
+
throw new KavachError("SUPERSEDED_DEVICE", stage, "this device is retired");
|
|
143
212
|
if (!this.account || !this.device || !this.selfPin)
|
|
144
213
|
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no account on this device");
|
|
145
214
|
return { account: this.account, device: this.device, pin: this.selfPin };
|
|
146
215
|
}
|
|
147
216
|
selfView() {
|
|
148
217
|
const { device, pin } = this.requireAccount("self");
|
|
149
|
-
return { identityId: pin.
|
|
218
|
+
return { identityId: pin.accountId, certificateHash: pin.certificateHash, box: device.box };
|
|
150
219
|
}
|
|
151
220
|
outboxDeps() {
|
|
152
221
|
const { device, pin } = this.requireAccount("outbox");
|
|
@@ -155,7 +224,7 @@ class ClientImpl {
|
|
|
155
224
|
relay: this.transport.relay,
|
|
156
225
|
policy: this.policy,
|
|
157
226
|
now: this.now,
|
|
158
|
-
author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.
|
|
227
|
+
author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.accountId }),
|
|
159
228
|
events: {
|
|
160
229
|
queued: (recordId, recordKind) => this.emit({ kind: "queued", recordId, recordKind }),
|
|
161
230
|
accepted: (recordId) => this.emit({ kind: "accepted", recordId }),
|
|
@@ -185,9 +254,11 @@ class ClientImpl {
|
|
|
185
254
|
// State -----------------------------------------------------------------------------------------
|
|
186
255
|
async getState() {
|
|
187
256
|
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
|
-
|
|
257
|
+
const base = { identityId: this.selfPin?.accountId, keyIdentityId: this.selfPin?.identityId, certificateHash: this.selfPin?.certificateHash, generation: this.selfPin?.generation, outbox: 0, waitingFor: [], circles: [] };
|
|
258
|
+
if (!meta || (!this.account && meta.state !== "RETIRED")) {
|
|
259
|
+
const proposal = await this.store.read((tx) => tx.get("replacement", "pending"));
|
|
260
|
+
return { status: proposal && !proposal.stale ? "replacementPrepared" : "fresh", paused: undefined, ...base };
|
|
261
|
+
}
|
|
191
262
|
const circles = await this.listCircles();
|
|
192
263
|
const outbox = await outboxCount(this.store);
|
|
193
264
|
const waitingFor = await this.waitingFor();
|
|
@@ -209,7 +280,7 @@ class ClientImpl {
|
|
|
209
280
|
async waitingFor() {
|
|
210
281
|
if (!this.selfPin)
|
|
211
282
|
return [];
|
|
212
|
-
const selfHex = toHex(this.selfPin.
|
|
283
|
+
const selfHex = toHex(this.selfPin.accountId);
|
|
213
284
|
return this.store.read((tx) => {
|
|
214
285
|
const authors = new Set();
|
|
215
286
|
for (const [, c] of tx.list("circles"))
|
|
@@ -235,9 +306,15 @@ class ClientImpl {
|
|
|
235
306
|
const stream = tx.get("stream", STREAM);
|
|
236
307
|
const revision = tx.get("revision", REVISION);
|
|
237
308
|
const vault = tx.get("vault", VAULT);
|
|
309
|
+
const meta = tx.get("meta", META);
|
|
310
|
+
const proposal = tx.get("replacement", "pending");
|
|
238
311
|
const pendingEpoch = tx.list("epochs").find(([, e]) => e.status === "prepared" || e.status === "committed")?.[1].epoch;
|
|
239
312
|
return {
|
|
240
|
-
identityId: this.selfPin?.
|
|
313
|
+
identityId: this.selfPin?.accountId,
|
|
314
|
+
keyIdentityId: this.selfPin?.identityId,
|
|
315
|
+
replacementModel: this.policy.replacement,
|
|
316
|
+
replacement: proposal ? { successionId: fromHex(proposal.successionId), expectedGeneration: unbig(proposal.expectedGeneration), stale: proposal.stale, conflictGeneration: proposal.conflictGeneration === undefined ? undefined : unbig(proposal.conflictGeneration) } : undefined,
|
|
317
|
+
bootstrap: meta?.bootstrap ? { cursor: meta.bootstrap.cursor, complete: meta.bootstrap.complete } : undefined,
|
|
241
318
|
certificateHash: this.selfPin?.certificateHash,
|
|
242
319
|
generation: this.selfPin?.generation,
|
|
243
320
|
desiredRevision: revision ? unbig(revision.desiredRevision) : 1n,
|
|
@@ -250,7 +327,7 @@ class ClientImpl {
|
|
|
250
327
|
});
|
|
251
328
|
}
|
|
252
329
|
// Enrollment ------------------------------------------------------------------------------------
|
|
253
|
-
async
|
|
330
|
+
async initializeAccountNow() {
|
|
254
331
|
const stage = "initializeAccount";
|
|
255
332
|
const opened = await openStore(this.store, this.secrets, this.accountName);
|
|
256
333
|
if (opened.kind !== "fresh" && opened.kind !== "initializing")
|
|
@@ -272,8 +349,8 @@ class ClientImpl {
|
|
|
272
349
|
const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
|
|
273
350
|
const iid = instanceId;
|
|
274
351
|
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
|
|
352
|
+
const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
|
|
353
|
+
const outcome = await this.transport.directory.registerAccount({ certificate, vault: vault?.wire });
|
|
277
354
|
if (outcome.status === "CONFLICT") {
|
|
278
355
|
// A restart or a lost response after the host committed: the name is ours if the host holds
|
|
279
356
|
// exactly the certificate this device saved; anything else is somebody else's account.
|
|
@@ -285,7 +362,8 @@ class ClientImpl {
|
|
|
285
362
|
throw new KavachError("NOT_AUTHORIZED", stage, `host refused registration: ${outcome.status}`);
|
|
286
363
|
}
|
|
287
364
|
await this.store.transaction((tx) => {
|
|
288
|
-
|
|
365
|
+
if (vault)
|
|
366
|
+
t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
|
|
289
367
|
t1Activate(tx);
|
|
290
368
|
});
|
|
291
369
|
this.account = account;
|
|
@@ -293,7 +371,7 @@ class ClientImpl {
|
|
|
293
371
|
this.selfPin = pin;
|
|
294
372
|
// The recovery key goes to the provider; enrollment never blocks on it or on the code.
|
|
295
373
|
try {
|
|
296
|
-
await this.provider
|
|
374
|
+
await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
|
|
297
375
|
}
|
|
298
376
|
catch {
|
|
299
377
|
/* status is reported by recoveryStatus() */
|
|
@@ -302,15 +380,21 @@ class ClientImpl {
|
|
|
302
380
|
return { identityId: pin.identityId };
|
|
303
381
|
}
|
|
304
382
|
async recoveryStatus() {
|
|
383
|
+
if (!this.provider)
|
|
384
|
+
return { status: "unsupported", backupState: "unavailable", reason: "no recovery provider on a recovery-only profile" };
|
|
305
385
|
return this.provider.status();
|
|
306
386
|
}
|
|
307
387
|
async recoveryCode() {
|
|
388
|
+
// A code opens a vault for identity continuity; where the profile disables that, or where no
|
|
389
|
+
// vault exists, the code would open nothing and is not issued.
|
|
390
|
+
if (!allowsContinuity(this.policy) || !this.vaultEnabled)
|
|
391
|
+
throw new KavachError("NOT_AUTHORIZED", "recoveryCode", "this deployment does not restore accounts by recovery code");
|
|
308
392
|
const key = await this.secrets.get("recoveryKey");
|
|
309
393
|
if (!key)
|
|
310
394
|
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", "recoveryCode");
|
|
311
395
|
return encodeRecoveryCode(key);
|
|
312
396
|
}
|
|
313
|
-
async
|
|
397
|
+
async restoreAccountNow(recoveryCode) {
|
|
314
398
|
const stage = "restoreAccount";
|
|
315
399
|
const opened = await openStore(this.store, this.secrets, this.accountName);
|
|
316
400
|
if (opened.kind === "ACTIVE" || opened.kind === "RETIRED")
|
|
@@ -324,6 +408,8 @@ class ClientImpl {
|
|
|
324
408
|
// moved under us): prepare again with the same device keys. Nothing was allocated remotely.
|
|
325
409
|
pending = resumed;
|
|
326
410
|
}
|
|
411
|
+
if (!allowsContinuity(this.policy))
|
|
412
|
+
throw new KavachError("NOT_AUTHORIZED", stage, "this deployment replaces devices through successions, not the recovery key");
|
|
327
413
|
const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
|
|
328
414
|
if (!resolved)
|
|
329
415
|
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
|
|
@@ -331,7 +417,13 @@ class ClientImpl {
|
|
|
331
417
|
const vaultWire = await this.transport.directory.getVault(this.accountName);
|
|
332
418
|
if (!vaultWire)
|
|
333
419
|
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no vault");
|
|
420
|
+
const accountId = resolvedAccountId(resolved);
|
|
334
421
|
const identity = pinFromCertificate(current).identityId;
|
|
422
|
+
if (!this.provider)
|
|
423
|
+
throw new KavachError("NOT_AUTHORIZED", stage, "identity continuity needs a recovery provider");
|
|
424
|
+
// An account that succeeded: the served chain is verified link by link under the current list,
|
|
425
|
+
// from the root to the key this device is about to hold, before anything is written.
|
|
426
|
+
const verified = resolved.successions?.length ? (await this.followChain({ accountId, accountName: this.accountName, finalIdentityId: identity, firstPage: resolved.successions, next: resolved.successionsNext })).verified : [];
|
|
335
427
|
const recovered = await recoverAccount({
|
|
336
428
|
accountName: this.accountName,
|
|
337
429
|
identityId: identity,
|
|
@@ -340,12 +432,12 @@ class ClientImpl {
|
|
|
340
432
|
provider: this.provider,
|
|
341
433
|
...(recoveryCode === undefined ? {} : { recoveryCode })
|
|
342
434
|
});
|
|
343
|
-
const account = openAccount(this.accountName, recovered.vault.accountSeed);
|
|
435
|
+
const account = openAccount(this.accountName, recovered.vault.accountSeed, accountId);
|
|
344
436
|
// Fresh device keys and a fresh local store. Nothing from the old device is copied.
|
|
345
437
|
const device = pending?.device ?? newDevice();
|
|
346
438
|
const generation = current.generation + 1n;
|
|
347
439
|
const certificate = certify(account, device, generation);
|
|
348
|
-
const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
|
|
440
|
+
const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice), undefined, accountId);
|
|
349
441
|
const header = parseVaultHeader(vaultWire);
|
|
350
442
|
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
443
|
const instanceId = pending?.instanceId ?? randomBytes(16);
|
|
@@ -353,6 +445,8 @@ class ClientImpl {
|
|
|
353
445
|
await t1Secrets(this.secrets, { accountSeed: recovered.vault.accountSeed, deviceSeed: device.deviceSeed, recoveryKey: recovered.recoveryKey, instanceId });
|
|
354
446
|
await this.store.transaction((tx) => {
|
|
355
447
|
t1Header(tx, { account, device, certificate, instanceId, generation });
|
|
448
|
+
recordChainIn(tx, verified, this.now());
|
|
449
|
+
rootIdentityIn(tx, accountId, this.accountName, resolved.rootCertificate);
|
|
356
450
|
t2PrepareActivation(tx, {
|
|
357
451
|
certificate: toHex(certificate),
|
|
358
452
|
vault: toHex(nextVault.wire),
|
|
@@ -387,7 +481,7 @@ class ClientImpl {
|
|
|
387
481
|
tx.delete("activation", "pending");
|
|
388
482
|
t9Vault(tx, { revision: big(input.vaultRevision), hash: toHex(input.vault.hash), wire: toHex(input.vault.wire), pending: false, dirty: false });
|
|
389
483
|
for (const e of input.entries) {
|
|
390
|
-
const owned = equalBytes(e.ownerIdentityId, pin.
|
|
484
|
+
const owned = equalBytes(e.ownerIdentityId, pin.accountId);
|
|
391
485
|
const c = pinCircle(e.circleId, e.ownerIdentityId, e.sharingEnabled, owned ? "member" : "requested");
|
|
392
486
|
tx.put("circles", toHex(e.circleId), circleToRow(c));
|
|
393
487
|
}
|
|
@@ -398,7 +492,7 @@ class ClientImpl {
|
|
|
398
492
|
this.device = device;
|
|
399
493
|
this.selfPin = pin;
|
|
400
494
|
try {
|
|
401
|
-
await this.provider
|
|
495
|
+
await this.provider?.save(this.accountName, pin.identityId, input.recoveryKey);
|
|
402
496
|
}
|
|
403
497
|
catch {
|
|
404
498
|
/* reported by recoveryStatus() */
|
|
@@ -418,14 +512,15 @@ class ClientImpl {
|
|
|
418
512
|
if (!row)
|
|
419
513
|
throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its prepared bytes");
|
|
420
514
|
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)
|
|
515
|
+
const [self, meta] = await this.store.read((tx) => [tx.get("self", "self"), tx.get("meta", META)]);
|
|
516
|
+
if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId || !self || !meta)
|
|
423
517
|
throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its roots");
|
|
424
|
-
const
|
|
518
|
+
const accountId = fromHex(meta.accountId ?? meta.identityId);
|
|
519
|
+
const account = openAccount(this.accountName, accountSeed, accountId);
|
|
425
520
|
const device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
|
|
426
521
|
const certificate = fromHex(row.certificate);
|
|
427
522
|
const prepared = expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage);
|
|
428
|
-
const pin = pinFromCertificate(prepared);
|
|
523
|
+
const pin = pinFromCertificate(prepared, undefined, accountId);
|
|
429
524
|
const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
|
|
430
525
|
if (!me)
|
|
431
526
|
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
|
|
@@ -457,7 +552,7 @@ class ClientImpl {
|
|
|
457
552
|
* asks the provider and saves again when it has nothing for this identity.
|
|
458
553
|
*/
|
|
459
554
|
async ensureBackup() {
|
|
460
|
-
if (this.backupChecked || !this.selfPin)
|
|
555
|
+
if (this.backupChecked || !this.selfPin || !this.provider)
|
|
461
556
|
return;
|
|
462
557
|
this.backupChecked = true;
|
|
463
558
|
try {
|
|
@@ -476,7 +571,7 @@ class ClientImpl {
|
|
|
476
571
|
this.emit({ kind: "stateChanged", state: await this.getState() });
|
|
477
572
|
}
|
|
478
573
|
// Sending ---------------------------------------------------------------------------------------
|
|
479
|
-
async
|
|
574
|
+
async enqueueNow(kind, payload, options) {
|
|
480
575
|
const deps = this.outboxDeps();
|
|
481
576
|
// A key answered before anything that may need the network: the record it names is already
|
|
482
577
|
// durable, so a retry offline, or with a rotation due, returns it. The outbox checks the key
|
|
@@ -503,7 +598,7 @@ class ClientImpl {
|
|
|
503
598
|
/** After commit: the device's own record is stored like any author's, so one subscription covers self and others. */
|
|
504
599
|
enqueued(kind, r) {
|
|
505
600
|
if (!r.duplicate && this.selfPin)
|
|
506
|
-
this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.
|
|
601
|
+
this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.accountId, recordId: r.recordId, tookSlot: r.own });
|
|
507
602
|
return { recordId: r.recordId, duplicate: r.duplicate };
|
|
508
603
|
}
|
|
509
604
|
// Epochs ----------------------------------------------------------------------------------------
|
|
@@ -511,7 +606,15 @@ class ClientImpl {
|
|
|
511
606
|
const { pin } = this.requireAccount("audience");
|
|
512
607
|
const retired = new Set(this.trust.state.retired);
|
|
513
608
|
return this.store.read((tx) => {
|
|
609
|
+
// A reset retires the whole account, however many successions it went through.
|
|
610
|
+
for (const [, r] of tx.list("identities"))
|
|
611
|
+
if (r.resetAt !== undefined)
|
|
612
|
+
retired.add(r.accountId);
|
|
514
613
|
const pins = new Map(tx.list("pins").map(([k, p]) => [k, rowToPin(p)]));
|
|
614
|
+
// An account whose chain this device has not finished verifying is out of every audience until it has.
|
|
615
|
+
for (const [, p] of tx.list("chainProgress"))
|
|
616
|
+
if (!p.complete)
|
|
617
|
+
pins.delete(p.accountId);
|
|
515
618
|
const circles = tx.list("circles").map(([, c]) => rowToCircle(c));
|
|
516
619
|
return resolveAudience(pin, circles, pins, retired);
|
|
517
620
|
});
|
|
@@ -552,7 +655,7 @@ class ClientImpl {
|
|
|
552
655
|
else {
|
|
553
656
|
const revision = revisionRow ? unbig(revisionRow.desiredRevision) : 1n;
|
|
554
657
|
const fresh = prepareEpoch({
|
|
555
|
-
author: { identityId: pin.
|
|
658
|
+
author: { identityId: pin.accountId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
|
|
556
659
|
epoch: stream.highestCommittedEpoch + 1,
|
|
557
660
|
createdAt: this.now(),
|
|
558
661
|
audience,
|
|
@@ -598,20 +701,137 @@ class ClientImpl {
|
|
|
598
701
|
async sync(options = {}) {
|
|
599
702
|
if (this.syncing)
|
|
600
703
|
return this.syncing;
|
|
601
|
-
|
|
704
|
+
if (this.closed)
|
|
705
|
+
throw new KavachError("CLOSED", "sync", "the client is closed");
|
|
706
|
+
this.syncing = this.track("sync", () => this.runSync(options)).finally(() => {
|
|
602
707
|
this.syncing = undefined;
|
|
603
708
|
});
|
|
604
709
|
return this.syncing;
|
|
605
710
|
}
|
|
711
|
+
async drainNow(options) {
|
|
712
|
+
const maxRounds = Math.max(1, Math.floor(options.maxRounds ?? 10));
|
|
713
|
+
const maxMs = Math.max(0, Math.floor(options.maxMs ?? 30_000));
|
|
714
|
+
const deadline = this.now() + BigInt(maxMs);
|
|
715
|
+
const merged = {
|
|
716
|
+
metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
|
|
717
|
+
epoch: "unchanged",
|
|
718
|
+
outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
|
|
719
|
+
inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
|
|
720
|
+
purged: 0,
|
|
721
|
+
errors: []
|
|
722
|
+
};
|
|
723
|
+
let rounds = 0;
|
|
724
|
+
let previousErrors;
|
|
725
|
+
let stop = "drained";
|
|
726
|
+
let last;
|
|
727
|
+
// An inbox this device could not fully read this round: discovery or a stream failed, or metadata it
|
|
728
|
+
// depends on did. Such a round never establishes that the inbox is empty.
|
|
729
|
+
const inboxUncertain = (r) => r.errors.some((e) => e.operation === "listSubscriptions" || e.operation === "inbox" || e.operation === "metadata");
|
|
730
|
+
// Chains verified only partly: remaining work, and their cursors moving is progress.
|
|
731
|
+
const chains = () => this.store.read((tx) => new Map(tx.list("chainProgress").filter(([, c]) => !c.complete).map(([k, c]) => [k, c.nextGeneration])));
|
|
732
|
+
let previousChains = await chains();
|
|
733
|
+
for (;;) {
|
|
734
|
+
if (this.closed) {
|
|
735
|
+
stop = "closed";
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
// The time left is this round's reading budget too, so a round never starts work the deadline
|
|
739
|
+
// does not cover; a transport call already in flight ends on its own timeout.
|
|
740
|
+
const remainingMs = Number(deadline - this.now());
|
|
741
|
+
if (rounds > 0 && remainingMs <= 0) {
|
|
742
|
+
stop = "maxMs";
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
const roundMs = Math.max(0, Math.min(remainingMs, options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs));
|
|
746
|
+
const r = await this.sync({ catchUp: { maxMs: roundMs, ...(options.catchUp?.maxRecords !== undefined ? { maxRecords: options.catchUp.maxRecords } : {}) } });
|
|
747
|
+
rounds++;
|
|
748
|
+
last = r;
|
|
749
|
+
// The round's work counts whether or not it was the last: what it accepted, stored and failed
|
|
750
|
+
// is merged first, and only then is a close that landed meanwhile reported.
|
|
751
|
+
for (const k of Object.keys(merged.metadata))
|
|
752
|
+
merged.metadata[k] += r.metadata[k];
|
|
753
|
+
for (const k of Object.keys(merged.outbox))
|
|
754
|
+
merged.outbox[k] += r.outbox[k];
|
|
755
|
+
merged.inbox.streams += r.inbox.streams;
|
|
756
|
+
merged.inbox.stored += r.inbox.stored;
|
|
757
|
+
merged.inbox.keyPending += r.inbox.keyPending;
|
|
758
|
+
merged.inbox.invalid += r.inbox.invalid;
|
|
759
|
+
merged.inbox.acked += r.inbox.acked;
|
|
760
|
+
merged.purged += r.purged;
|
|
761
|
+
merged.epoch = r.epoch;
|
|
762
|
+
merged.errors.push(...r.errors);
|
|
763
|
+
if (r.bootstrap)
|
|
764
|
+
merged.bootstrap = r.bootstrap;
|
|
765
|
+
if (this.closed) {
|
|
766
|
+
// The round may have stopped at a boundary before its inbox: nothing it reports is complete.
|
|
767
|
+
stop = "closed";
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
const bootstrapIncomplete = (await this.inspect()).bootstrap?.complete === false;
|
|
771
|
+
const currentChains = await chains();
|
|
772
|
+
const more = r.inbox.hasMore || bootstrapIncomplete || currentChains.size > 0;
|
|
773
|
+
const status = (await this.getState()).status;
|
|
774
|
+
if (status === "replacementPrepared" || status === "accountRecoveryRequired") {
|
|
775
|
+
stop = "authentication";
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
if (!more) {
|
|
779
|
+
stop = inboxUncertain(r) ? "incomplete" : "drained";
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
const signature = r.errors.map((e) => `${e.operation}:${e.code}`).sort().join(",");
|
|
783
|
+
const chainsAdvanced = [...currentChains].some(([k, next]) => previousChains.get(k) !== next) || [...previousChains.keys()].some((k) => !currentChains.has(k));
|
|
784
|
+
previousChains = currentChains;
|
|
785
|
+
const changed = chainsAdvanced || r.inbox.stored + r.metadata.certificates + r.metadata.snapshots + r.metadata.successions + (r.bootstrap?.circles ?? 0) + (r.bootstrap?.pending ?? 0) > 0;
|
|
786
|
+
if (!changed && signature !== "" && signature === previousErrors) {
|
|
787
|
+
stop = r.errors.some((e) => e.code === "METADATA_REQUIRED" || e.code === "RESET_KEYS_REQUIRED" || e.code === "RESET_CLOCK_UNTRUSTED") ? "metadata" : "noProgress";
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
previousErrors = signature;
|
|
791
|
+
if (rounds >= maxRounds) {
|
|
792
|
+
stop = "maxRounds";
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
if (this.now() >= deadline) {
|
|
796
|
+
stop = "maxMs";
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
merged.inbox.hasMore = last?.inbox.hasMore ?? false;
|
|
801
|
+
const bootstrapIncomplete = this.closed ? false : (await this.inspect()).bootstrap?.complete === false;
|
|
802
|
+
const chainsIncomplete = this.closed ? previousChains.size : (await chains()).size;
|
|
803
|
+
const waitingFor = this.closed ? 0 : (await this.getState()).waitingFor.length;
|
|
804
|
+
const inboxHasMore = !last || inboxUncertain(last) || stop === "closed" ? "unknown" : merged.inbox.hasMore;
|
|
805
|
+
return { ...merged, rounds, stoppedBecause: stop, remaining: { inboxHasMore, bootstrapIncomplete, chainsIncomplete, waitingFor } };
|
|
806
|
+
}
|
|
606
807
|
async runSync(options) {
|
|
607
808
|
const report = {
|
|
608
|
-
metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0 },
|
|
809
|
+
metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
|
|
609
810
|
epoch: "unchanged",
|
|
610
811
|
outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
|
|
611
812
|
inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
|
|
612
813
|
purged: 0,
|
|
613
814
|
errors: []
|
|
614
815
|
};
|
|
816
|
+
this.syncBudget = {
|
|
817
|
+
remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
|
|
818
|
+
deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
|
|
819
|
+
};
|
|
820
|
+
if (!this.selfPin) {
|
|
821
|
+
// A prepared replacement whose commit this device never saw: the chain says whether it landed.
|
|
822
|
+
const row = await this.store.read((tx) => tx.get("replacement", "pending"));
|
|
823
|
+
if (row && !row.stale) {
|
|
824
|
+
try {
|
|
825
|
+
const link = await this.findOwnLink(row);
|
|
826
|
+
const roots = await this.secrets.get("pendingReplacement");
|
|
827
|
+
if (link && roots && roots.length === RESET_ROOTS_BYTES)
|
|
828
|
+
await this.activateReplacement(row, roots, link.wire);
|
|
829
|
+
}
|
|
830
|
+
catch (e) {
|
|
831
|
+
report.errors.push(syncError("resumeReplacement", e));
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
615
835
|
if (!this.account || !this.selfPin)
|
|
616
836
|
return report;
|
|
617
837
|
const meta = await this.store.read((tx) => tx.get("meta", META));
|
|
@@ -643,14 +863,28 @@ class ClientImpl {
|
|
|
643
863
|
else if (meta.state !== "ACTIVE")
|
|
644
864
|
return report;
|
|
645
865
|
await this.ensureBackup();
|
|
866
|
+
if (meta.bootstrap && !meta.bootstrap.complete) {
|
|
867
|
+
try {
|
|
868
|
+
report.bootstrap = await this.bootstrap(report, meta.bootstrap);
|
|
869
|
+
}
|
|
870
|
+
catch (e) {
|
|
871
|
+
report.errors.push(syncError("bootstrap", e));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
646
874
|
try {
|
|
647
875
|
await this.syncMetadata(report);
|
|
648
876
|
}
|
|
649
877
|
catch (e) {
|
|
650
878
|
report.errors.push(syncError("metadata", e));
|
|
651
879
|
}
|
|
880
|
+
try {
|
|
881
|
+
await this.pushPreferences(report);
|
|
882
|
+
}
|
|
883
|
+
catch (e) {
|
|
884
|
+
report.errors.push(syncError("preference", e));
|
|
885
|
+
}
|
|
652
886
|
const after = await this.store.read((tx) => tx.get("meta", META));
|
|
653
|
-
if (after?.state === "RETIRED")
|
|
887
|
+
if (after?.state === "RETIRED" || this.closed)
|
|
654
888
|
return report;
|
|
655
889
|
try {
|
|
656
890
|
report.epoch = await this.ensureEpoch();
|
|
@@ -658,12 +892,16 @@ class ClientImpl {
|
|
|
658
892
|
catch (e) {
|
|
659
893
|
report.errors.push(syncError("epoch", e));
|
|
660
894
|
}
|
|
895
|
+
if (this.closed)
|
|
896
|
+
return report;
|
|
661
897
|
try {
|
|
662
898
|
report.outbox = await drain(this.outboxDeps());
|
|
663
899
|
}
|
|
664
900
|
catch (e) {
|
|
665
901
|
report.errors.push(syncError("outbox", e));
|
|
666
902
|
}
|
|
903
|
+
if (this.closed)
|
|
904
|
+
return report;
|
|
667
905
|
let subs = [];
|
|
668
906
|
try {
|
|
669
907
|
subs = await this.transport.relay.listSubscriptions();
|
|
@@ -674,11 +912,12 @@ class ClientImpl {
|
|
|
674
912
|
// Each stream on its own: a failed download or a refused record names its stream in the report
|
|
675
913
|
// and leaves the other streams to be read; a stream that failed is not counted as synced.
|
|
676
914
|
// One budget spans the streams; a stream it stops early reports hasMore.
|
|
677
|
-
const budget =
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
};
|
|
915
|
+
const budget = this.syncBudget;
|
|
916
|
+
if (budget.remainingRecords < 1)
|
|
917
|
+
budget.remainingRecords = 1; // every stream reads at least one page
|
|
681
918
|
for (const s of subs) {
|
|
919
|
+
if (this.closed)
|
|
920
|
+
return report;
|
|
682
921
|
try {
|
|
683
922
|
const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
|
|
684
923
|
if (!known)
|
|
@@ -708,7 +947,16 @@ class ClientImpl {
|
|
|
708
947
|
if (me) {
|
|
709
948
|
const theirs = expectType(verifyDocument(me.certificate), DocumentType.AccountDevice, "sync");
|
|
710
949
|
if (!equalBytes(theirs.accountKey, pin.accountKey)) {
|
|
711
|
-
// Our own key changed at the host
|
|
950
|
+
// Our own key changed at the host. A succession from our identity, attested by an authority
|
|
951
|
+
// we trust, retires this device (its key is no longer the account's); a bare key change or a
|
|
952
|
+
// reset we did not make is ignored here, as before.
|
|
953
|
+
if (await this.supersededBySuccession(pin, me, report)) {
|
|
954
|
+
await this.retire();
|
|
955
|
+
// The key itself is retired, not only the device: nothing of it stays here.
|
|
956
|
+
await this.secrets.delete("accountSeed");
|
|
957
|
+
this.account = undefined;
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
712
960
|
}
|
|
713
961
|
else if (!equalBytes(theirs.bodyHash, pin.certificateHash) && theirs.generation > pin.generation) {
|
|
714
962
|
await this.retire();
|
|
@@ -733,7 +981,7 @@ class ClientImpl {
|
|
|
733
981
|
// Unconfirmed writes of our own first: a successor snapshot the host never took, a vault update
|
|
734
982
|
// it never saw. Until they land, peers read stale membership and a replacement phone a stale
|
|
735
983
|
// preference.
|
|
736
|
-
const pendingOwned = await this.store.read((tx) => tx.list("circles").filter(([, r]) => r.pending && r.ownerIdentityId === toHex(pin.
|
|
984
|
+
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
985
|
for (const circleId of pendingOwned) {
|
|
738
986
|
try {
|
|
739
987
|
if ((await this.publishPendingSnapshot(circleId)) === "COMMITTED")
|
|
@@ -755,18 +1003,18 @@ class ClientImpl {
|
|
|
755
1003
|
for (const c of circles) {
|
|
756
1004
|
if (c.status === "closed" || c.status === "quarantined" || c.status === "left")
|
|
757
1005
|
continue;
|
|
758
|
-
if (!equalBytes(c.ownerIdentityId, pin.
|
|
1006
|
+
if (!equalBytes(c.ownerIdentityId, pin.accountId) && !(await this.pinFor(c.ownerIdentityId)))
|
|
759
1007
|
unknownOwners.push(c.ownerIdentityId);
|
|
760
1008
|
}
|
|
761
1009
|
if (unknownOwners.length > 0) {
|
|
762
1010
|
for (const r of await this.transport.directory.resolveIdentities(unknownOwners))
|
|
763
|
-
await this.acceptMemberCertificate(r
|
|
1011
|
+
await this.acceptMemberCertificate(r, report);
|
|
764
1012
|
}
|
|
765
1013
|
const memberIds = new Map();
|
|
766
1014
|
for (const c of circles) {
|
|
767
1015
|
if (c.status === "closed" || c.status === "quarantined" || c.status === "left")
|
|
768
1016
|
continue;
|
|
769
|
-
const ownerPin = equalBytes(c.ownerIdentityId, pin.
|
|
1017
|
+
const ownerPin = equalBytes(c.ownerIdentityId, pin.accountId) ? pin : await this.pinFor(c.ownerIdentityId);
|
|
770
1018
|
if (!ownerPin) {
|
|
771
1019
|
memberIds.set(toHex(c.ownerIdentityId), c.ownerIdentityId);
|
|
772
1020
|
continue;
|
|
@@ -774,7 +1022,7 @@ class ClientImpl {
|
|
|
774
1022
|
const wire = await this.transport.directory.getCircle(c.circleId);
|
|
775
1023
|
if (wire) {
|
|
776
1024
|
try {
|
|
777
|
-
const r =
|
|
1025
|
+
const r = await this.acceptSnapshotFrom(c, wire, ownerPin, now, report);
|
|
778
1026
|
if (r.outcome === "ACCEPTED") {
|
|
779
1027
|
// The row may have moved since it was read: a sharing preference, a leave or a close
|
|
780
1028
|
// made meanwhile wins over the fetched snapshot, and a newer snapshot already stored
|
|
@@ -821,7 +1069,7 @@ class ClientImpl {
|
|
|
821
1069
|
else
|
|
822
1070
|
for (const m of c.members)
|
|
823
1071
|
memberIds.set(toHex(m), m);
|
|
824
|
-
if (equalBytes(c.ownerIdentityId, pin.
|
|
1072
|
+
if (equalBytes(c.ownerIdentityId, pin.accountId) && c.status === "member") {
|
|
825
1073
|
const requests = await this.transport.directory.readRequests(c.circleId);
|
|
826
1074
|
const joins = requests.filter((r) => r.action === "join").map((r) => r.accountName);
|
|
827
1075
|
if (joins.length)
|
|
@@ -830,9 +1078,8 @@ class ClientImpl {
|
|
|
830
1078
|
const [acct] = await this.transport.directory.resolveAccounts([r.accountName]);
|
|
831
1079
|
if (!acct)
|
|
832
1080
|
continue;
|
|
833
|
-
const leaver = pinFromCertificate(expectType(verifyDocument(acct.certificate), DocumentType.AccountDevice));
|
|
834
1081
|
try {
|
|
835
|
-
await this.changeMembership(c.circleId, { remove:
|
|
1082
|
+
await this.changeMembership(c.circleId, { remove: resolvedAccountId(acct) });
|
|
836
1083
|
}
|
|
837
1084
|
catch (e) {
|
|
838
1085
|
report.errors.push(syncError("removeLeaver", e));
|
|
@@ -840,28 +1087,308 @@ class ClientImpl {
|
|
|
840
1087
|
}
|
|
841
1088
|
}
|
|
842
1089
|
}
|
|
843
|
-
memberIds.delete(toHex(pin.
|
|
1090
|
+
memberIds.delete(toHex(pin.accountId));
|
|
844
1091
|
if (memberIds.size > 0) {
|
|
845
1092
|
const resolved = await this.transport.directory.resolveIdentities([...memberIds.values()]);
|
|
846
1093
|
const byIdentity = new Map();
|
|
847
1094
|
for (const r of resolved) {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
await this.acceptMemberCertificate(r.certificate, r.resetReceipt, report);
|
|
1095
|
+
expectType(verifyDocument(r.certificate), DocumentType.AccountDevice, "sync");
|
|
1096
|
+
byIdentity.set(toHex(resolvedAccountId(r)), { accountName: r.accountName, accountKey: r.accountKey });
|
|
1097
|
+
await this.acceptMemberCertificate(r, report);
|
|
852
1098
|
}
|
|
853
1099
|
checkMemberResolution(byIdentity);
|
|
854
1100
|
}
|
|
855
1101
|
}
|
|
856
|
-
|
|
857
|
-
|
|
1102
|
+
/**
|
|
1103
|
+
* Has the host's chain for our own account moved past our key? A valid succession from our
|
|
1104
|
+
* current identity means an authority replaced this account's key; the device holding the old
|
|
1105
|
+
* key has nothing left to say. Deferred (no fresh list, untrusted clock) and invalid
|
|
1106
|
+
* attestations leave the device as it is.
|
|
1107
|
+
*/
|
|
1108
|
+
async supersededBySuccession(pin, me, report) {
|
|
1109
|
+
if (!me.successions?.length)
|
|
1110
|
+
return false;
|
|
1111
|
+
try {
|
|
1112
|
+
const r = await this.followChain({ accountId: pin.accountId, accountName: pin.accountName, current: pin, firstPage: me.successions, next: me.successionsNext, budget: this.syncBudget });
|
|
1113
|
+
return r.pin !== undefined && !equalBytes(r.pin.identityId, pin.identityId);
|
|
1114
|
+
}
|
|
1115
|
+
catch (e) {
|
|
1116
|
+
// A chain this device cannot follow for its own account is a diagnostic the product must
|
|
1117
|
+
// see: the host has already fenced the device, and only this report says why it still runs.
|
|
1118
|
+
report.errors.push(syncError("ownSuccession", e));
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Verify a snapshot under the owner's current key, or, when the owner's key changed through a
|
|
1124
|
+
* succession and the snapshot predates it, under the retired key it was signed with. The
|
|
1125
|
+
* successor republishes at the next revision; until then the older snapshot stays valid.
|
|
1126
|
+
*/
|
|
1127
|
+
async acceptSnapshotFrom(c, wire, ownerPin, now, report, resolved = false) {
|
|
1128
|
+
const { pin } = this.requireAccount("sync");
|
|
1129
|
+
// The very bytes this device already verified (a bootstrap snapshot under a key since retired)
|
|
1130
|
+
// are a duplicate whatever key signed them; only new bytes are verified.
|
|
1131
|
+
if (c.snapshot && equalBytes(c.snapshot, wire))
|
|
1132
|
+
return { outcome: "DUPLICATE", state: c };
|
|
1133
|
+
try {
|
|
1134
|
+
return acceptSnapshot(c, wire, ownerPin.accountKey, pin.accountId, now);
|
|
1135
|
+
}
|
|
1136
|
+
catch (e) {
|
|
1137
|
+
if (!isKavachError(e, "BAD_SIGNATURE"))
|
|
1138
|
+
throw e;
|
|
1139
|
+
// A retired key may only vouch for a circle this device holds at revision 0 (bootstrap, first
|
|
1140
|
+
// contact): an established circle advances under the current owner key alone.
|
|
1141
|
+
const ownerHex = toHex(ownerPin.accountId);
|
|
1142
|
+
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)] : []))) : [];
|
|
1143
|
+
for (const key of retiredKeys) {
|
|
1144
|
+
try {
|
|
1145
|
+
return acceptSnapshot(c, wire, key, pin.accountId, now);
|
|
1146
|
+
}
|
|
1147
|
+
catch (inner) {
|
|
1148
|
+
if (!isKavachError(inner, "BAD_SIGNATURE"))
|
|
1149
|
+
throw inner;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
// Signed by a key we do not know: the owner may have succeeded since we last resolved it.
|
|
1153
|
+
// The chain is verified first; only a key it produced may sign this circle.
|
|
1154
|
+
if (!resolved && report && !equalBytes(ownerPin.accountId, pin.accountId)) {
|
|
1155
|
+
const [r] = await this.transport.directory.resolveIdentities([ownerPin.accountId]);
|
|
1156
|
+
if (r && !equalBytes(r.accountKey, ownerPin.accountKey)) {
|
|
1157
|
+
await this.acceptMemberCertificate(r, report);
|
|
1158
|
+
const fresh = await this.pinFor(ownerPin.accountId);
|
|
1159
|
+
if (fresh && !equalBytes(fresh.accountKey, ownerPin.accountKey))
|
|
1160
|
+
return this.acceptSnapshotFrom(c, wire, fresh, now, report, true);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
throw e;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
async pinFor(accountId) {
|
|
1167
|
+
const row = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
|
|
858
1168
|
return row ? rowToPin(row) : undefined;
|
|
859
1169
|
}
|
|
860
|
-
/**
|
|
861
|
-
|
|
1170
|
+
/**
|
|
1171
|
+
* The one path every succession takes (section 4, "Chains"): the policy gate first, then the
|
|
1172
|
+
* key list, then each link verified in order from the start identity (the pinned identity for
|
|
1173
|
+
* an established contact, the account id at first contact or for a restoring device), page by
|
|
1174
|
+
* page within the sync's budget, with the verified prefix persisted so that the next sync
|
|
1175
|
+
* resumes instead of starting over. Only when the chain is complete does anything move: for a
|
|
1176
|
+
* pinned contact every link is applied in one transaction, so no epoch is ever sealed to an
|
|
1177
|
+
* intermediate successor; for a first contact or a restore the verified links are returned and
|
|
1178
|
+
* the caller records them with the pin or the header. Any failure leaves the pin where it was.
|
|
1179
|
+
*/
|
|
1180
|
+
async followChain(input) {
|
|
1181
|
+
const stage = "chain";
|
|
1182
|
+
if (!acceptsSuccession(this.policy))
|
|
1183
|
+
throw new KavachError("UNSUPPORTED_TYPE", stage, "this deployment does not accept successions");
|
|
1184
|
+
const accountHex = toHex(input.accountId);
|
|
1185
|
+
const startIdentity = input.current?.identityId ?? input.accountId;
|
|
1186
|
+
await this.ensureResetList();
|
|
1187
|
+
// The state the chain is validated against: the pin with what this store retired, or the root.
|
|
1188
|
+
const current = input.current;
|
|
1189
|
+
let state = current ? await this.store.read((tx) => pinnedState(tx, current, this.trust)) : rootState(input.accountId, input.accountName, this.trust);
|
|
1190
|
+
// Reconcile first: the host serves the chain from the root, and a pinned contact already holds
|
|
1191
|
+
// a prefix of it. Links whose transition is on record are dropped (the same transition at a
|
|
1192
|
+
// recorded state; a different one is the authority signing two successors), so verification
|
|
1193
|
+
// starts at the pinned identity with only the transitions this device has not seen.
|
|
1194
|
+
const recorded = current ? await this.store.read((tx) => tx.list("successions", accountHex + ":").map(([, r]) => r)) : [];
|
|
1195
|
+
// The bound, applied to every page alike: links past `untilGeneration` are not this call's to
|
|
1196
|
+
// follow (a replacing device stops at the state its own succession left from).
|
|
1197
|
+
const within = (l) => input.untilGeneration === undefined || expectType(parseDocument(l.wire), DocumentType.AccountSuccession, stage).newGeneration <= input.untilGeneration;
|
|
1198
|
+
const unseen = (links) => links.filter(within).filter((l) => {
|
|
1199
|
+
const doc = expectType(parseDocument(l.wire), DocumentType.AccountSuccession, stage);
|
|
1200
|
+
const t = transitionOf(doc);
|
|
1201
|
+
const row = recorded.find((r) => unbig(r.previousGeneration) === t.previousGeneration && r.previousIdentityId === t.previousIdentityId);
|
|
1202
|
+
if (!row)
|
|
1203
|
+
return true;
|
|
1204
|
+
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)) {
|
|
1205
|
+
// A different transition at a recorded state is evidence against the authority only if
|
|
1206
|
+
// the authority signed it: unauthenticated bytes are a bad signature, nothing more.
|
|
1207
|
+
authenticateSuccession(l.wire, this.trust, this.now());
|
|
1208
|
+
throw new KavachError("EQUIVOCATION", stage, "two successors for one account state");
|
|
1209
|
+
}
|
|
1210
|
+
return false;
|
|
1211
|
+
});
|
|
1212
|
+
const progress = await this.store.read((tx) => tx.get("chainProgress", accountHex));
|
|
1213
|
+
const verified = [];
|
|
1214
|
+
let cursor;
|
|
1215
|
+
let pending;
|
|
1216
|
+
// Only links this device has not recorded are verified, and only they count against the budget.
|
|
1217
|
+
const verifyMore = (links) => {
|
|
1218
|
+
const fresh = unseen(links);
|
|
1219
|
+
const r = verifyChain(fresh, { state, trust: this.trust, now: this.now() });
|
|
1220
|
+
verified.push(...r.links);
|
|
1221
|
+
state = r.state;
|
|
1222
|
+
if (input.budget)
|
|
1223
|
+
input.budget.remainingRecords -= fresh.length;
|
|
1224
|
+
};
|
|
1225
|
+
if (progress && progress.startIdentityId === toHex(startIdentity)) {
|
|
1226
|
+
// Resume at the saved cursor, however long the verified prefix is. The prefix's transitions
|
|
1227
|
+
// were verified when their attestations were allowed, so they are replayed through the
|
|
1228
|
+
// transition rules alone; their bytes are authenticated again below, and refreshed from the
|
|
1229
|
+
// host where a key rotation since made them stale.
|
|
1230
|
+
for (const l of progress.links) {
|
|
1231
|
+
const doc = expectType(parseDocument(fromHex(l.wire)), DocumentType.AccountSuccession, stage);
|
|
1232
|
+
state = validateTransition(doc, fromHex(l.certificate), state).next;
|
|
1233
|
+
verified.push({ doc, wire: fromHex(l.wire), certificate: fromHex(l.certificate) });
|
|
1234
|
+
}
|
|
1235
|
+
cursor = unbig(progress.nextGeneration);
|
|
1236
|
+
pending = [];
|
|
1237
|
+
}
|
|
1238
|
+
else {
|
|
1239
|
+
pending = input.firstPage;
|
|
1240
|
+
cursor = input.next;
|
|
1241
|
+
}
|
|
1242
|
+
const persist = () => this.store.transaction((tx) => {
|
|
1243
|
+
if (cursor === undefined)
|
|
1244
|
+
tx.delete("chainProgress", accountHex);
|
|
1245
|
+
else
|
|
1246
|
+
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 });
|
|
1247
|
+
});
|
|
1248
|
+
const reached = () => input.untilGeneration !== undefined && state.generation >= input.untilGeneration;
|
|
1249
|
+
verifyMore(pending);
|
|
1250
|
+
if (reached())
|
|
1251
|
+
cursor = undefined;
|
|
1252
|
+
while (cursor !== undefined) {
|
|
1253
|
+
if (input.budget && (input.budget.remainingRecords <= 0 || this.now() >= input.budget.deadline)) {
|
|
1254
|
+
await persist();
|
|
1255
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the chain continues past this sync's budget");
|
|
1256
|
+
}
|
|
1257
|
+
let page;
|
|
1258
|
+
try {
|
|
1259
|
+
page = await this.transport.directory.getSuccessions(input.accountId, cursor, 32);
|
|
1260
|
+
}
|
|
1261
|
+
catch (e) {
|
|
1262
|
+
await persist();
|
|
1263
|
+
throw e;
|
|
1264
|
+
}
|
|
1265
|
+
if (page.successions.length === 0) {
|
|
1266
|
+
await persist();
|
|
1267
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the host did not finish serving the chain");
|
|
1268
|
+
}
|
|
1269
|
+
verifyMore(page.successions);
|
|
1270
|
+
cursor = reached() ? undefined : page.nextGeneration;
|
|
1271
|
+
if (cursor !== undefined)
|
|
1272
|
+
await persist();
|
|
1273
|
+
}
|
|
1274
|
+
if (input.finalIdentityId && state.identityId !== toHex(input.finalIdentityId))
|
|
1275
|
+
throw new KavachError("SUCCESSION_BINDING", stage, "the chain does not end at the served key");
|
|
1276
|
+
// Every attestation under the current list before anything is recorded. A link whose signer
|
|
1277
|
+
// left the list since it was verified is fetched again: the host re-attests a legitimate
|
|
1278
|
+
// transition under a current key, and the transition must be the same one.
|
|
1279
|
+
for (let i = 0; i < verified.length; i++) {
|
|
1280
|
+
const link = verified[i];
|
|
1281
|
+
if (!link)
|
|
1282
|
+
continue;
|
|
1283
|
+
try {
|
|
1284
|
+
authenticateSuccession(link.wire, this.trust, this.now());
|
|
1285
|
+
}
|
|
1286
|
+
catch (e) {
|
|
1287
|
+
if (!isKavachError(e, "RESET_KEY_REVOKED"))
|
|
1288
|
+
throw e;
|
|
1289
|
+
const page = await this.transport.directory.getSuccessions(input.accountId, link.doc.newGeneration - 1n, 1);
|
|
1290
|
+
const again = page.successions[0];
|
|
1291
|
+
if (!again)
|
|
1292
|
+
throw e;
|
|
1293
|
+
const doc = authenticateSuccession(again.wire, this.trust, this.now());
|
|
1294
|
+
if (!sameTransition(transitionOf(doc), transitionOf(link.doc)) || !equalBytes(again.certificate, link.certificate))
|
|
1295
|
+
throw new KavachError("EQUIVOCATION", stage, "the re-attested link is a different transition");
|
|
1296
|
+
verified[i] = { doc, wire: again.wire, certificate: link.certificate };
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (!current) {
|
|
1300
|
+
if (progress)
|
|
1301
|
+
await this.store.transaction((tx) => tx.delete("chainProgress", accountHex));
|
|
1302
|
+
return { pin: undefined, verified, accepted: [] };
|
|
1303
|
+
}
|
|
1304
|
+
const applied = await this.store.transaction((tx) => {
|
|
1305
|
+
const effects = [];
|
|
1306
|
+
let pin = current;
|
|
1307
|
+
for (const link of verified) {
|
|
1308
|
+
// A link behind the pinned key that is already on record: the pin moved past it earlier.
|
|
1309
|
+
if (link.doc.previousGeneration < pin.generation && tx.list("successions").some(([, s]) => s.accountId === accountHex && unbig(s.previousGeneration) === link.doc.previousGeneration))
|
|
1310
|
+
continue;
|
|
1311
|
+
const e = applySuccession(tx, { wire: link.wire, newCertificate: link.certificate, policy: this.policy, trust: this.trust, now: this.now() });
|
|
1312
|
+
effects.push(e);
|
|
1313
|
+
pin = e.pin;
|
|
1314
|
+
}
|
|
1315
|
+
this.persistTrustIn(tx);
|
|
1316
|
+
tx.delete("chainProgress", accountHex);
|
|
1317
|
+
return { effects, pin };
|
|
1318
|
+
});
|
|
1319
|
+
return { pin: applied.pin, verified, accepted: applied.effects };
|
|
1320
|
+
}
|
|
1321
|
+
/** The report side of an applied chain: counters and events for every accepted link. */
|
|
1322
|
+
reportAccepted(accepted, report) {
|
|
1323
|
+
for (const effect of accepted) {
|
|
1324
|
+
if (effect.outcome !== "ACCEPTED")
|
|
1325
|
+
continue;
|
|
1326
|
+
report.metadata.successions++;
|
|
1327
|
+
report.metadata.rotations++;
|
|
1328
|
+
this.emit({ kind: "accountKeyChanged", accountName: effect.accountName, quarantined: false });
|
|
1329
|
+
this.emit({ kind: "deviceChanged", identityId: effect.pin.accountId, generation: effect.pin.generation });
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
securityCodeOf(e) {
|
|
1333
|
+
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;
|
|
1334
|
+
}
|
|
1335
|
+
/** Accept a member's certificate; a changed account key is quarantined unless a succession chain or a reset receipt explains it. */
|
|
1336
|
+
async acceptMemberCertificate(resolved, report) {
|
|
1337
|
+
const wire = resolved.certificate;
|
|
1338
|
+
const resetReceipt = resolved.resetReceipt;
|
|
862
1339
|
const doc = expectType(verifyDocument(wire), DocumentType.AccountDevice, "sync");
|
|
863
1340
|
const existing = await this.store.read((tx) => tx.list("pins").find(([, p]) => p.accountName === doc.accountName)?.[1]);
|
|
864
|
-
|
|
1341
|
+
let current = existing ? rowToPin(existing) : undefined;
|
|
1342
|
+
const accountId = current?.accountId ?? resolvedAccountId(resolved);
|
|
1343
|
+
const chain = resolved.successions ?? [];
|
|
1344
|
+
// A served chain is followed whenever there is one, even when the served key equals the pinned
|
|
1345
|
+
// one: a certificate under a retired key at a higher generation must be refused as a return to
|
|
1346
|
+
// that identity, never accepted as a plain device change. Links already on record cost nothing.
|
|
1347
|
+
if (current && chain.length > 0) {
|
|
1348
|
+
try {
|
|
1349
|
+
const r = await this.followChain({ accountId, accountName: doc.accountName, current, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget });
|
|
1350
|
+
this.reportAccepted(r.accepted, report);
|
|
1351
|
+
current = r.pin;
|
|
1352
|
+
}
|
|
1353
|
+
catch (e) {
|
|
1354
|
+
report.errors.push(syncError("succession", e));
|
|
1355
|
+
// A deployment that refuses successions quarantines the key change below, as a bare
|
|
1356
|
+
// directory key change. Anything else leaves the pin where it was until the next sync.
|
|
1357
|
+
if (!isKavachError(e, "UNSUPPORTED_TYPE")) {
|
|
1358
|
+
const code = this.securityCodeOf(e);
|
|
1359
|
+
if (code)
|
|
1360
|
+
this.emit({ kind: "securityError", code, detail: doc.accountName });
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
if (!current)
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
let verified = [];
|
|
1368
|
+
if (!current && chain.length > 0) {
|
|
1369
|
+
// A first contact with an account that went through successions: the chain is verified from
|
|
1370
|
+
// the root to the served key before any pin is written, under the same policy gate as for an
|
|
1371
|
+
// established contact. A deployment that refuses successions pins nothing and quarantines.
|
|
1372
|
+
try {
|
|
1373
|
+
verified = (await this.followChain({ accountId, accountName: doc.accountName, finalIdentityId: pinFromCertificate(doc).identityId, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget })).verified;
|
|
1374
|
+
}
|
|
1375
|
+
catch (e) {
|
|
1376
|
+
report.errors.push(syncError("succession", e));
|
|
1377
|
+
if (isKavachError(e, "UNSUPPORTED_TYPE")) {
|
|
1378
|
+
const already = await this.store.read((tx) => tx.get("quarantine", doc.accountName));
|
|
1379
|
+
await this.store.transaction((tx) => tx.put("quarantine", doc.accountName, { accountName: doc.accountName, certificate: toHex(wire), reason: "succession refused", at: big(this.now()) }));
|
|
1380
|
+
report.metadata.quarantined++;
|
|
1381
|
+
if (!already)
|
|
1382
|
+
this.emit({ kind: "accountKeyChanged", accountName: doc.accountName, quarantined: true });
|
|
1383
|
+
}
|
|
1384
|
+
else {
|
|
1385
|
+
const code = this.securityCodeOf(e);
|
|
1386
|
+
if (code)
|
|
1387
|
+
this.emit({ kind: "securityError", code, detail: doc.accountName });
|
|
1388
|
+
}
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
865
1392
|
let outcome;
|
|
866
1393
|
try {
|
|
867
1394
|
outcome = acceptCertificate(current, wire);
|
|
@@ -881,24 +1408,33 @@ class ClientImpl {
|
|
|
881
1408
|
}
|
|
882
1409
|
if (outcome.outcome === "DUPLICATE")
|
|
883
1410
|
return;
|
|
884
|
-
const p = outcome.pin;
|
|
1411
|
+
const p = current ? outcome.pin : { ...outcome.pin, accountId };
|
|
885
1412
|
await this.store.transaction((tx) => {
|
|
886
|
-
tx.put("pins", toHex(p.
|
|
1413
|
+
tx.put("pins", toHex(p.accountId), pinToRow(p));
|
|
1414
|
+
if (verified.length) {
|
|
1415
|
+
recordChainIn(tx, verified, this.now());
|
|
1416
|
+
rootIdentityIn(tx, p.accountId, p.accountName, resolved.rootCertificate);
|
|
1417
|
+
}
|
|
1418
|
+
if (!tx.get("identities", toHex(p.identityId))) {
|
|
1419
|
+
const last = resolved.successions?.at(-1);
|
|
1420
|
+
const since = last ? expectType(parseDocument(last.wire), DocumentType.AccountSuccession, "sync").newGeneration : 1n;
|
|
1421
|
+
tx.put("identities", toHex(p.identityId), { accountId: toHex(p.accountId), accountName: p.accountName, accountKey: toHex(p.accountKey), sinceGeneration: big(since), retired: false });
|
|
1422
|
+
}
|
|
887
1423
|
if (outcome.previous) {
|
|
888
1424
|
const old = tx.get("certificates", toHex(outcome.previous.certificateHash));
|
|
889
1425
|
if (old)
|
|
890
1426
|
tx.put("certificates", toHex(outcome.previous.certificateHash), { ...old, current: false });
|
|
891
1427
|
}
|
|
892
|
-
tx.put("certificates", toHex(p.certificateHash), { identityId: toHex(p.
|
|
1428
|
+
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
1429
|
if (outcome.rotate) {
|
|
894
|
-
const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.
|
|
1430
|
+
const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.accountId, certificateHash: p.certificateHash });
|
|
895
1431
|
if (ch.changed)
|
|
896
1432
|
report.metadata.rotations++;
|
|
897
1433
|
}
|
|
898
1434
|
});
|
|
899
1435
|
report.metadata.certificates++;
|
|
900
1436
|
if (outcome.rotate)
|
|
901
|
-
this.emit({ kind: "deviceChanged", identityId: p.
|
|
1437
|
+
this.emit({ kind: "deviceChanged", identityId: p.accountId, generation: p.generation });
|
|
902
1438
|
}
|
|
903
1439
|
async onKeyChanged(current, wire, receipt, report) {
|
|
904
1440
|
if (receipt) {
|
|
@@ -934,20 +1470,26 @@ class ClientImpl {
|
|
|
934
1470
|
}
|
|
935
1471
|
/** Peers on a validated reset: stop sharing to the old identity, close its circles, rotate. */
|
|
936
1472
|
applyResetIn(tx, old, newIdentityId) {
|
|
937
|
-
|
|
1473
|
+
const accountHex = toHex(old.accountId);
|
|
1474
|
+
tx.delete("pins", accountHex);
|
|
938
1475
|
tx.delete("quarantine", old.accountName);
|
|
939
1476
|
for (const [k, c] of tx.list("certificates"))
|
|
940
|
-
if (c.identityId ===
|
|
1477
|
+
if (c.identityId === accountHex)
|
|
941
1478
|
tx.put("certificates", k, { ...c, current: false });
|
|
1479
|
+
// Every identity of the chain is retired; the root remembers the reset, so no successor of
|
|
1480
|
+
// the old account is ever a recipient again.
|
|
1481
|
+
for (const [k, r] of tx.list("identities"))
|
|
1482
|
+
if (r.accountId === accountHex)
|
|
1483
|
+
tx.put("identities", k, k === accountHex ? { ...r, retired: true, resetAt: big(this.now()) } : { ...r, retired: true });
|
|
942
1484
|
for (const [k, c] of tx.list("circles")) {
|
|
943
|
-
if (c.ownerIdentityId ===
|
|
1485
|
+
if (c.ownerIdentityId === accountHex) {
|
|
944
1486
|
t3Circle(tx, { ...c, status: "closed" }, { kind: "leave", circleId: fromHex(k) });
|
|
945
1487
|
}
|
|
946
|
-
else if (c.members.includes(
|
|
947
|
-
t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.
|
|
1488
|
+
else if (c.members.includes(accountHex)) {
|
|
1489
|
+
t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.accountId, certificateHash: newIdentityId });
|
|
948
1490
|
}
|
|
949
1491
|
}
|
|
950
|
-
forgetAuthorIn(tx, old.
|
|
1492
|
+
forgetAuthorIn(tx, old.accountId);
|
|
951
1493
|
}
|
|
952
1494
|
persistTrustIn(tx) {
|
|
953
1495
|
const rows = this.trust.toRows();
|
|
@@ -968,12 +1510,13 @@ class ClientImpl {
|
|
|
968
1510
|
const { pin } = this.requireAccount("left");
|
|
969
1511
|
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
1512
|
for (const m of c.members) {
|
|
971
|
-
if (equalBytes(m, pin.
|
|
1513
|
+
if (equalBytes(m, pin.accountId) || others.includes(toHex(m)))
|
|
972
1514
|
continue;
|
|
973
1515
|
await forgetAuthor(this.store, m);
|
|
974
1516
|
}
|
|
975
1517
|
}
|
|
976
1518
|
async retire() {
|
|
1519
|
+
this.retired = true;
|
|
977
1520
|
await this.store.transaction((tx) => {
|
|
978
1521
|
const meta = tx.get("meta", META);
|
|
979
1522
|
if (meta)
|
|
@@ -987,9 +1530,6 @@ class ClientImpl {
|
|
|
987
1530
|
await this.emitState();
|
|
988
1531
|
}
|
|
989
1532
|
// Receiving and reads ---------------------------------------------------------------------------
|
|
990
|
-
receive(wire) {
|
|
991
|
-
return receiveRecord(this.inboxDeps(), wire);
|
|
992
|
-
}
|
|
993
1533
|
history(query) {
|
|
994
1534
|
return history(this.store, this.policy, query);
|
|
995
1535
|
}
|
|
@@ -1002,9 +1542,30 @@ class ClientImpl {
|
|
|
1002
1542
|
newest(identityId, kind) {
|
|
1003
1543
|
return newestSlot(this.store, identityId, kind, this.now());
|
|
1004
1544
|
}
|
|
1545
|
+
keyChanges(options = {}) {
|
|
1546
|
+
const limit = Math.max(1, Math.min(500, Math.floor(options.limit ?? 50)));
|
|
1547
|
+
const who = options.accountId ? toHex(options.accountId) : undefined;
|
|
1548
|
+
return this.store.read((tx) => tx
|
|
1549
|
+
.list("keyChanges")
|
|
1550
|
+
.map(([, r]) => r)
|
|
1551
|
+
.filter((r) => who === undefined || r.accountId === who)
|
|
1552
|
+
// Newest first; two changes accepted in one transaction order by generation.
|
|
1553
|
+
.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))
|
|
1554
|
+
.slice(0, limit)
|
|
1555
|
+
.map((r) => ({
|
|
1556
|
+
accountId: fromHex(r.accountId),
|
|
1557
|
+
accountName: r.accountName,
|
|
1558
|
+
previousIdentityId: fromHex(r.previousIdentityId),
|
|
1559
|
+
newIdentityId: fromHex(r.newIdentityId),
|
|
1560
|
+
generation: unbig(r.generation),
|
|
1561
|
+
reason: r.reason,
|
|
1562
|
+
successionId: r.successionId === undefined ? undefined : fromHex(r.successionId),
|
|
1563
|
+
acceptedAt: unbig(r.acceptedAt)
|
|
1564
|
+
})));
|
|
1565
|
+
}
|
|
1005
1566
|
// Circles ---------------------------------------------------------------------------------------
|
|
1006
1567
|
async listCircles() {
|
|
1007
|
-
const self = this.selfPin?.
|
|
1568
|
+
const self = this.selfPin?.accountId;
|
|
1008
1569
|
const now = this.now();
|
|
1009
1570
|
return this.store.read((tx) => tx.list("circles").map(([, r]) => {
|
|
1010
1571
|
const c = rowToCircle(r);
|
|
@@ -1019,6 +1580,8 @@ class ClientImpl {
|
|
|
1019
1580
|
* the state, so a change that landed in the rows reaches the vault however the process dies.
|
|
1020
1581
|
*/
|
|
1021
1582
|
async syncVault() {
|
|
1583
|
+
if (!this.vaultEnabled)
|
|
1584
|
+
return;
|
|
1022
1585
|
const { account, pin } = this.requireAccount("vault");
|
|
1023
1586
|
const recoveryKey = await this.secrets.get("recoveryKey");
|
|
1024
1587
|
const accountSeed = await this.secrets.get("accountSeed");
|
|
@@ -1101,11 +1664,14 @@ class ClientImpl {
|
|
|
1101
1664
|
async createCircle() {
|
|
1102
1665
|
const { account, pin } = this.requireAccount("createCircle");
|
|
1103
1666
|
const circleId = newCircleId();
|
|
1104
|
-
const genesis = createCircleSnapshot({ identityId: pin.
|
|
1105
|
-
const state = acceptSnapshot(pinCircle(circleId, pin.
|
|
1667
|
+
const genesis = createCircleSnapshot({ identityId: pin.accountId, accountSecretKey: account.account.secretKey }, circleId);
|
|
1668
|
+
const state = acceptSnapshot(pinCircle(circleId, pin.accountId, true, "member"), genesis, pin.accountKey, pin.accountId, this.now());
|
|
1106
1669
|
if (state.outcome !== "ACCEPTED")
|
|
1107
1670
|
throw new KavachError("STORAGE_FAILURE", "createCircle");
|
|
1108
|
-
await this.store.transaction((tx) =>
|
|
1671
|
+
await this.store.transaction((tx) => {
|
|
1672
|
+
t3Circle(tx, { ...circleToRow(state.state), pending: { snapshot: toHex(genesis), revision: "1" } });
|
|
1673
|
+
this.recordPreferenceIn(tx, circleId, true);
|
|
1674
|
+
});
|
|
1109
1675
|
const published = await this.publishPendingSnapshot(circleId);
|
|
1110
1676
|
if (published !== "COMMITTED")
|
|
1111
1677
|
throw new KavachError("STORAGE_FAILURE", "createCircle", published);
|
|
@@ -1114,6 +1680,7 @@ class ClientImpl {
|
|
|
1114
1680
|
t9MarkVault(tx);
|
|
1115
1681
|
});
|
|
1116
1682
|
await this.syncVault();
|
|
1683
|
+
await this.pushPreferencesQuietly();
|
|
1117
1684
|
this.emit({ kind: "circleChanged", circleId, revision: 1n, status: "member" });
|
|
1118
1685
|
return { circleId };
|
|
1119
1686
|
}
|
|
@@ -1121,6 +1688,8 @@ class ClientImpl {
|
|
|
1121
1688
|
this.requireAccount("requestJoin");
|
|
1122
1689
|
await this.store.transaction((tx) => {
|
|
1123
1690
|
const existing = tx.get("circles", toHex(circleId));
|
|
1691
|
+
if (!existing || existing.status === "left")
|
|
1692
|
+
this.recordPreferenceIn(tx, circleId, shareOnApproval);
|
|
1124
1693
|
if (!existing)
|
|
1125
1694
|
t3Circle(tx, circleToRow(pinCircle(circleId, ownerIdentityId, shareOnApproval, "requested")));
|
|
1126
1695
|
else if (existing.status === "left") {
|
|
@@ -1136,18 +1705,30 @@ class ClientImpl {
|
|
|
1136
1705
|
const outcome = await this.transport.directory.requestJoin(circleId);
|
|
1137
1706
|
if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED")
|
|
1138
1707
|
throw new KavachError("STORAGE_FAILURE", "requestJoin", outcome.status);
|
|
1708
|
+
await this.pushPreferencesQuietly();
|
|
1709
|
+
}
|
|
1710
|
+
/** The host's copy of a preference, best effort now and by sync later. */
|
|
1711
|
+
async pushPreferencesQuietly() {
|
|
1712
|
+
if (!acceptsSuccession(this.policy))
|
|
1713
|
+
return;
|
|
1714
|
+
try {
|
|
1715
|
+
await this.pushPreferences();
|
|
1716
|
+
}
|
|
1717
|
+
catch {
|
|
1718
|
+
/* retried by sync */
|
|
1719
|
+
}
|
|
1139
1720
|
}
|
|
1140
1721
|
async approve(circleId, accountName) {
|
|
1141
1722
|
const [resolved] = await this.transport.directory.resolveAccounts([accountName]);
|
|
1142
1723
|
if (!resolved)
|
|
1143
1724
|
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)
|
|
1725
|
+
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: [] };
|
|
1726
|
+
await this.acceptMemberCertificate(resolved, report);
|
|
1727
|
+
const requester = resolvedAccountId(resolved);
|
|
1728
|
+
const pinned = await this.pinFor(requester);
|
|
1729
|
+
if (!pinned || !equalBytes(pinned.certificateHash, expectType(verifyDocument(resolved.certificate), DocumentType.AccountDevice).bodyHash))
|
|
1149
1730
|
throw new KavachError("ACCOUNT_KEY_CHANGED", "approve", "requester's key is quarantined");
|
|
1150
|
-
return this.changeMembership(circleId, { admit: requester
|
|
1731
|
+
return this.changeMembership(circleId, { admit: requester });
|
|
1151
1732
|
}
|
|
1152
1733
|
async changeMembership(circleId, change) {
|
|
1153
1734
|
const { account, pin } = this.requireAccount("changeMembership");
|
|
@@ -1163,7 +1744,7 @@ class ClientImpl {
|
|
|
1163
1744
|
const state = rowToCircle(row);
|
|
1164
1745
|
if (state.status === "closed")
|
|
1165
1746
|
return { revision: state.revision };
|
|
1166
|
-
const successor = successorSnapshot(state, change, { identityId: pin.
|
|
1747
|
+
const successor = successorSnapshot(state, change, { identityId: pin.accountId, accountSecretKey: account.account.secretKey });
|
|
1167
1748
|
if (!successor) {
|
|
1168
1749
|
if (!("close" in change))
|
|
1169
1750
|
return { revision: state.revision };
|
|
@@ -1205,7 +1786,7 @@ class ClientImpl {
|
|
|
1205
1786
|
const expectedRevision = unbig(pending.revision) - 1n;
|
|
1206
1787
|
const outcome = await this.transport.directory.publishOwnedCircle({ circleId, expectedRevision, expectedHash: expectedRevision === state.revision ? state.bodyHash : undefined, newSnapshot: successor });
|
|
1207
1788
|
if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
|
|
1208
|
-
const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.
|
|
1789
|
+
const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.accountId, this.now());
|
|
1209
1790
|
if (accepted.outcome !== "ACCEPTED" && accepted.outcome !== "DUPLICATE")
|
|
1210
1791
|
throw new KavachError("STORAGE_FAILURE", stage, "the successor was refused locally");
|
|
1211
1792
|
let next = pending.close ? { ...accepted.state, status: "closed" } : accepted.state;
|
|
@@ -1219,7 +1800,7 @@ class ClientImpl {
|
|
|
1219
1800
|
});
|
|
1220
1801
|
const after = new Set(next.members.map(toHex));
|
|
1221
1802
|
for (const m of state.members) {
|
|
1222
|
-
if (after.has(toHex(m)) || equalBytes(m, pin.
|
|
1803
|
+
if (after.has(toHex(m)) || equalBytes(m, pin.accountId))
|
|
1223
1804
|
continue;
|
|
1224
1805
|
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
1806
|
if (!stillElsewhere)
|
|
@@ -1231,7 +1812,7 @@ class ClientImpl {
|
|
|
1231
1812
|
return "COMMITTED";
|
|
1232
1813
|
}
|
|
1233
1814
|
if (outcome.status === "CONFLICT" && outcome.current) {
|
|
1234
|
-
const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.
|
|
1815
|
+
const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.accountId, this.now());
|
|
1235
1816
|
await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(accepted.outcome === "ACCEPTED" ? accepted.state : state), pending: undefined }));
|
|
1236
1817
|
return "CONFLICT";
|
|
1237
1818
|
}
|
|
@@ -1242,7 +1823,7 @@ class ClientImpl {
|
|
|
1242
1823
|
const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
|
|
1243
1824
|
if (!row)
|
|
1244
1825
|
return;
|
|
1245
|
-
if (row.ownerIdentityId === toHex(pin.
|
|
1826
|
+
if (row.ownerIdentityId === toHex(pin.accountId))
|
|
1246
1827
|
throw new KavachError("NOT_AUTHORIZED", "leave", "the owner closes instead");
|
|
1247
1828
|
const state = rowToCircle(row);
|
|
1248
1829
|
await this.store.transaction((tx) => {
|
|
@@ -1263,15 +1844,395 @@ class ClientImpl {
|
|
|
1263
1844
|
}
|
|
1264
1845
|
await this.store.transaction((tx) => {
|
|
1265
1846
|
t3Circle(tx, { ...row, enabled }, { kind: enabled ? "enable" : "disable", circleId });
|
|
1847
|
+
this.recordPreferenceIn(tx, circleId, enabled);
|
|
1266
1848
|
});
|
|
1267
1849
|
await this.syncVault();
|
|
1850
|
+
await this.pushPreferencesQuietly();
|
|
1851
|
+
}
|
|
1852
|
+
/** The user's latest action on a circle's preference, with the local sequence that a host response must name to clear it. */
|
|
1853
|
+
recordPreferenceIn(tx, circleId, enabled) {
|
|
1854
|
+
const key = toHex(circleId);
|
|
1855
|
+
const row = tx.get("preferences", key) ?? { circleId: key, enabled, revision: "0", pending: undefined, nextSequence: "1" };
|
|
1856
|
+
const sequence = row.nextSequence;
|
|
1857
|
+
tx.put("preferences", key, { ...row, enabled, pending: { enabled, sequence }, nextSequence: big(unbig(sequence) + 1n) });
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Send pending preference changes to the host (amendment A1, section 7). On CONFLICT the
|
|
1861
|
+
* device's latest persisted action wins, read from the store at retry time, and is written again
|
|
1862
|
+
* at the served revision; a response clears only the sequence it answered.
|
|
1863
|
+
*/
|
|
1864
|
+
async pushPreferences(report) {
|
|
1865
|
+
if (!acceptsSuccession(this.policy) || !this.selfPin)
|
|
1866
|
+
return;
|
|
1867
|
+
const pending = await this.store.read((tx) => tx.list("preferences").filter(([, p]) => p.pending !== undefined).map(([, p]) => p.circleId));
|
|
1868
|
+
for (const key of pending) {
|
|
1869
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1870
|
+
const row = await this.store.read((tx) => tx.get("preferences", key));
|
|
1871
|
+
if (!row?.pending)
|
|
1872
|
+
break;
|
|
1873
|
+
const sent = row.pending;
|
|
1874
|
+
const outcome = await this.transport.directory.setSharingPreference(fromHex(key), sent.enabled, unbig(row.revision));
|
|
1875
|
+
if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
|
|
1876
|
+
await this.store.transaction((tx) => {
|
|
1877
|
+
const live = tx.get("preferences", key);
|
|
1878
|
+
if (!live)
|
|
1879
|
+
return;
|
|
1880
|
+
const cleared = live.pending?.sequence === sent.sequence ? undefined : live.pending;
|
|
1881
|
+
// A late success for an older request never moves the revision back.
|
|
1882
|
+
const revision = outcome.revision > unbig(live.revision) ? big(outcome.revision) : live.revision;
|
|
1883
|
+
tx.put("preferences", key, { ...live, revision, pending: cleared });
|
|
1884
|
+
});
|
|
1885
|
+
break;
|
|
1886
|
+
}
|
|
1887
|
+
if (outcome.status === "CONFLICT") {
|
|
1888
|
+
// Adopt the served revision; the latest persisted action is re-sent against it.
|
|
1889
|
+
await this.store.transaction((tx) => {
|
|
1890
|
+
const live = tx.get("preferences", key);
|
|
1891
|
+
if (live)
|
|
1892
|
+
tx.put("preferences", key, { ...live, revision: big(outcome.revision) });
|
|
1893
|
+
});
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1896
|
+
if (report)
|
|
1897
|
+
report.errors.push({ operation: "preference", code: outcome.status === "UNAUTHORIZED" ? "NOT_AUTHORIZED" : "NETWORK", stage: "preference", retryable: outcome.status !== "UNAUTHORIZED" });
|
|
1898
|
+
break;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1268
1901
|
}
|
|
1269
1902
|
/** Close: the successor names only the owner; its publication forgets the members and removes the vault entry. */
|
|
1270
1903
|
async closeCircle(circleId) {
|
|
1271
1904
|
await this.changeMembership(circleId, { close: true });
|
|
1272
1905
|
}
|
|
1906
|
+
// Account recovery (amendment A1) ---------------------------------------------------------------
|
|
1907
|
+
async prepareReplacementNow() {
|
|
1908
|
+
const stage = "prepareReplacement";
|
|
1909
|
+
if (!acceptsSuccession(this.policy))
|
|
1910
|
+
throw new KavachError("NOT_AUTHORIZED", stage, "this deployment does not use account recovery");
|
|
1911
|
+
const opened = await openStore(this.store, this.secrets, this.accountName);
|
|
1912
|
+
if (opened.kind !== "fresh")
|
|
1913
|
+
throw new KavachError("MALFORMED", stage, "an account already exists on this device");
|
|
1914
|
+
const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
|
|
1915
|
+
const roots = await this.secrets.get("pendingReplacement");
|
|
1916
|
+
if (existing && !existing.stale && roots && roots.length === RESET_ROOTS_BYTES)
|
|
1917
|
+
return proposalOf(existing);
|
|
1918
|
+
const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
|
|
1919
|
+
if (!resolved)
|
|
1920
|
+
throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
|
|
1921
|
+
const current = expectType(verifyDocument(resolved.certificate), DocumentType.AccountDevice, stage);
|
|
1922
|
+
const accountId = resolvedAccountId(resolved);
|
|
1923
|
+
// Fresh roots: nothing from the old device, nothing from the host. The certificate is at the
|
|
1924
|
+
// next generation under the new key; the vault is fresh at revision 1 so that identity
|
|
1925
|
+
// continuity works on the next replacement.
|
|
1926
|
+
const accountSeed = newAccountSeed();
|
|
1927
|
+
const device = newDevice();
|
|
1928
|
+
const recoveryKey = newRecoveryKey();
|
|
1929
|
+
const instanceId = randomBytes(16);
|
|
1930
|
+
const successionId = randomBytes(16);
|
|
1931
|
+
const account = openAccount(this.accountName, accountSeed, accountId);
|
|
1932
|
+
const generation = current.generation + 1n;
|
|
1933
|
+
const certificate = certify(account, device, generation);
|
|
1934
|
+
const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage), undefined, accountId);
|
|
1935
|
+
const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: generation, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
|
|
1936
|
+
// T11: roots in the secret store, the exact bytes in the store, before anything is sent.
|
|
1937
|
+
await this.secrets.set("pendingReplacement", concat(accountSeed, device.deviceSeed, recoveryKey, instanceId, device.deviceId));
|
|
1938
|
+
const row = {
|
|
1939
|
+
successionId: toHex(successionId),
|
|
1940
|
+
accountName: this.accountName,
|
|
1941
|
+
accountId: toHex(accountId),
|
|
1942
|
+
expectedCertificateHash: toHex(current.bodyHash),
|
|
1943
|
+
expectedGeneration: big(current.generation),
|
|
1944
|
+
certificate: toHex(certificate),
|
|
1945
|
+
vault: vault ? toHex(vault.wire) : undefined,
|
|
1946
|
+
vaultHash: vault ? toHex(vault.hash) : undefined,
|
|
1947
|
+
deviceId: toHex(device.deviceId),
|
|
1948
|
+
stale: false,
|
|
1949
|
+
conflictGeneration: undefined
|
|
1950
|
+
};
|
|
1951
|
+
await this.store.transaction((tx) => tx.put("replacement", "pending", row));
|
|
1952
|
+
await this.emitState();
|
|
1953
|
+
return proposalOf(row);
|
|
1954
|
+
}
|
|
1955
|
+
async completeReplacementNow(input) {
|
|
1956
|
+
const stage = "completeReplacement";
|
|
1957
|
+
// Already activated (by an earlier call, or by a sync that found the commit on the chain): idempotent.
|
|
1958
|
+
if (this.selfPin && this.account && !this.retired)
|
|
1959
|
+
return { identityId: this.selfPin.identityId, generation: this.selfPin.generation };
|
|
1960
|
+
const row = await this.store.read((tx) => tx.get("replacement", "pending"));
|
|
1961
|
+
if (!row || row.successionId !== toHex(input.successionId))
|
|
1962
|
+
throw new KavachError("MALFORMED", stage, "no such prepared replacement on this device");
|
|
1963
|
+
if (row.stale)
|
|
1964
|
+
throw new KavachError("REPLACEMENT_CONFLICT", stage, row.conflictGeneration === undefined ? "the account moved" : `the account is at generation ${row.conflictGeneration}`);
|
|
1965
|
+
const roots = await this.secrets.get("pendingReplacement");
|
|
1966
|
+
if (!roots || roots.length !== RESET_ROOTS_BYTES)
|
|
1967
|
+
throw new KavachError("RECOVERY_REQUIRED", stage, "replacement prepared without its roots");
|
|
1968
|
+
let outcome;
|
|
1969
|
+
try {
|
|
1970
|
+
outcome = await this.transport.directory.succeedAccount({
|
|
1971
|
+
accountName: this.accountName,
|
|
1972
|
+
expectedCertificateHash: fromHex(row.expectedCertificateHash),
|
|
1973
|
+
newCertificate: fromHex(row.certificate),
|
|
1974
|
+
newVault: row.vault === undefined ? undefined : fromHex(row.vault),
|
|
1975
|
+
successionId: input.successionId,
|
|
1976
|
+
authorization: input.authorization
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
catch (e) {
|
|
1980
|
+
if (!isKavachError(e, "NETWORK"))
|
|
1981
|
+
throw e;
|
|
1982
|
+
// Unknown: the chain says whether ours committed. Never a second identity.
|
|
1983
|
+
const link = await this.findOwnLink(row).catch(() => undefined);
|
|
1984
|
+
if (!link)
|
|
1985
|
+
throw new KavachError("SUCCESSION_PENDING", stage, "the commit's outcome is unknown; call again with the same proposal");
|
|
1986
|
+
outcome = { status: "COMMITTED", succession: link.wire };
|
|
1987
|
+
}
|
|
1988
|
+
if (outcome.status === "CONFLICT") {
|
|
1989
|
+
// Definitive: the current certificate moved under the proposal, and the succession id is
|
|
1990
|
+
// not on the chain (that answers first). The proposal can never commit: it is marked stale
|
|
1991
|
+
// and its keys, never activated anywhere, are discarded.
|
|
1992
|
+
let current;
|
|
1993
|
+
try {
|
|
1994
|
+
current = outcome.current ? expectType(verifyDocument(outcome.current), DocumentType.AccountDevice, stage).generation : undefined;
|
|
1995
|
+
}
|
|
1996
|
+
catch {
|
|
1997
|
+
current = undefined;
|
|
1998
|
+
}
|
|
1999
|
+
await this.store.transaction((tx) => tx.put("replacement", "pending", { ...row, stale: true, conflictGeneration: current === undefined ? undefined : big(current) }));
|
|
2000
|
+
await this.secrets.delete("pendingReplacement");
|
|
2001
|
+
await this.emitState();
|
|
2002
|
+
throw new KavachError("REPLACEMENT_CONFLICT", stage, current === undefined ? "the account moved" : `the account is at generation ${current}`);
|
|
2003
|
+
}
|
|
2004
|
+
if (outcome.status === "UNAUTHORIZED")
|
|
2005
|
+
throw new KavachError("AUTHORIZATION_REQUIRED", stage, "the host refused the authorization; obtain a fresh one for the same proposal");
|
|
2006
|
+
if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.succession) {
|
|
2007
|
+
throw new KavachError("SUCCESSION_PENDING", stage, `host answered ${outcome.status}`);
|
|
2008
|
+
}
|
|
2009
|
+
return this.activateReplacement(row, roots, outcome.succession);
|
|
2010
|
+
}
|
|
2011
|
+
/** Our own link on the host's chain, if the proposal committed: the one naming our succession id and certificate. */
|
|
2012
|
+
async findOwnLink(row) {
|
|
2013
|
+
const page = await this.transport.directory.getSuccessions(fromHex(row.accountId), unbig(row.expectedGeneration), 8);
|
|
2014
|
+
for (const l of page.successions) {
|
|
2015
|
+
try {
|
|
2016
|
+
const doc = expectType(parseDocument(l.wire), DocumentType.AccountSuccession, "resume");
|
|
2017
|
+
if (toHex(doc.successionId) === row.successionId && equalBytes(l.certificate, fromHex(row.certificate)))
|
|
2018
|
+
return l;
|
|
2019
|
+
}
|
|
2020
|
+
catch {
|
|
2021
|
+
/* not ours */
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
return undefined;
|
|
2025
|
+
}
|
|
2026
|
+
/** The reset-key list, fetched when the one held is not fresh. */
|
|
2027
|
+
async ensureResetList() {
|
|
2028
|
+
const now = this.now();
|
|
2029
|
+
if (this.trust.fresh(now))
|
|
2030
|
+
return;
|
|
2031
|
+
const wire = await this.transport.directory.getResetKeyList();
|
|
2032
|
+
if (!wire)
|
|
2033
|
+
return;
|
|
2034
|
+
this.trust.install(wire, now);
|
|
2035
|
+
await this.persistTrust();
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Activate: verify the returned succession exactly as a peer would, against the previous state
|
|
2039
|
+
* pinned from the host, then the T1 header under the new identity with the account id carried
|
|
2040
|
+
* from the proposal. A succession that does not verify leaves the proposal intact.
|
|
2041
|
+
*/
|
|
2042
|
+
async activateReplacement(row, roots, succession) {
|
|
2043
|
+
const stage = "completeReplacement";
|
|
2044
|
+
await this.ensureResetList();
|
|
2045
|
+
const previousWire = await this.transport.directory.getCertificate(fromHex(row.expectedCertificateHash));
|
|
2046
|
+
if (!previousWire)
|
|
2047
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the previous certificate is not served");
|
|
2048
|
+
const previous = expectType(verifyDocument(previousWire), DocumentType.AccountDevice, stage);
|
|
2049
|
+
if (toHex(previous.bodyHash) !== row.expectedCertificateHash)
|
|
2050
|
+
throw new KavachError("SUCCESSION_BINDING", stage, "the served certificate is not the expected one");
|
|
2051
|
+
const accountId = fromHex(row.accountId);
|
|
2052
|
+
const previousPin = pinFromCertificate(previous, undefined, accountId);
|
|
2053
|
+
// The account's earlier successions, if any: the chain from the root to the previous identity,
|
|
2054
|
+
// verified through the one chain path and kept, so this device holds every key the account
|
|
2055
|
+
// has signed with (an owned circle's snapshot from before an earlier replacement) and can
|
|
2056
|
+
// follow its own chain later (to learn of its retirement).
|
|
2057
|
+
const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
|
|
2058
|
+
// The evidence must be there before anything is written: without the account's resolution the
|
|
2059
|
+
// history is unknown, not empty, and the proposal stays for the next call. An empty history is
|
|
2060
|
+
// right only when the previous identity is the account root.
|
|
2061
|
+
if (!me)
|
|
2062
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the account's history is not served; the proposal stays");
|
|
2063
|
+
const served = me.successions ?? [];
|
|
2064
|
+
const ownSuccession = authenticateSuccession(succession, this.trust, this.now());
|
|
2065
|
+
if (served.length === 0 && me.successionsNext === undefined && !equalBytes(previousPin.identityId, accountId))
|
|
2066
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the account's earlier successions are not served; the proposal stays");
|
|
2067
|
+
// Bounded by the generation this device's own succession left from, on every page alike.
|
|
2068
|
+
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 : [];
|
|
2069
|
+
const accountSeed = roots.subarray(0, 32);
|
|
2070
|
+
const deviceSeed = roots.subarray(32, 64);
|
|
2071
|
+
const recoveryKey = roots.subarray(64, 96);
|
|
2072
|
+
const instanceId = roots.subarray(96, 112);
|
|
2073
|
+
const deviceId = roots.subarray(112, 128);
|
|
2074
|
+
const account = openAccount(this.accountName, accountSeed, accountId);
|
|
2075
|
+
const device = deviceFromSeed(deviceSeed, deviceId);
|
|
2076
|
+
const certificate = fromHex(row.certificate);
|
|
2077
|
+
const doc = expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage);
|
|
2078
|
+
const pin = pinFromCertificate(doc, undefined, accountId);
|
|
2079
|
+
// Roots first, then the store: a crash in between leaves a device the next call finishes.
|
|
2080
|
+
await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
|
|
2081
|
+
await this.store.transaction((tx) => {
|
|
2082
|
+
// The previous state as a first contact pins it, then the succession as a peer verifies it:
|
|
2083
|
+
// nothing is written unless the document binds this account, this generation and this key.
|
|
2084
|
+
tx.put("pins", toHex(accountId), pinToRow(previousPin));
|
|
2085
|
+
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 });
|
|
2086
|
+
recordChainIn(tx, history, this.now());
|
|
2087
|
+
rootIdentityIn(tx, accountId, this.accountName, me?.rootCertificate);
|
|
2088
|
+
const effect = applySuccession(tx, { wire: succession, newCertificate: certificate, policy: this.policy, trust: this.trust, now: this.now() });
|
|
2089
|
+
if (!equalBytes(effect.newIdentityId, account.identityId) || toHex(effect.accountId) !== row.accountId)
|
|
2090
|
+
throw new KavachError("SUCCESSION_BINDING", stage, "the succession does not name this device's key");
|
|
2091
|
+
this.persistTrustIn(tx);
|
|
2092
|
+
t1Header(tx, { account, device, certificate, instanceId, generation: doc.generation });
|
|
2093
|
+
if (row.vault !== undefined && row.vaultHash !== undefined)
|
|
2094
|
+
t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
|
|
2095
|
+
t3ApplyChange(tx, { kind: "ownActivation" });
|
|
2096
|
+
t1Activate(tx);
|
|
2097
|
+
const meta = tx.get("meta", META);
|
|
2098
|
+
if (meta)
|
|
2099
|
+
tx.put("meta", META, { ...meta, bootstrap: { complete: false } });
|
|
2100
|
+
tx.delete("replacement", "pending");
|
|
2101
|
+
});
|
|
2102
|
+
await this.secrets.delete("pendingReplacement");
|
|
2103
|
+
this.account = account;
|
|
2104
|
+
this.device = device;
|
|
2105
|
+
this.selfPin = pin;
|
|
2106
|
+
this.retired = false;
|
|
2107
|
+
try {
|
|
2108
|
+
await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
|
|
2109
|
+
}
|
|
2110
|
+
catch {
|
|
2111
|
+
/* reported by recoveryStatus() */
|
|
2112
|
+
}
|
|
2113
|
+
await this.emitState();
|
|
2114
|
+
return { identityId: pin.identityId, generation: pin.generation };
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Discovery after account recovery (amendment A1, section 5): the host lists the account's
|
|
2118
|
+
* circles page by page; each is pinned only when its snapshot verifies under the owner's
|
|
2119
|
+
* verified chain and names this account as the host says. Discovery is not permission to share:
|
|
2120
|
+
* the circle is enabled only if the served preference says so. Never writes membership; an
|
|
2121
|
+
* owner's successor republishes the served snapshot at the next revision.
|
|
2122
|
+
*/
|
|
2123
|
+
async bootstrap(report, state) {
|
|
2124
|
+
const out = { circles: 0, pending: 0, failed: 0 };
|
|
2125
|
+
// A circle that could not be adopted is kept with what the host served and retried by every
|
|
2126
|
+
// sync; a failure never advances past it silently, and never undoes another circle.
|
|
2127
|
+
let retry = state.retry ?? [];
|
|
2128
|
+
let cursor = state.cursor;
|
|
2129
|
+
let pagesDone = state.pagesDone === true;
|
|
2130
|
+
const adopt = async (c) => {
|
|
2131
|
+
try {
|
|
2132
|
+
const r = await this.adoptListing(c, report);
|
|
2133
|
+
if (r === "member")
|
|
2134
|
+
out.circles++;
|
|
2135
|
+
else if (r === "pending")
|
|
2136
|
+
out.pending++;
|
|
2137
|
+
return true;
|
|
2138
|
+
}
|
|
2139
|
+
catch (e) {
|
|
2140
|
+
out.failed++;
|
|
2141
|
+
report.errors.push(syncError("bootstrap", e, { circleId: c.circleId }));
|
|
2142
|
+
return false;
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
const save = (done) => this.store.transaction((tx) => {
|
|
2146
|
+
const meta = tx.get("meta", META);
|
|
2147
|
+
if (!meta)
|
|
2148
|
+
return;
|
|
2149
|
+
const bootstrap = done && retry.length === 0 ? { complete: true } : { ...(cursor === undefined ? {} : { cursor }), complete: false, retry, pagesDone: done };
|
|
2150
|
+
tx.put("meta", META, { ...meta, bootstrap });
|
|
2151
|
+
t9MarkVault(tx);
|
|
2152
|
+
});
|
|
2153
|
+
const stillFailing = [];
|
|
2154
|
+
for (const r of retry) {
|
|
2155
|
+
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) };
|
|
2156
|
+
if (!(await adopt(listing)))
|
|
2157
|
+
stillFailing.push(r);
|
|
2158
|
+
}
|
|
2159
|
+
retry = stillFailing;
|
|
2160
|
+
for (let pages = 0; !pagesDone && pages < 64; pages++) {
|
|
2161
|
+
let page;
|
|
2162
|
+
try {
|
|
2163
|
+
page = await this.transport.directory.listCircles(cursor);
|
|
2164
|
+
}
|
|
2165
|
+
catch (e) {
|
|
2166
|
+
// What landed stays; the cursor says where to resume on the next sync.
|
|
2167
|
+
report.errors.push(syncError("bootstrap", e));
|
|
2168
|
+
await save(false);
|
|
2169
|
+
return out;
|
|
2170
|
+
}
|
|
2171
|
+
for (const c of page.circles) {
|
|
2172
|
+
if (!(await adopt(c)))
|
|
2173
|
+
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) });
|
|
2174
|
+
}
|
|
2175
|
+
cursor = page.cursor;
|
|
2176
|
+
if (cursor === undefined)
|
|
2177
|
+
pagesDone = true;
|
|
2178
|
+
await save(pagesDone);
|
|
2179
|
+
}
|
|
2180
|
+
await save(pagesDone);
|
|
2181
|
+
return out;
|
|
2182
|
+
}
|
|
2183
|
+
async adoptListing(c, report) {
|
|
2184
|
+
const stage = "bootstrap";
|
|
2185
|
+
const { pin, account } = this.requireAccount(stage);
|
|
2186
|
+
const key = toHex(c.circleId);
|
|
2187
|
+
const existing = await this.store.read((tx) => tx.get("circles", key));
|
|
2188
|
+
if (existing && (existing.revision !== "0" || existing.status === "requested"))
|
|
2189
|
+
return "skipped"; // a restart between pages
|
|
2190
|
+
if (!c.snapshot)
|
|
2191
|
+
return "skipped"; // closed: nothing to pin, nothing to reopen
|
|
2192
|
+
const ownerIsMe = equalBytes(c.ownerAccountId, pin.accountId);
|
|
2193
|
+
let ownerPin = ownerIsMe ? pin : await this.pinFor(c.ownerAccountId);
|
|
2194
|
+
if (!ownerPin) {
|
|
2195
|
+
const [r] = await this.transport.directory.resolveIdentities([c.ownerAccountId]);
|
|
2196
|
+
if (r)
|
|
2197
|
+
await this.acceptMemberCertificate(r, report);
|
|
2198
|
+
ownerPin = await this.pinFor(c.ownerAccountId);
|
|
2199
|
+
}
|
|
2200
|
+
if (!ownerPin)
|
|
2201
|
+
throw new KavachError("METADATA_REQUIRED", stage, "the circle's owner cannot be resolved");
|
|
2202
|
+
const enabled = c.enabled === true;
|
|
2203
|
+
const base = pinCircle(c.circleId, c.ownerAccountId, enabled, c.role === "pending" ? "requested" : "member");
|
|
2204
|
+
const accepted = await this.acceptSnapshotFrom(base, c.snapshot, ownerPin, this.now());
|
|
2205
|
+
if (accepted.outcome !== "ACCEPTED")
|
|
2206
|
+
throw new KavachError("MALFORMED", stage, "an empty snapshot");
|
|
2207
|
+
let state = accepted.state;
|
|
2208
|
+
const named = c.role === "owner" ? equalBytes(state.ownerIdentityId, pin.accountId) : c.role === "member" ? state.members.some((m) => equalBytes(m, pin.accountId)) : true;
|
|
2209
|
+
if (!named)
|
|
2210
|
+
throw new KavachError("NOT_AUTHORIZED", stage, "the snapshot does not name this account as the host says");
|
|
2211
|
+
if (c.role === "pending")
|
|
2212
|
+
state = { ...state, status: "requested" };
|
|
2213
|
+
// The successor owner republishes what it was served at the next revision; the host's
|
|
2214
|
+
// compare-and-swap decides.
|
|
2215
|
+
const republish = ownerIsMe ? buildCircleSnapshot({ circleId: c.circleId, ownerIdentityId: pin.accountId, revision: state.revision + 1n, members: state.members }, account.account.secretKey) : undefined;
|
|
2216
|
+
await this.store.transaction((tx) => {
|
|
2217
|
+
const row = circleToRow(state);
|
|
2218
|
+
t3Circle(tx, republish ? { ...row, pending: { snapshot: toHex(republish), revision: big(state.revision + 1n) } } : row, { kind: "snapshot", circleId: c.circleId, revision: state.revision });
|
|
2219
|
+
if (state.status === "member")
|
|
2220
|
+
t3ApplyChange(tx, { kind: "join", circleId: c.circleId });
|
|
2221
|
+
tx.put("preferences", key, { circleId: key, enabled, revision: big(c.preferenceRevision), pending: undefined, nextSequence: "1" });
|
|
2222
|
+
});
|
|
2223
|
+
this.emit({ kind: "circleChanged", circleId: c.circleId, revision: state.revision, status: state.status });
|
|
2224
|
+
if (republish) {
|
|
2225
|
+
try {
|
|
2226
|
+
await this.publishPendingSnapshot(c.circleId);
|
|
2227
|
+
}
|
|
2228
|
+
catch (e) {
|
|
2229
|
+
report.errors.push(syncError("publishSnapshot", e));
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
return state.status === "requested" ? "pending" : "member";
|
|
2233
|
+
}
|
|
1273
2234
|
// Reset -----------------------------------------------------------------------------------------
|
|
1274
|
-
async
|
|
2235
|
+
async resetAccountNow(options) {
|
|
1275
2236
|
const stage = "resetAccount";
|
|
1276
2237
|
if (!options.confirmed)
|
|
1277
2238
|
throw new KavachError("NOT_AUTHORIZED", stage, "reset requires confirmation");
|
|
@@ -1284,13 +2245,13 @@ class ClientImpl {
|
|
|
1284
2245
|
const certificate = certify(account, device, 1n);
|
|
1285
2246
|
const newPin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
|
|
1286
2247
|
const recoveryKey = newRecoveryKey();
|
|
1287
|
-
const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey);
|
|
2248
|
+
const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey) : undefined;
|
|
1288
2249
|
const resetId = randomBytes(16);
|
|
1289
2250
|
const instanceId = randomBytes(16);
|
|
1290
2251
|
// Persisted before it is sent: the host may commit the new identity and the response be lost,
|
|
1291
2252
|
// and the only keys that can continue are these. Roots in the secret store, wires in the store.
|
|
1292
2253
|
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) }));
|
|
2254
|
+
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
2255
|
return this.resumeReset(stage, options.reauthProof);
|
|
1295
2256
|
}
|
|
1296
2257
|
/** Send (or re-send) the persisted reset with its own reset id, and finish locally once the host holds it. */
|
|
@@ -1301,7 +2262,7 @@ class ClientImpl {
|
|
|
1301
2262
|
throw new KavachError("RECOVERY_REQUIRED", stage, "reset pending without its roots");
|
|
1302
2263
|
const certificate = fromHex(row.certificate);
|
|
1303
2264
|
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() });
|
|
2265
|
+
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
2266
|
if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.receipt)
|
|
1306
2267
|
throw new KavachError("NOT_AUTHORIZED", stage, `host refused reset: ${outcome.status}`);
|
|
1307
2268
|
return this.finishReset(stage, row, roots);
|
|
@@ -1323,12 +2284,13 @@ class ClientImpl {
|
|
|
1323
2284
|
const newPin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage));
|
|
1324
2285
|
await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
|
|
1325
2286
|
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"]) {
|
|
2287
|
+
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
2288
|
for (const [k] of tx.list(table))
|
|
1328
2289
|
tx.delete(table, k);
|
|
1329
2290
|
}
|
|
1330
2291
|
t1Header(tx, { account, device, certificate, instanceId, generation: 1n });
|
|
1331
|
-
|
|
2292
|
+
if (row.vault !== undefined && row.vaultHash !== undefined)
|
|
2293
|
+
t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
|
|
1332
2294
|
t1Activate(tx);
|
|
1333
2295
|
});
|
|
1334
2296
|
await this.secrets.delete("pendingReset");
|
|
@@ -1336,7 +2298,7 @@ class ClientImpl {
|
|
|
1336
2298
|
this.device = device;
|
|
1337
2299
|
this.selfPin = newPin;
|
|
1338
2300
|
try {
|
|
1339
|
-
await this.provider
|
|
2301
|
+
await this.provider?.save(this.accountName, newPin.identityId, recoveryKey);
|
|
1340
2302
|
}
|
|
1341
2303
|
catch {
|
|
1342
2304
|
/* reported by recoveryStatus() */
|
|
@@ -1359,7 +2321,55 @@ class ClientImpl {
|
|
|
1359
2321
|
await this.finishReset("load", row, roots);
|
|
1360
2322
|
}
|
|
1361
2323
|
async close() {
|
|
2324
|
+
this.closed = true;
|
|
2325
|
+
// Everything in flight finishes its current transaction; a sync stops at its next boundary.
|
|
2326
|
+
while (this.running.size > 0)
|
|
2327
|
+
await Promise.allSettled([...this.running]);
|
|
1362
2328
|
this.listeners.clear();
|
|
1363
2329
|
}
|
|
2330
|
+
// The replacement helper ------------------------------------------------------------------------
|
|
2331
|
+
async replaceDeviceNow(options) {
|
|
2332
|
+
const maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 3));
|
|
2333
|
+
const activated = () => (this.selfPin && this.account && !this.retired ? { status: "activated", identityId: this.selfPin.identityId, generation: this.selfPin.generation } : undefined);
|
|
2334
|
+
const done = activated();
|
|
2335
|
+
if (done)
|
|
2336
|
+
return done;
|
|
2337
|
+
// A proposal left by an earlier run may have committed: the chain settles it before anything else.
|
|
2338
|
+
const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
|
|
2339
|
+
if (existing && !existing.stale) {
|
|
2340
|
+
await this.sync();
|
|
2341
|
+
const settled = activated();
|
|
2342
|
+
if (settled)
|
|
2343
|
+
return settled;
|
|
2344
|
+
}
|
|
2345
|
+
const proposal = await this.prepareReplacementNow();
|
|
2346
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2347
|
+
const authorization = await options.authorize(proposal);
|
|
2348
|
+
if (authorization === "reauthenticate")
|
|
2349
|
+
return { status: "reauthenticate", proposal };
|
|
2350
|
+
for (;;) {
|
|
2351
|
+
try {
|
|
2352
|
+
const r = await this.completeReplacementNow({ successionId: proposal.successionId, authorization });
|
|
2353
|
+
return { status: "activated", identityId: r.identityId, generation: r.generation };
|
|
2354
|
+
}
|
|
2355
|
+
catch (e) {
|
|
2356
|
+
if (isKavachError(e, "SUCCESSION_PENDING")) {
|
|
2357
|
+
// The same call again with the same bytes; the host answers a committed id first.
|
|
2358
|
+
if (++attempt >= maxAttempts)
|
|
2359
|
+
return { status: "pending", proposal };
|
|
2360
|
+
continue;
|
|
2361
|
+
}
|
|
2362
|
+
if (isKavachError(e, "AUTHORIZATION_REQUIRED"))
|
|
2363
|
+
break; // a fresh token for the same proposal
|
|
2364
|
+
if (isKavachError(e, "REPLACEMENT_CONFLICT")) {
|
|
2365
|
+
const conflict = (await this.inspect()).replacement;
|
|
2366
|
+
return { status: "conflict", currentGeneration: conflict?.conflictGeneration };
|
|
2367
|
+
}
|
|
2368
|
+
throw e;
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
return { status: "pending", proposal };
|
|
2373
|
+
}
|
|
1364
2374
|
}
|
|
1365
2375
|
//# sourceMappingURL=client.js.map
|