@plane31/blind-relay 0.2.0 → 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/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/client.d.ts +81 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +211 -19
- package/dist/client.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/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2 -0
- package/dist/store.js.map +1 -1
- package/dist/testing/faults.d.ts +3 -1
- package/dist/testing/faults.d.ts.map +1 -1
- package/dist/testing/faults.js +3 -0
- package/dist/testing/faults.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/index.ts +1 -0
- package/src/client.ts +286 -20
- package/src/crypto/noble.ts +18 -1
- package/src/errors.ts +1 -0
- package/src/index.ts +5 -0
- package/src/store.ts +3 -0
- package/src/testing/faults.ts +5 -1
package/src/client.ts
CHANGED
|
@@ -167,6 +167,22 @@ export interface Client {
|
|
|
167
167
|
completeReplacement(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }>;
|
|
168
168
|
/** The key changes this device accepted for its contacts, newest first, bounded. */
|
|
169
169
|
keyChanges(options?: { accountId?: Uint8Array; limit?: number }): Promise<KeyChangeEntry[]>;
|
|
170
|
+
/**
|
|
171
|
+
* Account recovery as one call over `prepareReplacement` and `completeReplacement`, with the
|
|
172
|
+
* protocol's retry rules: an earlier proposal is settled through the chain first; the same
|
|
173
|
+
* proposal is kept through an unknown outcome and a refused authorization; a new proposal is
|
|
174
|
+
* made only after a definitive conflict, and then only by the next call. The product supplies
|
|
175
|
+
* `authorize`, which obtains the host's token for a proposal after its own fresh sign-in, or
|
|
176
|
+
* answers `"reauthenticate"` when it cannot. Attempts are bounded; `pending` means call again.
|
|
177
|
+
*/
|
|
178
|
+
replaceDevice(options: ReplaceDeviceOptions): Promise<ReplaceDeviceResult>;
|
|
179
|
+
/**
|
|
180
|
+
* Synchronise in rounds until nothing is left that this device can do now: the relay has no
|
|
181
|
+
* more records within the budget, bootstrap is complete, or progress needs someone else (an
|
|
182
|
+
* authorization, another person, metadata the host does not serve). Bounded by rounds and by
|
|
183
|
+
* time; the result says why it stopped and what remains.
|
|
184
|
+
*/
|
|
185
|
+
syncUntilDrained(options?: DrainOptions): Promise<DrainReport>;
|
|
170
186
|
/** A durable outbound record of a declared kind; the payload is the product's bytes, never read by the SDK. */
|
|
171
187
|
enqueue(kind: RecordKind, payload: Uint8Array, options?: EnqueueOptions): Promise<EnqueueResult>;
|
|
172
188
|
/** One synchronisation; `options.catchUp` bounds how much of a backlog this call reads (`SyncReport.inbox.hasMore` says when to call again). */
|
|
@@ -192,9 +208,70 @@ export interface Client {
|
|
|
192
208
|
list(): Promise<readonly CircleSummary[]>;
|
|
193
209
|
};
|
|
194
210
|
resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }>;
|
|
211
|
+
/**
|
|
212
|
+
* Stop: no new operation is accepted (`CLOSED`), the operations in flight finish their current
|
|
213
|
+
* transaction and a running sync stops at its next phase boundary, then listeners are cleared
|
|
214
|
+
* so nothing is emitted afterwards. Closes nothing it did not open: the store and the secret
|
|
215
|
+
* store are the caller's. Idempotent.
|
|
216
|
+
*/
|
|
195
217
|
close(): Promise<void>;
|
|
196
218
|
}
|
|
197
219
|
|
|
220
|
+
export interface ReplaceDeviceOptions {
|
|
221
|
+
/** Obtain the host's authorization for this proposal after the product's fresh sign-in, or say that a sign-in is needed first. */
|
|
222
|
+
readonly authorize: (proposal: ReplacementProposal) => Promise<Uint8Array | "reauthenticate">;
|
|
223
|
+
/** Commit attempts per call, over unknown outcomes and refused authorizations. Default 3. */
|
|
224
|
+
readonly maxAttempts?: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export type ReplaceDeviceResult =
|
|
228
|
+
| { readonly status: "activated"; readonly identityId: Uint8Array; readonly generation: bigint }
|
|
229
|
+
/** The outcome is still unknown or the attempts are spent: the proposal is kept; call again. */
|
|
230
|
+
| { readonly status: "pending"; readonly proposal: ReplacementProposal }
|
|
231
|
+
/** The account moved under the proposal, which is discarded; the next call prepares a new one after a fresh sign-in. */
|
|
232
|
+
| { readonly status: "conflict"; readonly currentGeneration: bigint | undefined }
|
|
233
|
+
/** The product's `authorize` asked for a fresh sign-in; the proposal is kept for the next call. */
|
|
234
|
+
| { readonly status: "reauthenticate"; readonly proposal: ReplacementProposal };
|
|
235
|
+
|
|
236
|
+
export interface DrainOptions {
|
|
237
|
+
readonly catchUp?: SyncOptions["catchUp"];
|
|
238
|
+
/** Rounds at most. Default 10. */
|
|
239
|
+
readonly maxRounds?: number;
|
|
240
|
+
/** A soft bound in milliseconds across every round: no round starts past it and each round's reading budget is the time left, but a transport call already in flight ends on its own timeout. Default 30,000. */
|
|
241
|
+
readonly maxMs?: number;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type DrainStop =
|
|
245
|
+
/** Nothing is left that this device can do now. */
|
|
246
|
+
| "drained"
|
|
247
|
+
| "maxRounds"
|
|
248
|
+
| "maxMs"
|
|
249
|
+
/** A round changed nothing and reported the same errors as the one before. */
|
|
250
|
+
| "noProgress"
|
|
251
|
+
/** Progress needs metadata the host does not serve yet (a chain, a certificate, the key list). */
|
|
252
|
+
| "metadata"
|
|
253
|
+
/** Progress needs an authorization or a sign-in (a prepared replacement, an account to recover). */
|
|
254
|
+
| "authentication"
|
|
255
|
+
/** Nothing more is reported, but a round could not read its inbox fully (discovery or a stream failed), so emptiness is not established. */
|
|
256
|
+
| "incomplete"
|
|
257
|
+
| "closed";
|
|
258
|
+
|
|
259
|
+
/** The merged report of every round, with why the loop stopped and what remains. */
|
|
260
|
+
export interface DrainReport extends SyncReport {
|
|
261
|
+
readonly rounds: number;
|
|
262
|
+
readonly stoppedBecause: DrainStop;
|
|
263
|
+
readonly remaining: {
|
|
264
|
+
/** The relay still holds records this device has not read; `"unknown"` when the last round could not read its inbox fully. */
|
|
265
|
+
readonly inboxHasMore: boolean | "unknown";
|
|
266
|
+
/** Bootstrap after account recovery has circles left to adopt. */
|
|
267
|
+
readonly bootstrapIncomplete: boolean;
|
|
268
|
+
/** Contacts whose succession chain this device has verified only partly; the next rounds continue them. */
|
|
269
|
+
readonly chainsIncomplete: number;
|
|
270
|
+
/** Authors whose current epoch has not reached this device: nothing to do but wait for them. */
|
|
271
|
+
readonly waitingFor: number;
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
198
275
|
export interface SyncOptions {
|
|
199
276
|
/**
|
|
200
277
|
* The inbox budget of this sync, shared by every stream: at most this many records
|
|
@@ -292,6 +369,9 @@ class ClientImpl implements Client {
|
|
|
292
369
|
private selfPin: AccountPin | undefined;
|
|
293
370
|
private trust: ResetTrust;
|
|
294
371
|
private syncing: Promise<SyncReport> | undefined;
|
|
372
|
+
private closed = false;
|
|
373
|
+
/** Every public operation in flight, so `close()` can wait for them. */
|
|
374
|
+
private readonly running = new Set<Promise<unknown>>();
|
|
295
375
|
/** The current sync's reading budget: records, and the links of a succession chain, count against it. */
|
|
296
376
|
private syncBudget: ReadBudget | undefined;
|
|
297
377
|
private backupChecked = false;
|
|
@@ -309,17 +389,56 @@ class ClientImpl implements Client {
|
|
|
309
389
|
this.now = options.now ?? ((): bigint => BigInt(Date.now()));
|
|
310
390
|
this.trust = new ResetTrust(options.resetTrust);
|
|
311
391
|
this.circles = {
|
|
312
|
-
create: () => this.createCircle(),
|
|
313
|
-
requestJoin: (c, o, opts) => this.requestJoin(c, o, opts?.shareOnApproval ?? true),
|
|
314
|
-
approve: (c, n) => this.approve(c, n),
|
|
315
|
-
remove: (c, m) => this.changeMembership(c, { remove: m }),
|
|
316
|
-
leave: (c) => this.leave(c),
|
|
317
|
-
setEnabled: (c, e) => this.setEnabled(c, e),
|
|
318
|
-
closeCircle: (c) => this.closeCircle(c),
|
|
392
|
+
create: () => this.track("createCircle", () => this.createCircle()),
|
|
393
|
+
requestJoin: (c, o, opts) => this.track("requestJoin", () => this.requestJoin(c, o, opts?.shareOnApproval ?? true)),
|
|
394
|
+
approve: (c, n) => this.track("approve", () => this.approve(c, n)),
|
|
395
|
+
remove: (c, m) => this.track("changeMembership", () => this.changeMembership(c, { remove: m })),
|
|
396
|
+
leave: (c) => this.track("leave", () => this.leave(c)),
|
|
397
|
+
setEnabled: (c, e) => this.track("setEnabled", () => this.setEnabled(c, e)),
|
|
398
|
+
closeCircle: (c) => this.track("closeCircle", () => this.closeCircle(c)),
|
|
319
399
|
list: () => this.listCircles()
|
|
320
400
|
};
|
|
321
401
|
}
|
|
322
402
|
|
|
403
|
+
// Every public operation that writes enters through `track`, explicitly and typed, so `close()`
|
|
404
|
+
// refuses new ones and waits for those in flight. Reads stay untracked.
|
|
405
|
+
|
|
406
|
+
initializeAccount(): Promise<{ identityId: Uint8Array }> {
|
|
407
|
+
return this.track("initializeAccount", () => this.initializeAccountNow());
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
restoreAccount(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }> {
|
|
411
|
+
return this.track("restoreAccount", () => this.restoreAccountNow(recoveryCode));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
enqueue(kind: RecordKind, payload: Uint8Array, options: EnqueueOptions = {}): Promise<EnqueueResult> {
|
|
415
|
+
return this.track("enqueue", () => this.enqueueNow(kind, payload, options));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
receive(wire: Uint8Array): Promise<ReceiveResult> {
|
|
419
|
+
return this.track("receive", () => receiveRecord(this.inboxDeps(), wire));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
prepareReplacement(): Promise<ReplacementProposal> {
|
|
423
|
+
return this.track("prepareReplacement", () => this.prepareReplacementNow());
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
completeReplacement(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }> {
|
|
427
|
+
return this.track("completeReplacement", () => this.completeReplacementNow(input));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
|
|
431
|
+
return this.track("resetAccount", () => this.resetAccountNow(options));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
replaceDevice(options: ReplaceDeviceOptions): Promise<ReplaceDeviceResult> {
|
|
435
|
+
return this.track("replaceDevice", () => this.replaceDeviceNow(options));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
syncUntilDrained(options: DrainOptions = {}): Promise<DrainReport> {
|
|
439
|
+
return this.track("syncUntilDrained", () => this.drainNow(options));
|
|
440
|
+
}
|
|
441
|
+
|
|
323
442
|
// Loading ---------------------------------------------------------------------------------------
|
|
324
443
|
|
|
325
444
|
async load(): Promise<void> {
|
|
@@ -354,8 +473,18 @@ class ClientImpl implements Client {
|
|
|
354
473
|
});
|
|
355
474
|
}
|
|
356
475
|
|
|
357
|
-
/**
|
|
476
|
+
/** Refuse after `close()`, and keep the operation's promise until it settles. */
|
|
477
|
+
private track<T>(stage: string, fn: () => Promise<T>): Promise<T> {
|
|
478
|
+
if (this.closed) return Promise.reject(new KavachError("CLOSED", stage, "the client is closed"));
|
|
479
|
+
const p = fn();
|
|
480
|
+
this.running.add(p);
|
|
481
|
+
void p.finally(() => this.running.delete(p)).catch(() => undefined);
|
|
482
|
+
return p;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. Nothing after `close()`. */
|
|
358
486
|
private emit(event: ClientEvent): void {
|
|
487
|
+
if (this.closed) return;
|
|
359
488
|
for (const l of this.listeners) {
|
|
360
489
|
try {
|
|
361
490
|
l(event);
|
|
@@ -489,7 +618,7 @@ class ClientImpl implements Client {
|
|
|
489
618
|
|
|
490
619
|
// Enrollment ------------------------------------------------------------------------------------
|
|
491
620
|
|
|
492
|
-
async
|
|
621
|
+
private async initializeAccountNow(): Promise<{ identityId: Uint8Array }> {
|
|
493
622
|
const stage = "initializeAccount";
|
|
494
623
|
const opened = await openStore(this.store, this.secrets, this.accountName);
|
|
495
624
|
if (opened.kind !== "fresh" && opened.kind !== "initializing") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
|
|
@@ -551,7 +680,7 @@ class ClientImpl implements Client {
|
|
|
551
680
|
return encodeRecoveryCode(key);
|
|
552
681
|
}
|
|
553
682
|
|
|
554
|
-
async
|
|
683
|
+
private async restoreAccountNow(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }> {
|
|
555
684
|
const stage = "restoreAccount";
|
|
556
685
|
const opened = await openStore(this.store, this.secrets, this.accountName);
|
|
557
686
|
if (opened.kind === "ACTIVE" || opened.kind === "RETIRED") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
|
|
@@ -720,7 +849,7 @@ class ClientImpl implements Client {
|
|
|
720
849
|
|
|
721
850
|
// Sending ---------------------------------------------------------------------------------------
|
|
722
851
|
|
|
723
|
-
async
|
|
852
|
+
private async enqueueNow(kind: RecordKind, payload: Uint8Array, options: EnqueueOptions): Promise<EnqueueResult> {
|
|
724
853
|
const deps = this.outboxDeps();
|
|
725
854
|
// A key answered before anything that may need the network: the record it names is already
|
|
726
855
|
// durable, so a retry offline, or with a rotation due, returns it. The outbox checks the key
|
|
@@ -839,12 +968,107 @@ class ClientImpl implements Client {
|
|
|
839
968
|
|
|
840
969
|
async sync(options: SyncOptions = {}): Promise<SyncReport> {
|
|
841
970
|
if (this.syncing) return this.syncing;
|
|
842
|
-
this.
|
|
971
|
+
if (this.closed) throw new KavachError("CLOSED", "sync", "the client is closed");
|
|
972
|
+
this.syncing = this.track("sync", () => this.runSync(options)).finally(() => {
|
|
843
973
|
this.syncing = undefined;
|
|
844
974
|
});
|
|
845
975
|
return this.syncing;
|
|
846
976
|
}
|
|
847
977
|
|
|
978
|
+
private async drainNow(options: DrainOptions): Promise<DrainReport> {
|
|
979
|
+
const maxRounds = Math.max(1, Math.floor(options.maxRounds ?? 10));
|
|
980
|
+
const maxMs = Math.max(0, Math.floor(options.maxMs ?? 30_000));
|
|
981
|
+
const deadline = this.now() + BigInt(maxMs);
|
|
982
|
+
const merged: SyncReport = {
|
|
983
|
+
metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
|
|
984
|
+
epoch: "unchanged",
|
|
985
|
+
outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
|
|
986
|
+
inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
|
|
987
|
+
purged: 0,
|
|
988
|
+
errors: []
|
|
989
|
+
};
|
|
990
|
+
let rounds = 0;
|
|
991
|
+
let previousErrors: string | undefined;
|
|
992
|
+
let stop: DrainStop = "drained";
|
|
993
|
+
let last: SyncReport | undefined;
|
|
994
|
+
// An inbox this device could not fully read this round: discovery or a stream failed, or metadata it
|
|
995
|
+
// depends on did. Such a round never establishes that the inbox is empty.
|
|
996
|
+
const inboxUncertain = (r: SyncReport): boolean => r.errors.some((e) => e.operation === "listSubscriptions" || e.operation === "inbox" || e.operation === "metadata");
|
|
997
|
+
// Chains verified only partly: remaining work, and their cursors moving is progress.
|
|
998
|
+
const chains = (): Promise<Map<string, string>> => this.store.read((tx) => new Map(tx.list("chainProgress").filter(([, c]) => !c.complete).map(([k, c]) => [k, c.nextGeneration])));
|
|
999
|
+
let previousChains = await chains();
|
|
1000
|
+
for (;;) {
|
|
1001
|
+
if (this.closed) {
|
|
1002
|
+
stop = "closed";
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
// The time left is this round's reading budget too, so a round never starts work the deadline
|
|
1006
|
+
// does not cover; a transport call already in flight ends on its own timeout.
|
|
1007
|
+
const remainingMs = Number(deadline - this.now());
|
|
1008
|
+
if (rounds > 0 && remainingMs <= 0) {
|
|
1009
|
+
stop = "maxMs";
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
const roundMs = Math.max(0, Math.min(remainingMs, options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs));
|
|
1013
|
+
const r = await this.sync({ catchUp: { maxMs: roundMs, ...(options.catchUp?.maxRecords !== undefined ? { maxRecords: options.catchUp.maxRecords } : {}) } });
|
|
1014
|
+
rounds++;
|
|
1015
|
+
last = r;
|
|
1016
|
+
// The round's work counts whether or not it was the last: what it accepted, stored and failed
|
|
1017
|
+
// is merged first, and only then is a close that landed meanwhile reported.
|
|
1018
|
+
for (const k of Object.keys(merged.metadata) as (keyof SyncReport["metadata"])[]) merged.metadata[k] += r.metadata[k];
|
|
1019
|
+
for (const k of Object.keys(merged.outbox) as (keyof SyncReport["outbox"])[]) merged.outbox[k] += r.outbox[k];
|
|
1020
|
+
merged.inbox.streams += r.inbox.streams;
|
|
1021
|
+
merged.inbox.stored += r.inbox.stored;
|
|
1022
|
+
merged.inbox.keyPending += r.inbox.keyPending;
|
|
1023
|
+
merged.inbox.invalid += r.inbox.invalid;
|
|
1024
|
+
merged.inbox.acked += r.inbox.acked;
|
|
1025
|
+
merged.purged += r.purged;
|
|
1026
|
+
merged.epoch = r.epoch;
|
|
1027
|
+
merged.errors.push(...r.errors);
|
|
1028
|
+
if (r.bootstrap) merged.bootstrap = r.bootstrap;
|
|
1029
|
+
if (this.closed) {
|
|
1030
|
+
// The round may have stopped at a boundary before its inbox: nothing it reports is complete.
|
|
1031
|
+
stop = "closed";
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
const bootstrapIncomplete = (await this.inspect()).bootstrap?.complete === false;
|
|
1035
|
+
const currentChains = await chains();
|
|
1036
|
+
const more = r.inbox.hasMore || bootstrapIncomplete || currentChains.size > 0;
|
|
1037
|
+
const status = (await this.getState()).status;
|
|
1038
|
+
if (status === "replacementPrepared" || status === "accountRecoveryRequired") {
|
|
1039
|
+
stop = "authentication";
|
|
1040
|
+
break;
|
|
1041
|
+
}
|
|
1042
|
+
if (!more) {
|
|
1043
|
+
stop = inboxUncertain(r) ? "incomplete" : "drained";
|
|
1044
|
+
break;
|
|
1045
|
+
}
|
|
1046
|
+
const signature = r.errors.map((e) => `${e.operation}:${e.code}`).sort().join(",");
|
|
1047
|
+
const chainsAdvanced = [...currentChains].some(([k, next]) => previousChains.get(k) !== next) || [...previousChains.keys()].some((k) => !currentChains.has(k));
|
|
1048
|
+
previousChains = currentChains;
|
|
1049
|
+
const changed = chainsAdvanced || r.inbox.stored + r.metadata.certificates + r.metadata.snapshots + r.metadata.successions + (r.bootstrap?.circles ?? 0) + (r.bootstrap?.pending ?? 0) > 0;
|
|
1050
|
+
if (!changed && signature !== "" && signature === previousErrors) {
|
|
1051
|
+
stop = r.errors.some((e) => e.code === "METADATA_REQUIRED" || e.code === "RESET_KEYS_REQUIRED" || e.code === "RESET_CLOCK_UNTRUSTED") ? "metadata" : "noProgress";
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
previousErrors = signature;
|
|
1055
|
+
if (rounds >= maxRounds) {
|
|
1056
|
+
stop = "maxRounds";
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
if (this.now() >= deadline) {
|
|
1060
|
+
stop = "maxMs";
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
merged.inbox.hasMore = last?.inbox.hasMore ?? false;
|
|
1065
|
+
const bootstrapIncomplete = this.closed ? false : (await this.inspect()).bootstrap?.complete === false;
|
|
1066
|
+
const chainsIncomplete = this.closed ? previousChains.size : (await chains()).size;
|
|
1067
|
+
const waitingFor = this.closed ? 0 : (await this.getState()).waitingFor.length;
|
|
1068
|
+
const inboxHasMore = !last || inboxUncertain(last) || stop === "closed" ? "unknown" : merged.inbox.hasMore;
|
|
1069
|
+
return { ...merged, rounds, stoppedBecause: stop, remaining: { inboxHasMore, bootstrapIncomplete, chainsIncomplete, waitingFor } };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
848
1072
|
private async runSync(options: SyncOptions): Promise<SyncReport> {
|
|
849
1073
|
const report: SyncReport = {
|
|
850
1074
|
metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
|
|
@@ -914,17 +1138,19 @@ class ClientImpl implements Client {
|
|
|
914
1138
|
report.errors.push(syncError("preference", e));
|
|
915
1139
|
}
|
|
916
1140
|
const after = await this.store.read((tx) => tx.get("meta", META));
|
|
917
|
-
if (after?.state === "RETIRED") return report;
|
|
1141
|
+
if (after?.state === "RETIRED" || this.closed) return report;
|
|
918
1142
|
try {
|
|
919
1143
|
report.epoch = await this.ensureEpoch();
|
|
920
1144
|
} catch (e) {
|
|
921
1145
|
report.errors.push(syncError("epoch", e));
|
|
922
1146
|
}
|
|
1147
|
+
if (this.closed) return report;
|
|
923
1148
|
try {
|
|
924
1149
|
report.outbox = await drain(this.outboxDeps());
|
|
925
1150
|
} catch (e) {
|
|
926
1151
|
report.errors.push(syncError("outbox", e));
|
|
927
1152
|
}
|
|
1153
|
+
if (this.closed) return report;
|
|
928
1154
|
let subs: Awaited<ReturnType<Relay["listSubscriptions"]>> = [];
|
|
929
1155
|
try {
|
|
930
1156
|
subs = await this.transport.relay.listSubscriptions();
|
|
@@ -937,6 +1163,7 @@ class ClientImpl implements Client {
|
|
|
937
1163
|
const budget: ReadBudget = this.syncBudget;
|
|
938
1164
|
if (budget.remainingRecords < 1) budget.remainingRecords = 1; // every stream reads at least one page
|
|
939
1165
|
for (const s of subs) {
|
|
1166
|
+
if (this.closed) return report;
|
|
940
1167
|
try {
|
|
941
1168
|
const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
|
|
942
1169
|
if (!known) continue;
|
|
@@ -1480,10 +1707,6 @@ class ClientImpl implements Client {
|
|
|
1480
1707
|
|
|
1481
1708
|
// Receiving and reads ---------------------------------------------------------------------------
|
|
1482
1709
|
|
|
1483
|
-
receive(wire: Uint8Array): Promise<ReceiveResult> {
|
|
1484
|
-
return receiveRecord(this.inboxDeps(), wire);
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
1710
|
history(query?: HistoryQuery): Promise<HistoryEntry[]> {
|
|
1488
1711
|
return history(this.store, this.policy, query);
|
|
1489
1712
|
}
|
|
@@ -1848,7 +2071,7 @@ class ClientImpl implements Client {
|
|
|
1848
2071
|
|
|
1849
2072
|
// Account recovery (amendment A1) ---------------------------------------------------------------
|
|
1850
2073
|
|
|
1851
|
-
async
|
|
2074
|
+
private async prepareReplacementNow(): Promise<ReplacementProposal> {
|
|
1852
2075
|
const stage = "prepareReplacement";
|
|
1853
2076
|
if (!acceptsSuccession(this.policy)) throw new KavachError("NOT_AUTHORIZED", stage, "this deployment does not use account recovery");
|
|
1854
2077
|
const opened = await openStore(this.store, this.secrets, this.accountName);
|
|
@@ -1893,7 +2116,7 @@ class ClientImpl implements Client {
|
|
|
1893
2116
|
return proposalOf(row);
|
|
1894
2117
|
}
|
|
1895
2118
|
|
|
1896
|
-
async
|
|
2119
|
+
private async completeReplacementNow(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }> {
|
|
1897
2120
|
const stage = "completeReplacement";
|
|
1898
2121
|
// Already activated (by an earlier call, or by a sync that found the commit on the chain): idempotent.
|
|
1899
2122
|
if (this.selfPin && this.account && !this.retired) return { identityId: this.selfPin.identityId, generation: this.selfPin.generation };
|
|
@@ -2143,7 +2366,7 @@ class ClientImpl implements Client {
|
|
|
2143
2366
|
|
|
2144
2367
|
// Reset -----------------------------------------------------------------------------------------
|
|
2145
2368
|
|
|
2146
|
-
async
|
|
2369
|
+
private async resetAccountNow(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
|
|
2147
2370
|
const stage = "resetAccount";
|
|
2148
2371
|
if (!options.confirmed) throw new KavachError("NOT_AUTHORIZED", stage, "reset requires confirmation");
|
|
2149
2372
|
const { pin } = this.requireAccount(stage);
|
|
@@ -2227,7 +2450,50 @@ class ClientImpl implements Client {
|
|
|
2227
2450
|
}
|
|
2228
2451
|
|
|
2229
2452
|
async close(): Promise<void> {
|
|
2453
|
+
this.closed = true;
|
|
2454
|
+
// Everything in flight finishes its current transaction; a sync stops at its next boundary.
|
|
2455
|
+
while (this.running.size > 0) await Promise.allSettled([...this.running]);
|
|
2230
2456
|
this.listeners.clear();
|
|
2231
2457
|
}
|
|
2458
|
+
|
|
2459
|
+
// The replacement helper ------------------------------------------------------------------------
|
|
2460
|
+
|
|
2461
|
+
private async replaceDeviceNow(options: ReplaceDeviceOptions): Promise<ReplaceDeviceResult> {
|
|
2462
|
+
const maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 3));
|
|
2463
|
+
const activated = (): ReplaceDeviceResult | undefined => (this.selfPin && this.account && !this.retired ? { status: "activated", identityId: this.selfPin.identityId, generation: this.selfPin.generation } : undefined);
|
|
2464
|
+
const done = activated();
|
|
2465
|
+
if (done) return done;
|
|
2466
|
+
// A proposal left by an earlier run may have committed: the chain settles it before anything else.
|
|
2467
|
+
const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
|
|
2468
|
+
if (existing && !existing.stale) {
|
|
2469
|
+
await this.sync();
|
|
2470
|
+
const settled = activated();
|
|
2471
|
+
if (settled) return settled;
|
|
2472
|
+
}
|
|
2473
|
+
const proposal = await this.prepareReplacementNow();
|
|
2474
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2475
|
+
const authorization = await options.authorize(proposal);
|
|
2476
|
+
if (authorization === "reauthenticate") return { status: "reauthenticate", proposal };
|
|
2477
|
+
for (;;) {
|
|
2478
|
+
try {
|
|
2479
|
+
const r = await this.completeReplacementNow({ successionId: proposal.successionId, authorization });
|
|
2480
|
+
return { status: "activated", identityId: r.identityId, generation: r.generation };
|
|
2481
|
+
} catch (e) {
|
|
2482
|
+
if (isKavachError(e, "SUCCESSION_PENDING")) {
|
|
2483
|
+
// The same call again with the same bytes; the host answers a committed id first.
|
|
2484
|
+
if (++attempt >= maxAttempts) return { status: "pending", proposal };
|
|
2485
|
+
continue;
|
|
2486
|
+
}
|
|
2487
|
+
if (isKavachError(e, "AUTHORIZATION_REQUIRED")) break; // a fresh token for the same proposal
|
|
2488
|
+
if (isKavachError(e, "REPLACEMENT_CONFLICT")) {
|
|
2489
|
+
const conflict = (await this.inspect()).replacement;
|
|
2490
|
+
return { status: "conflict", currentGeneration: conflict?.conflictGeneration };
|
|
2491
|
+
}
|
|
2492
|
+
throw e;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
return { status: "pending", proposal };
|
|
2497
|
+
}
|
|
2232
2498
|
}
|
|
2233
2499
|
|
package/src/crypto/noble.ts
CHANGED
|
@@ -4,7 +4,24 @@ import { hsalsa, xsalsa20poly1305 } from "@noble/ciphers/salsa";
|
|
|
4
4
|
import { ed25519, x25519 } from "@noble/curves/ed25519";
|
|
5
5
|
import { blake2b } from "@noble/hashes/blake2b";
|
|
6
6
|
import { sha512 } from "@noble/hashes/sha512";
|
|
7
|
-
import {
|
|
7
|
+
import { KavachError } from "../errors.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Randomness from the Web Crypto global, read at every call. `@noble/hashes` captures
|
|
11
|
+
* `globalThis.crypto` once when it is imported, which on Hermes is before the app has installed
|
|
12
|
+
* a source (the Expo helper installs expo-crypto's inside `openKavach`), so noble's own
|
|
13
|
+
* `randomBytes` would throw for the life of the process. Reading the global here, when the bytes
|
|
14
|
+
* are needed, sees whatever is installed by then.
|
|
15
|
+
*/
|
|
16
|
+
function randomBytes(length: number): Uint8Array {
|
|
17
|
+
const g = globalThis as { crypto?: { getRandomValues?: (array: Uint8Array) => Uint8Array } };
|
|
18
|
+
const get = g.crypto?.getRandomValues;
|
|
19
|
+
if (typeof get !== "function") throw new KavachError("INVALID_KEY", "randomBytes", "no crypto.getRandomValues: install a secure random source before opening Kavach");
|
|
20
|
+
const out = new Uint8Array(length);
|
|
21
|
+
// Web Crypto caps one call at 65,536 bytes; nothing here is near it, but the rule is kept.
|
|
22
|
+
for (let offset = 0; offset < length; offset += 65_536) get.call(g.crypto, out.subarray(offset, Math.min(length, offset + 65_536)));
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
8
25
|
import type { BoxKeypair, CryptoBackend, SignKeypair } from "./backend.js";
|
|
9
26
|
|
|
10
27
|
// "expand 32-byte k" as little-endian u32 words: the Salsa constant.
|
package/src/errors.ts
CHANGED
|
@@ -49,6 +49,7 @@ export type ErrorCode =
|
|
|
49
49
|
| "STALE_APPEND"
|
|
50
50
|
// Storage and transport
|
|
51
51
|
| "LOCKED"
|
|
52
|
+
| "CLOSED"
|
|
52
53
|
| "RECOVERY_REQUIRED"
|
|
53
54
|
| "STORAGE_FAILURE"
|
|
54
55
|
/** The host could not be reached or did not answer in time; the same call may succeed later. A transport raises it, never a plain error. */
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,9 @@ export {
|
|
|
14
14
|
open,
|
|
15
15
|
type CircleSummary,
|
|
16
16
|
type Client,
|
|
17
|
+
type DrainOptions,
|
|
18
|
+
type DrainReport,
|
|
19
|
+
type DrainStop,
|
|
17
20
|
type ClientEvent,
|
|
18
21
|
type ClientState,
|
|
19
22
|
type ClientStatus,
|
|
@@ -28,6 +31,8 @@ export {
|
|
|
28
31
|
type OpenOptions,
|
|
29
32
|
type PauseReason,
|
|
30
33
|
type ReceiveResult,
|
|
34
|
+
type ReplaceDeviceOptions,
|
|
35
|
+
type ReplaceDeviceResult,
|
|
31
36
|
type ReplacementProposal,
|
|
32
37
|
type SyncError,
|
|
33
38
|
type SyncOptions,
|
package/src/store.ts
CHANGED
|
@@ -19,6 +19,9 @@ export interface SecretStore {
|
|
|
19
19
|
|
|
20
20
|
export type SecretName = "accountSeed" | "deviceSeed" | "recoveryKey" | "storageKey" | "instanceId" | "pendingReset" | "pendingReplacement";
|
|
21
21
|
|
|
22
|
+
/** Every secret the SDK keeps for an account, in the order an erasure deletes them: the account's roots first, the storage key last, so an interrupted erasure leaves a store that refuses to open rather than one that re-enrolls under old roots. */
|
|
23
|
+
export const SECRET_NAMES: readonly SecretName[] = ["accountSeed", "deviceSeed", "recoveryKey", "instanceId", "pendingReset", "pendingReplacement", "storageKey"];
|
|
24
|
+
|
|
22
25
|
// Logical tables. Rows are JSON-safe: bytes as hex strings, u64 as decimal strings.
|
|
23
26
|
|
|
24
27
|
export type StoreState = "INITIALIZING" | "ACTIVE" | "ACTIVATION_PENDING" | "RETIRED";
|
package/src/testing/faults.ts
CHANGED
|
@@ -111,8 +111,10 @@ export interface MaliciousKnobs {
|
|
|
111
111
|
readonly refuse: Set<MutationName>;
|
|
112
112
|
/** Mutations whose next response waits for the given promise after the host committed (consumed once). */
|
|
113
113
|
readonly hold: Map<MutationName, Promise<void>>;
|
|
114
|
+
/** Called when a held mutation has reached its gate (the host has answered; the response is being held), so a test can act at exactly that point. */
|
|
115
|
+
onHeld?: (name: MutationName) => void;
|
|
114
116
|
/** Reads that answer "nothing" while present. */
|
|
115
|
-
readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates" | "successions" | "circleList">;
|
|
117
|
+
readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates" | "successions" | "circleList" | "subscriptions">;
|
|
116
118
|
/** Older documents served instead of the current ones. */
|
|
117
119
|
readonly replay: {
|
|
118
120
|
vault?: Uint8Array;
|
|
@@ -188,6 +190,7 @@ export class MaliciousTransport implements Transport {
|
|
|
188
190
|
const gate = this.knobs.hold.get(name);
|
|
189
191
|
if (gate) {
|
|
190
192
|
this.knobs.hold.delete(name);
|
|
193
|
+
this.knobs.onHeld?.(name);
|
|
191
194
|
await gate;
|
|
192
195
|
}
|
|
193
196
|
if (this.knobs.loseResponseOf.has(name)) {
|
|
@@ -328,6 +331,7 @@ export class MaliciousTransport implements Transport {
|
|
|
328
331
|
ackThrough: (streamId, cursor) => this.mutate("ackThrough", () => r().ackThrough(streamId, cursor)),
|
|
329
332
|
listSubscriptions: async () => {
|
|
330
333
|
this.calls.push("listSubscriptions");
|
|
334
|
+
if (k.withhold.has("subscriptions")) throw new KavachError("NETWORK", "transport", "network unreachable");
|
|
331
335
|
return r().listSubscriptions();
|
|
332
336
|
}
|
|
333
337
|
};
|