@plane31/blind-relay 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/index.d.ts +3 -3
  3. package/dist/adapters/index.d.ts.map +1 -1
  4. package/dist/adapters/index.js +1 -1
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/audience.d.ts +1 -1
  7. package/dist/audience.d.ts.map +1 -1
  8. package/dist/audience.js +7 -6
  9. package/dist/audience.js.map +1 -1
  10. package/dist/client.d.ts +78 -3
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +903 -85
  13. package/dist/client.js.map +1 -1
  14. package/dist/codec.d.ts +25 -1
  15. package/dist/codec.d.ts.map +1 -1
  16. package/dist/codec.js +44 -0
  17. package/dist/codec.js.map +1 -1
  18. package/dist/crypto.d.ts +1 -1
  19. package/dist/crypto.d.ts.map +1 -1
  20. package/dist/crypto.js.map +1 -1
  21. package/dist/errors.d.ts +9 -1
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +2 -1
  24. package/dist/errors.js.map +1 -1
  25. package/dist/identity.d.ts +8 -2
  26. package/dist/identity.d.ts.map +1 -1
  27. package/dist/identity.js +7 -3
  28. package/dist/identity.js.map +1 -1
  29. package/dist/index.d.ts +4 -4
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/policy.d.ts +13 -0
  34. package/dist/policy.d.ts.map +1 -1
  35. package/dist/policy.js +12 -1
  36. package/dist/policy.js.map +1 -1
  37. package/dist/rows.d.ts.map +1 -1
  38. package/dist/rows.js +2 -0
  39. package/dist/rows.js.map +1 -1
  40. package/dist/store.d.ts +114 -3
  41. package/dist/store.d.ts.map +1 -1
  42. package/dist/store.js.map +1 -1
  43. package/dist/succession.d.ts +108 -0
  44. package/dist/succession.d.ts.map +1 -0
  45. package/dist/succession.js +205 -0
  46. package/dist/succession.js.map +1 -0
  47. package/dist/testing/clock.d.ts.map +1 -1
  48. package/dist/testing/clock.js +1 -0
  49. package/dist/testing/clock.js.map +1 -1
  50. package/dist/testing/fakeHost.d.ts +60 -1
  51. package/dist/testing/fakeHost.d.ts.map +1 -1
  52. package/dist/testing/fakeHost.js +201 -31
  53. package/dist/testing/fakeHost.js.map +1 -1
  54. package/dist/testing/faults.d.ts +5 -3
  55. package/dist/testing/faults.d.ts.map +1 -1
  56. package/dist/testing/faults.js +24 -3
  57. package/dist/testing/faults.js.map +1 -1
  58. package/dist/transactions.d.ts +6 -0
  59. package/dist/transactions.d.ts.map +1 -1
  60. package/dist/transactions.js +11 -5
  61. package/dist/transactions.js.map +1 -1
  62. package/dist/transport.d.ts +66 -2
  63. package/dist/transport.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/adapters/index.ts +13 -0
  66. package/src/audience.ts +7 -6
  67. package/src/client.ts +881 -90
  68. package/src/codec.ts +64 -1
  69. package/src/crypto.ts +1 -0
  70. package/src/errors.ts +12 -1
  71. package/src/identity.ts +13 -3
  72. package/src/index.ts +5 -3
  73. package/src/policy.ts +23 -1
  74. package/src/rows.ts +2 -0
  75. package/src/store.ts +110 -3
  76. package/src/succession.ts +270 -0
  77. package/src/testing/clock.ts +1 -0
  78. package/src/testing/fakeHost.ts +204 -28
  79. package/src/testing/faults.ts +28 -7
  80. package/src/transactions.ts +13 -6
  81. package/src/transport.ts +69 -2
@@ -8,19 +8,22 @@ import { equalBytes, fromHex, toHex } from "../bytes.js";
8
8
  import {
9
9
  DocumentType,
10
10
  buildResetReceipt,
11
+ buildSuccession,
11
12
  expectType,
12
13
  parseDocument,
13
14
  parseVaultHeader,
14
15
  verifyDocument,
15
16
  type AccountDevice,
17
+ type AccountSuccession,
16
18
  type CircleSnapshot,
17
19
  type EncryptedRecord,
18
20
  type EpochManifest,
19
21
  type RecordKind
20
22
  } from "../codec.js";
21
- import { bodyHash, identityId, streamId as deriveStreamId, type SigningKeypair } from "../crypto.js";
23
+ import { bodyHash, identityId, randomBytes, streamId as deriveStreamId, type SigningKeypair } from "../crypto.js";
22
24
  import type {
23
25
  AppendOutcome,
26
+ CircleListing,
24
27
  Directory,
25
28
  EpochBundle,
26
29
  JoinRequest,
@@ -28,13 +31,21 @@ import type {
28
31
  ReadRecordsResult,
29
32
  Relay,
30
33
  ResolvedAccount,
34
+ SuccessionLink,
31
35
  Transport
32
36
  } from "../transport.js";
33
37
 
34
38
  interface Account {
35
39
  accountName: string;
36
40
  accountKey: Uint8Array;
41
+ /** The identity of the current key. */
37
42
  identityId: string;
43
+ /** The account id: the root of the succession chain; what circles, manifests and streams name. */
44
+ accountId: string;
45
+ /** The chain from the root, oldest first; empty until a succession. */
46
+ successions: SuccessionLink[];
47
+ /** The certificate the first succession replaced: a certificate under the root key. */
48
+ rootCertificate: Uint8Array | undefined;
38
49
  certificate: Uint8Array;
39
50
  certificateHash: string;
40
51
  generation: bigint;
@@ -89,6 +100,22 @@ export class FakeHost {
89
100
  readonly streams = new Map<string, StreamState>();
90
101
  readonly notifications: Notification[] = [];
91
102
  readonly receipts = new Map<string, Uint8Array>();
103
+ /** Successions by succession id: the committed document and the certificate hash it bound, for idempotent commits. */
104
+ readonly successionLog = new Map<string, { accountName: string; certificateHash: string; wire: Uint8Array }>();
105
+ /** Replacement authorizations the product's fresh authentication issued (`authorizeReplacement`), single use. */
106
+ readonly authorizations = new Map<string, { accountName: string; successionId: string; expectedCertificateHash: string; certificateHash: string; expiresAt: bigint; used: boolean }>();
107
+ /** Sharing preferences per (account id, circle id), with the revision that orders them. */
108
+ readonly preferences = new Map<string, { enabled: boolean; revision: bigint }>();
109
+ authorizationTtlMs = 300_000n;
110
+ successionPageSize = 8;
111
+ circlePageSize = 50;
112
+ /** Re-attest a link under the current operational key when its original signer is no longer it (section 7). */
113
+ reattest = true;
114
+ /** Test switch: the n-th call to listCircles (1-based) fails with a transport error, once. */
115
+ failCircleListAt: number | undefined;
116
+ circleListCalls = 0;
117
+ /** Every getSuccessions call, for tests that check where a peer resumes a chain. */
118
+ readonly successionCalls: { accountId: string; afterGeneration: bigint; limit: number }[] = [];
92
119
  resetKeyList: Uint8Array | undefined;
93
120
  resetSigner: { key: SigningKeypair; keyId: Uint8Array } | undefined;
94
121
  /** Test switches. */
@@ -106,6 +133,58 @@ export class FakeHost {
106
133
  return [...this.accounts.values()].find((a) => a.identityId === iid);
107
134
  }
108
135
 
136
+ /** The account an id names today: its account id, its current identity, or an identity it retired. */
137
+ accountByAnyId(id: string): Account | undefined {
138
+ return [...this.accounts.values()].find((a) => a.accountId === id || a.identityId === id || a.previousIdentities.has(id));
139
+ }
140
+
141
+ /**
142
+ * The product's fresh authentication, as a test hook: an authorization bound to the proposal
143
+ * (account, succession id, expected certificate, the certificate's hash), single use, expiring.
144
+ */
145
+ authorizeReplacement(input: { accountName: string; successionId: Uint8Array; expectedCertificateHash: Uint8Array; certificate: Uint8Array }): Uint8Array {
146
+ const doc = expectType<AccountDevice>(verifyDocument(input.certificate), DocumentType.AccountDevice);
147
+ const token = randomBytes(32);
148
+ this.authorizations.set(toHex(token), { accountName: input.accountName, successionId: toHex(input.successionId), expectedCertificateHash: toHex(input.expectedCertificateHash), certificateHash: toHex(doc.bodyHash), expiresAt: this.now() + this.authorizationTtlMs, used: false });
149
+ return token;
150
+ }
151
+
152
+ private resolvedOf(a: Account): ResolvedAccount {
153
+ const r: ResolvedAccount = { accountName: a.accountName, accountKey: a.accountKey, certificate: a.certificate };
154
+ const page = this.successionPage(a, 0n, this.successionPageSize);
155
+ return {
156
+ ...r,
157
+ ...(a.resetReceipt ? { resetReceipt: a.resetReceipt } : {}),
158
+ ...(page.successions.length ? { successions: page.successions } : {}),
159
+ ...(page.nextGeneration !== undefined ? { successionsNext: page.nextGeneration } : {}),
160
+ ...(page.rootCertificate ? { rootCertificate: page.rootCertificate } : {})
161
+ };
162
+ }
163
+
164
+ private successionPage(a: Account, afterGeneration: bigint, limit: number): { successions: SuccessionLink[]; nextGeneration?: bigint; rootCertificate?: Uint8Array } {
165
+ const gen = (l: SuccessionLink): bigint => expectType<AccountDevice>(parseDocument(l.certificate), DocumentType.AccountDevice).generation;
166
+ const rest = a.successions.filter((l) => gen(l) > afterGeneration).map((l) => this.served(l));
167
+ const page = rest.slice(0, Math.max(1, limit));
168
+ const last = page.at(-1);
169
+ const root = afterGeneration === 0n && page.length && a.rootCertificate ? { rootCertificate: a.rootCertificate } : {};
170
+ return rest.length > page.length && last ? { successions: page, nextGeneration: gen(last), ...root } : { successions: page, ...root };
171
+ }
172
+
173
+ /** A link as served: re-attested under the current operational key when its signer changed and re-attestation is on. */
174
+ private served(l: SuccessionLink): SuccessionLink {
175
+ const signer = this.resetSigner;
176
+ if (!this.reattest || !signer) return l;
177
+ const doc = expectType<AccountSuccession>(parseDocument(l.wire), DocumentType.AccountSuccession);
178
+ if (equalBytes(doc.authorityKeyId, signer.keyId)) return l;
179
+ const fields = { accountName: doc.accountName, accountId: doc.accountId, previousIdentityId: doc.previousIdentityId, newIdentityId: doc.newIdentityId, previousGeneration: doc.previousGeneration, newGeneration: doc.newGeneration, newCertificateHash: doc.newCertificateHash, successionId: doc.successionId };
180
+ return { wire: buildSuccession({ ...fields, issuedAt: this.now(), authorityKeyId: signer.keyId }, signer.key.secretKey), certificate: l.certificate };
181
+ }
182
+
183
+ /** The circle fence: is this certificate the account's current device? A retired device is refused reads too. */
184
+ private isCurrentDevice(accountName: string, certificateHash: string): boolean {
185
+ return this.accounts.get(accountName)?.certificateHash === certificateHash;
186
+ }
187
+
109
188
  private stream(streamHex: string, authorIdentityId: string, accountName: string): StreamState {
110
189
  let s = this.streams.get(streamHex);
111
190
  if (!s) {
@@ -181,17 +260,13 @@ export class FakeHost {
181
260
  async resolveAccounts(names): Promise<ResolvedAccount[]> {
182
261
  return names.flatMap((n) => {
183
262
  const a = host.accounts.get(n);
184
- if (!a) return [];
185
- const r: ResolvedAccount = { accountName: n, accountKey: a.accountKey, certificate: a.certificate };
186
- return [a.resetReceipt ? { ...r, resetReceipt: a.resetReceipt } : r];
263
+ return a ? [host.resolvedOf(a)] : [];
187
264
  });
188
265
  },
189
266
  async resolveIdentities(identityIds): Promise<ResolvedAccount[]> {
190
267
  return identityIds.flatMap((iid) => {
191
- const a = host.accountByIdentity(toHex(iid)) ?? [...host.accounts.values()].find((x) => x.previousIdentities.has(toHex(iid)));
192
- if (!a) return [];
193
- const r: ResolvedAccount = { accountName: a.accountName, accountKey: a.accountKey, certificate: a.certificate };
194
- return [a.resetReceipt ? { ...r, resetReceipt: a.resetReceipt } : r];
268
+ const a = host.accountByAnyId(toHex(iid));
269
+ return a ? [host.resolvedOf(a)] : [];
195
270
  });
196
271
  },
197
272
  async getCertificate(hash) {
@@ -201,18 +276,21 @@ export class FakeHost {
201
276
  const doc = expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice);
202
277
  const taken = host.accounts.get(doc.accountName);
203
278
  if (taken) return equalBytes(taken.certificate, certificate) ? { status: "ALREADY_COMMITTED" } : { status: "CONFLICT" };
204
- const vh = parseVaultHeader(vault);
279
+ const vh = vault ? parseVaultHeader(vault) : undefined;
205
280
  const iid = toHex(identityId(doc.accountName, doc.accountKey));
206
281
  host.accounts.set(doc.accountName, {
207
282
  accountName: doc.accountName,
208
283
  accountKey: doc.accountKey,
209
284
  identityId: iid,
285
+ accountId: iid,
286
+ successions: [],
287
+ rootCertificate: undefined,
210
288
  certificate,
211
289
  certificateHash: toHex(doc.bodyHash),
212
290
  generation: doc.generation,
213
291
  deviceIds: new Set([toHex(doc.deviceId)]),
214
292
  retiredCertificates: new Set(),
215
- vault: { wire: vault, revision: vh.vaultRevision, hash: toHex(vh.hash) },
293
+ vault: vault && vh ? { wire: vault, revision: vh.vaultRevision, hash: toHex(vh.hash) } : undefined,
216
294
  retiredIdentity: false,
217
295
  previousIdentities: new Set(),
218
296
  resetReceipt: undefined
@@ -237,7 +315,7 @@ export class FakeHost {
237
315
  a.deviceIds.add(toHex(doc.deviceId));
238
316
  a.vault = { wire: input.newVault, revision: vh.vaultRevision, hash: toHex(vh.hash) };
239
317
  host.certificates.set(a.certificateHash, input.newCertificate);
240
- host.stream(toHex(deriveStreamId(fromHex(a.identityId), doc.deviceId)), a.identityId, a.accountName);
318
+ host.stream(toHex(deriveStreamId(fromHex(a.identityId), doc.deviceId)), a.accountId, a.accountName);
241
319
  return { status: "COMMITTED" };
242
320
  },
243
321
  async getVault(name) {
@@ -264,7 +342,7 @@ export class FakeHost {
264
342
  const a = caller();
265
343
  if (!current()) return { status: "UNAUTHORIZED" };
266
344
  const doc = expectType<CircleSnapshot>(verifyDocument(newSnapshot, a.accountKey), DocumentType.CircleSnapshot);
267
- if (toHex(doc.ownerIdentityId) !== a.identityId || !equalBytes(doc.circleId, circleId)) return { status: "UNAUTHORIZED" };
345
+ if (toHex(doc.ownerIdentityId) !== a.accountId || !equalBytes(doc.circleId, circleId)) return { status: "UNAUTHORIZED" };
268
346
  const key = toHex(circleId);
269
347
  const existing = host.circles.get(key);
270
348
  if (existing) {
@@ -273,10 +351,10 @@ export class FakeHost {
273
351
  return { status: "CONFLICT", current: existing.snapshot, currentRevision: existing.revision };
274
352
  }
275
353
  } else if (expectedRevision !== 0n || doc.revision !== 1n) return { status: "CONFLICT" };
276
- host.circles.set(key, { snapshot: newSnapshot, revision: doc.revision, hash: toHex(doc.bodyHash), ownerIdentityId: a.identityId, closed: false });
354
+ host.circles.set(key, { snapshot: newSnapshot, revision: doc.revision, hash: toHex(doc.bodyHash), ownerIdentityId: a.accountId, closed: false });
277
355
  // Requests for accounts now admitted are cleared.
278
356
  const members = new Set(doc.members.map(toHex));
279
- host.requests.set(key, (host.requests.get(key) ?? []).filter((r) => !members.has(host.accounts.get(r.accountName)?.identityId ?? "")));
357
+ host.requests.set(key, (host.requests.get(key) ?? []).filter((r) => !members.has(host.accounts.get(r.accountName)?.accountId ?? "")));
280
358
  return { status: "COMMITTED" };
281
359
  },
282
360
  async requestJoin(circleId): Promise<MutationOutcome> {
@@ -289,7 +367,7 @@ export class FakeHost {
289
367
  list.push({ circleId, accountName: a.accountName, action: "join", requestedAt: host.now() });
290
368
  host.requests.set(toHex(circleId), list);
291
369
  if (!hadLive) {
292
- const owner = host.accountByIdentity(c.ownerIdentityId);
370
+ const owner = host.accountByAnyId(c.ownerIdentityId);
293
371
  if (owner) host.notifications.push({ kind: "joinRequest", to: owner.accountName, from: a.accountName, circleId: toHex(circleId) });
294
372
  }
295
373
  return { status: "COMMITTED" };
@@ -301,14 +379,14 @@ export class FakeHost {
301
379
  const list = host.requests.get(toHex(circleId)) ?? [];
302
380
  list.push({ circleId, accountName: a.accountName, action: "leave", requestedAt: host.now() });
303
381
  host.requests.set(toHex(circleId), list);
304
- const owner = host.accountByIdentity(c.ownerIdentityId);
382
+ const owner = host.accountByAnyId(c.ownerIdentityId);
305
383
  if (owner) host.notifications.push({ kind: "leaveRequest", to: owner.accountName, from: a.accountName, circleId: toHex(circleId) });
306
384
  return { status: "COMMITTED" };
307
385
  },
308
386
  async readRequests(circleId) {
309
387
  const a = caller();
310
388
  const c = host.circles.get(toHex(circleId));
311
- if (!c || c.ownerIdentityId !== a.identityId) return [];
389
+ if (!c || c.ownerIdentityId !== a.accountId) return [];
312
390
  const cutoff = host.now() - 7n * 86_400_000n;
313
391
  return (host.requests.get(toHex(circleId)) ?? []).filter((r) => r.requestedAt > cutoff);
314
392
  },
@@ -332,9 +410,14 @@ export class FakeHost {
332
410
  host.resetSigner.key.secretKey
333
411
  );
334
412
  host.receipts.set(toHex(input.resetId), receipt);
335
- for (const c of host.circles.values()) if (c.ownerIdentityId === a.identityId) c.closed = true;
336
- const vh = parseVaultHeader(input.newVault);
413
+ for (const c of host.circles.values()) if (c.ownerIdentityId === a.accountId) c.closed = true;
414
+ const vh = input.newVault ? parseVaultHeader(input.newVault) : undefined;
415
+ // The whole chain is retired; the reset starts a new account root with an empty chain.
337
416
  a.previousIdentities.add(a.identityId);
417
+ a.previousIdentities.add(a.accountId);
418
+ for (const l of a.successions) a.previousIdentities.add(toHex(expectType<AccountSuccession>(parseDocument(l.wire), DocumentType.AccountSuccession).previousIdentityId));
419
+ a.successions = [];
420
+ a.rootCertificate = undefined;
338
421
  a.resetReceipt = receipt;
339
422
  a.retiredCertificates.add(a.certificateHash);
340
423
  a.accountKey = doc.accountKey;
@@ -343,10 +426,101 @@ export class FakeHost {
343
426
  a.certificateHash = toHex(doc.bodyHash);
344
427
  a.generation = doc.generation;
345
428
  a.deviceIds.add(toHex(doc.deviceId));
346
- a.vault = { wire: input.newVault, revision: vh.vaultRevision, hash: toHex(vh.hash) };
429
+ a.accountId = toHex(newIid);
430
+ a.vault = input.newVault && vh ? { wire: input.newVault, revision: vh.vaultRevision, hash: toHex(vh.hash) } : undefined;
347
431
  host.certificates.set(a.certificateHash, input.newCertificate);
348
- host.stream(toHex(deriveStreamId(newIid, doc.deviceId)), a.identityId, a.accountName);
432
+ host.stream(toHex(deriveStreamId(newIid, doc.deviceId)), a.accountId, a.accountName);
349
433
  return { status: "COMMITTED" as const, receipt };
434
+ },
435
+ async succeedAccount(input) {
436
+ // The succession id answers first: a device retrying after a lost response finds its
437
+ // proposal committed and its token spent, and must get the document, not UNAUTHORIZED.
438
+ const doc = expectType<AccountDevice>(verifyDocument(input.newCertificate), DocumentType.AccountDevice);
439
+ const certHash = toHex(doc.bodyHash);
440
+ const known = host.successionLog.get(toHex(input.successionId));
441
+ if (known) return known.certificateHash === certHash && known.accountName === input.accountName ? { status: "ALREADY_COMMITTED" as const, succession: known.wire } : { status: "UNAUTHORIZED" as const };
442
+ const a = host.accounts.get(input.accountName);
443
+ if (!a || doc.accountName !== a.accountName) return { status: "UNAUTHORIZED" as const };
444
+ // The authorization: issued for this proposal, unspent, unexpired.
445
+ const auth = host.authorizations.get(toHex(input.authorization));
446
+ if (!auth || auth.used || auth.expiresAt < host.now() || auth.accountName !== a.accountName || auth.successionId !== toHex(input.successionId) || auth.expectedCertificateHash !== toHex(input.expectedCertificateHash) || auth.certificateHash !== certHash) {
447
+ return { status: "UNAUTHORIZED" as const };
448
+ }
449
+ if (a.certificateHash !== toHex(input.expectedCertificateHash)) return { status: "CONFLICT" as const, current: a.certificate };
450
+ if (equalBytes(doc.accountKey, a.accountKey) || doc.generation !== a.generation + 1n || a.deviceIds.has(toHex(doc.deviceId))) return { status: "UNAUTHORIZED" as const };
451
+ if (!host.resetSigner) return { status: "PERMANENT_FAILURE" as const };
452
+ const newIid = identityId(doc.accountName, doc.accountKey);
453
+ const wire = buildSuccession(
454
+ {
455
+ accountName: a.accountName,
456
+ accountId: fromHex(a.accountId),
457
+ previousIdentityId: fromHex(a.identityId),
458
+ newIdentityId: newIid,
459
+ previousGeneration: a.generation,
460
+ newGeneration: doc.generation,
461
+ newCertificateHash: doc.bodyHash,
462
+ successionId: input.successionId,
463
+ issuedAt: host.now(),
464
+ authorityKeyId: host.resetSigner.keyId
465
+ },
466
+ host.resetSigner.key.secretKey
467
+ );
468
+ auth.used = true;
469
+ host.successionLog.set(toHex(input.successionId), { accountName: a.accountName, certificateHash: certHash, wire });
470
+ if (a.successions.length === 0) a.rootCertificate = a.certificate;
471
+ a.successions.push({ wire, certificate: input.newCertificate });
472
+ a.previousIdentities.add(a.identityId);
473
+ a.retiredCertificates.add(a.certificateHash);
474
+ a.accountKey = doc.accountKey;
475
+ a.identityId = toHex(newIid);
476
+ a.certificate = input.newCertificate;
477
+ a.certificateHash = certHash;
478
+ a.generation = doc.generation;
479
+ a.deviceIds.add(toHex(doc.deviceId));
480
+ if (input.newVault) {
481
+ const vh = parseVaultHeader(input.newVault);
482
+ a.vault = { wire: input.newVault, revision: vh.vaultRevision, hash: toHex(vh.hash) };
483
+ }
484
+ host.certificates.set(certHash, input.newCertificate);
485
+ host.stream(toHex(deriveStreamId(newIid, doc.deviceId)), a.accountId, a.accountName);
486
+ return { status: "COMMITTED" as const, succession: wire };
487
+ },
488
+ async getSuccessions(accountId, afterGeneration, limit) {
489
+ host.successionCalls.push({ accountId: toHex(accountId), afterGeneration, limit });
490
+ const a = host.accountByAnyId(toHex(accountId));
491
+ if (!a || a.accountId !== toHex(accountId)) return { successions: [] };
492
+ return host.successionPage(a, afterGeneration, limit);
493
+ },
494
+ async listCircles(cursor) {
495
+ const a = caller();
496
+ if (host.failCircleListAt !== undefined && ++host.circleListCalls === host.failCircleListAt) {
497
+ host.failCircleListAt = undefined;
498
+ throw new Error("transport down");
499
+ }
500
+ if (!current()) return { circles: [] };
501
+ const mine: CircleListing[] = [];
502
+ for (const [key, c] of [...host.circles].sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0))) {
503
+ if (cursor !== undefined && key <= cursor) continue;
504
+ const snap = expectType<CircleSnapshot>(parseDocument(c.snapshot), DocumentType.CircleSnapshot);
505
+ const pending = (host.requests.get(key) ?? []).some((r) => r.accountName === a.accountName && r.action === "join");
506
+ const role = c.ownerIdentityId === a.accountId ? "owner" : snap.members.some((m) => toHex(m) === a.accountId) ? "member" : pending ? "pending" : undefined;
507
+ if (!role) continue;
508
+ const pref = host.preferences.get(`${a.accountId}:${key}`);
509
+ mine.push({ circleId: fromHex(key), ownerAccountId: fromHex(c.ownerIdentityId), role, snapshot: c.closed ? undefined : c.snapshot, enabled: pref?.enabled, preferenceRevision: pref?.revision ?? 0n });
510
+ }
511
+ const page = mine.slice(0, Math.max(1, host.circlePageSize));
512
+ const last = page.at(-1);
513
+ return mine.length > page.length && last ? { circles: page, cursor: toHex(last.circleId) } : { circles: page };
514
+ },
515
+ async setSharingPreference(circleId, enabled, expectedRevision) {
516
+ const a = caller();
517
+ if (!current()) return { status: "UNAUTHORIZED" as const, revision: 0n };
518
+ const key = `${a.accountId}:${toHex(circleId)}`;
519
+ const cur = host.preferences.get(key) ?? { enabled: false, revision: 0n };
520
+ if (cur.revision !== expectedRevision) return { status: "CONFLICT" as const, revision: cur.revision, enabled: cur.enabled };
521
+ const next = { enabled, revision: cur.revision + 1n };
522
+ host.preferences.set(key, next);
523
+ return { status: "COMMITTED" as const, revision: next.revision };
350
524
  }
351
525
  };
352
526
 
@@ -361,12 +535,12 @@ export class FakeHost {
361
535
  const s = host.streams.get(toHex(streamId));
362
536
  const e = s?.epochs.get(epoch);
363
537
  if (!e) return undefined;
364
- return { manifest: e.manifest, wrap: e.wraps.get(caller().identityId) };
538
+ return { manifest: e.manifest, wrap: e.wraps.get(caller().accountId) };
365
539
  },
366
540
  async publishEpoch({ streamId, expectedEpoch, manifest, wraps }) {
367
541
  const a = caller();
368
542
  const s = host.streams.get(toHex(streamId));
369
- if (!s || s.authorIdentityId !== a.identityId) return { status: "UNAUTHORIZED" as const };
543
+ if (!s || s.authorIdentityId !== a.accountId) return { status: "UNAUTHORIZED" as const };
370
544
  if (!current()) return { status: "UNAUTHORIZED" as const };
371
545
  const doc = expectType<EpochManifest>(parseDocument(manifest), DocumentType.EpochManifest);
372
546
  const cur = s.epochs.get(s.currentEpoch);
@@ -379,7 +553,7 @@ export class FakeHost {
379
553
  s.currentEpoch = doc.epoch;
380
554
  s.currentManifestHash = toHex(doc.bodyHash);
381
555
  for (const r of rows) {
382
- if (r.identityId === a.identityId) continue;
556
+ if (r.identityId === a.accountId) continue;
383
557
  if (!s.subscriptions.has(r.certificateHash)) s.subscriptions.set(r.certificateHash, { identityId: r.identityId, cursor: s.tail, startSeq: s.tail });
384
558
  }
385
559
  host.purgeEpochs(s);
@@ -392,7 +566,7 @@ export class FakeHost {
392
566
  const a = caller();
393
567
  const rec = expectType<EncryptedRecord>(parseDocument(wire), DocumentType.EncryptedRecord);
394
568
  const s = host.streams.get(toHex(rec.streamId));
395
- if (!s || s.authorIdentityId !== a.identityId) return { status: "UNAUTHORIZED" };
569
+ if (!s || s.authorIdentityId !== a.accountId) return { status: "UNAUTHORIZED" };
396
570
  if (!current()) return { status: "SUPERSEDED_DEVICE" };
397
571
  const id = toHex(recordId);
398
572
  const hash = toHex(bodyHash(wire)); // exact bytes, signature included
@@ -410,14 +584,14 @@ export class FakeHost {
410
584
  const seq = ++s.tail;
411
585
  const acceptedAt = host.now() > s.lastAcceptedAt ? host.now() : s.lastAcceptedAt;
412
586
  s.lastAcceptedAt = acceptedAt;
413
- const required = new Set(epoch.rows.filter((r) => r.identityId !== a.identityId).map((r) => r.certificateHash));
587
+ const required = new Set(epoch.rows.filter((r) => r.identityId !== a.accountId).map((r) => r.certificateHash));
414
588
  s.records.set(seq, { recordId: id, wire, acceptedAt, epoch: rec.epoch, counter: rec.counter, kind: rec.headerClass, required });
415
589
  s.highWater = { epoch: rec.epoch, counter: rec.counter };
416
590
  const outcome: AppendOutcome = { status: "ACCEPTED", deliverySeq: seq };
417
591
  s.lastAppend = { recordId: id, hash, seq, at: acceptedAt, outcome };
418
592
  for (const r of epoch.rows) {
419
- if (r.identityId === a.identityId) continue;
420
- const to = host.accountByIdentity(r.identityId)?.accountName ?? r.identityId;
593
+ if (r.identityId === a.accountId) continue;
594
+ const to = host.accountByAnyId(r.identityId)?.accountName ?? r.identityId;
421
595
  // By header class, as the test profile declares them: 1 is a visible notification, 2 is
422
596
  // silent and needs no wake-up, anything else is a wake-up. The host never sees the kind.
423
597
  if (rec.headerClass !== 2) host.notifications.push({ kind: rec.headerClass === 1 ? "visible" : "wake", to, from: a.accountName });
@@ -437,6 +611,8 @@ export class FakeHost {
437
611
  },
438
612
  async readRecords(streamId, afterSeq, limit): Promise<ReadRecordsResult> {
439
613
  const s = host.streams.get(toHex(streamId));
614
+ // The fence: a retired device reads nothing more (amendment A1, host rules).
615
+ if (!host.isCurrentDevice(accountName, callerCert)) throw new Error("SUPERSEDED_DEVICE");
440
616
  if (!s) return { records: [], nextSeq: afterSeq, tail: 0n, expiredThrough: 0n, committedCursor: 0n };
441
617
  if (afterSeq > s.tail) throw new Error("CURSOR_OUT_OF_RANGE");
442
618
  const sub = s.subscriptions.get(callerCert);
@@ -8,7 +8,8 @@
8
8
  import { equalBytes, toHex } from "../bytes.js";
9
9
  import { KavachError } from "../errors.js";
10
10
  import { invokeSync, type Store, type Tx } from "../store.js";
11
- import type { AppendOutcome, Directory, EpochBundle, MutationOutcome, ReadRecordsResult, Relay, Transport } from "../transport.js";
11
+ import type {
12
+ ResolvedAccount, AppendOutcome, Directory, EpochBundle, MutationOutcome, ReadRecordsResult, Relay, Transport } from "../transport.js";
12
13
 
13
14
  // Crashes ------------------------------------------------------------------------------------------
14
15
 
@@ -99,9 +100,9 @@ export class FaultyStore implements Store {
99
100
 
100
101
  // The malicious transport ----------------------------------------------------------------------------
101
102
 
102
- export type MutationName = "registerAccount" | "activateDevice" | "publishVault" | "publishOwnedCircle" | "requestJoin" | "requestLeave" | "resetAccount" | "publishEpoch" | "appendRecord" | "ackThrough";
103
+ export type MutationName = "registerAccount" | "activateDevice" | "publishVault" | "publishOwnedCircle" | "requestJoin" | "requestLeave" | "resetAccount" | "succeedAccount" | "setSharingPreference" | "publishEpoch" | "appendRecord" | "ackThrough";
103
104
 
104
- export type ServedKind = "certificate" | "snapshot" | "vault" | "manifest" | "wrap" | "record" | "keyList" | "receipt";
105
+ export type ServedKind = "certificate" | "snapshot" | "vault" | "manifest" | "wrap" | "record" | "keyList" | "receipt" | "succession";
105
106
 
106
107
  export interface MaliciousKnobs {
107
108
  /** Mutations whose next response is lost after the host committed it (each name is consumed once). */
@@ -111,7 +112,7 @@ export interface MaliciousKnobs {
111
112
  /** Mutations whose next response waits for the given promise after the host committed (consumed once). */
112
113
  readonly hold: Map<MutationName, Promise<void>>;
113
114
  /** Reads that answer "nothing" while present. */
114
- readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates">;
115
+ readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates" | "successions" | "circleList">;
115
116
  /** Older documents served instead of the current ones. */
116
117
  readonly replay: {
117
118
  vault?: Uint8Array;
@@ -197,6 +198,13 @@ export class MaliciousTransport implements Transport {
197
198
  return result;
198
199
  }
199
200
 
201
+ /** A resolved account as served: a forked or tampered certificate, and a tampered chain, where the knobs say so. */
202
+ private servedAccount(r: ResolvedAccount): ResolvedAccount {
203
+ const certificate = this.knobs.fork.certificates.get(r.accountName) ?? this.serve("certificate", r.certificate) ?? r.certificate;
204
+ const successions = r.successions?.map((l) => ({ ...l, wire: this.serve("succession", l.wire) ?? l.wire }));
205
+ return { ...r, certificate, ...(successions ? { successions } : {}) };
206
+ }
207
+
200
208
  private wrapDirectory(): Directory {
201
209
  // The inner transport may rebind itself per call (a device's certificate changes at
202
210
  // activation), so it is read on every call, never captured.
@@ -207,13 +215,13 @@ export class MaliciousTransport implements Transport {
207
215
  this.calls.push("resolveAccounts");
208
216
  if (k.withhold.has("certificates")) return [];
209
217
  const rs = await d().resolveAccounts(names);
210
- return rs.map((r) => ({ ...r, certificate: k.fork.certificates.get(r.accountName) ?? this.serve("certificate", r.certificate) ?? r.certificate }));
218
+ return rs.map((r) => this.servedAccount(r));
211
219
  },
212
220
  resolveIdentities: async (ids) => {
213
221
  this.calls.push("resolveIdentities");
214
222
  if (k.withhold.has("certificates")) return [];
215
223
  const rs = await d().resolveIdentities(ids);
216
- return rs.map((r) => ({ ...r, certificate: k.fork.certificates.get(r.accountName) ?? this.serve("certificate", r.certificate) ?? r.certificate }));
224
+ return rs.map((r) => this.servedAccount(r));
217
225
  },
218
226
  getCertificate: async (hash) => {
219
227
  this.calls.push("getCertificate");
@@ -248,7 +256,20 @@ export class MaliciousTransport implements Transport {
248
256
  this.calls.push("getResetReceipt");
249
257
  return this.serve("receipt", await d().getResetReceipt(id));
250
258
  },
251
- resetAccount: (input) => this.mutate("resetAccount", () => d().resetAccount(input))
259
+ resetAccount: (input) => this.mutate("resetAccount", () => d().resetAccount(input)),
260
+ succeedAccount: (input) => this.mutate("succeedAccount", () => d().succeedAccount(input)),
261
+ getSuccessions: async (accountId, after, limit) => {
262
+ this.calls.push("getSuccessions");
263
+ if (k.withhold.has("successions")) return { successions: [] };
264
+ const page = await d().getSuccessions(accountId, after, limit);
265
+ return { ...page, successions: page.successions.map((l) => ({ ...l, wire: this.serve("succession", l.wire) ?? l.wire })) };
266
+ },
267
+ listCircles: async (cursor) => {
268
+ this.calls.push("listCircles");
269
+ if (k.withhold.has("circleList")) throw new KavachError("NETWORK", "transport", "network unreachable");
270
+ return d().listCircles(cursor);
271
+ },
272
+ setSharingPreference: (circleId, enabled, expected) => this.mutate("setSharingPreference", () => d().setSharingPreference(circleId, enabled, expected))
252
273
  };
253
274
  }
254
275
 
@@ -3,7 +3,7 @@
3
3
  * live with the outbox and the inbox. Each function takes an open `Tx` so callers can compose
4
4
  * them; nothing here opens the network.
5
5
  */
6
- import { equalBytes, toHex } from "./bytes.js";
6
+ import { equalBytes, fromHex, toHex } from "./bytes.js";
7
7
  import { applyAudienceEvent, initialRevisionState, type AudienceEvent } from "./audience.js";
8
8
  import { DocumentType, expectType, parseDocument, type AccountDevice } from "./codec.js";
9
9
  import { identityId } from "./crypto.js";
@@ -25,7 +25,9 @@ export const VAULT = "vault";
25
25
  export type OpenedState =
26
26
  | { readonly kind: "fresh" }
27
27
  | { readonly kind: "initializing"; readonly account: OwnAccount }
28
- | { readonly kind: StoreState; readonly meta: MetaRow; readonly account: OwnAccount };
28
+ | { readonly kind: StoreState; readonly meta: MetaRow; readonly account: OwnAccount }
29
+ /** A device retired by a succession erased its account seed (amendment A1, section 4): the header stays, the key is gone. */
30
+ | { readonly kind: "RETIRED"; readonly meta: MetaRow; readonly account: undefined };
29
31
 
30
32
  /**
31
33
  * Bind the secret store to the local store. A mismatch between the two is `RECOVERY_REQUIRED`
@@ -36,10 +38,11 @@ export async function openStore(store: Store, secrets: SecretStore, accountName:
36
38
  const [accountSeed, instanceId] = await Promise.all([secrets.get("accountSeed"), secrets.get("instanceId")]);
37
39
  const meta = await store.read((tx) => tx.get("meta", META));
38
40
  if (!accountSeed || !instanceId) {
41
+ if (meta?.state === "RETIRED") return { kind: "RETIRED", meta, account: undefined };
39
42
  if (meta) throw new KavachError("RECOVERY_REQUIRED", stage, "store present but secrets missing");
40
43
  return { kind: "fresh" };
41
44
  }
42
- const account = openAccount(accountName, accountSeed);
45
+ const account = openAccount(accountName, accountSeed, meta?.accountId ? fromHex(meta.accountId) : undefined);
43
46
  if (!meta) return { kind: "initializing", account };
44
47
  if (meta.instanceId !== toHex(instanceId) || meta.identityId !== toHex(account.identityId) || meta.accountName !== accountName || meta.schema !== 1) {
45
48
  throw new KavachError("RECOVERY_REQUIRED", stage, "store header does not match the secret store");
@@ -61,7 +64,7 @@ export async function t1Secrets(secrets: SecretStore, roots: { accountSeed: Uint
61
64
  /** T1b: the store header with the INITIALIZING marker, the own pin and the stream state. */
62
65
  export function t1Header(tx: Tx, input: { account: OwnAccount; device: DeviceKeys; certificate: Uint8Array; instanceId: Uint8Array; generation: bigint }): void {
63
66
  const doc = expectType<AccountDevice>(parseDocument(input.certificate), DocumentType.AccountDevice, "t1");
64
- const pin = pinFromCertificate(doc);
67
+ const pin = pinFromCertificate(doc, undefined, input.account.accountId);
65
68
  if (!equalBytes(pin.identityId, input.account.identityId)) throw new KavachError("MALFORMED", "t1", "certificate is not for this account");
66
69
  tx.put("meta", META, {
67
70
  schema: 1,
@@ -70,6 +73,7 @@ export function t1Header(tx: Tx, input: { account: OwnAccount; device: DeviceKey
70
73
  instanceId: toHex(input.instanceId),
71
74
  accountName: input.account.accountName,
72
75
  identityId: toHex(input.account.identityId),
76
+ accountId: toHex(input.account.accountId),
73
77
  certificateHash: toHex(pin.certificateHash),
74
78
  state: "INITIALIZING"
75
79
  });
@@ -80,9 +84,12 @@ export function t1Header(tx: Tx, input: { account: OwnAccount; device: DeviceKey
80
84
  certificateHash: toHex(pin.certificateHash),
81
85
  streamId: toHex(pin.streamId)
82
86
  });
83
- tx.put("pins", toHex(pin.identityId), pinToRow(pin));
87
+ tx.put("pins", toHex(pin.accountId), pinToRow(pin));
88
+ if (!tx.get("identities", toHex(pin.identityId))) {
89
+ tx.put("identities", toHex(pin.identityId), { accountId: toHex(pin.accountId), accountName: pin.accountName, accountKey: toHex(pin.accountKey), sinceGeneration: big(input.generation), retired: false });
90
+ }
84
91
  tx.put("certificates", toHex(pin.certificateHash), {
85
- identityId: toHex(pin.identityId),
92
+ identityId: toHex(pin.accountId),
86
93
  certificateHash: toHex(pin.certificateHash),
87
94
  signingKey: toHex(pin.signingKey),
88
95
  boxKey: toHex(pin.boxKey),
package/src/transport.ts CHANGED
@@ -19,6 +19,52 @@ export interface ResolvedAccount {
19
19
  readonly certificate: Uint8Array;
20
20
  /** When the account's key changed through a reset: the receipt that proves it. */
21
21
  readonly resetReceipt?: Uint8Array | undefined;
22
+ /** When the account's key changed through successions (revision 9): the first page of the chain from the root, each link with the successor's certificate, oldest first. */
23
+ readonly successions?: readonly SuccessionLink[] | undefined;
24
+ /** Set when `successions` is not the whole chain: the generation to continue from with `getSuccessions`. */
25
+ readonly successionsNext?: bigint | undefined;
26
+ /** With a non-empty chain: a certificate under the root key (the one the first succession replaced), so a peer can verify what that key signed. */
27
+ readonly rootCertificate?: Uint8Array | undefined;
28
+ }
29
+
30
+ /** One link of an account's succession chain: the type 9 document and the certificate it binds. */
31
+ export interface SuccessionLink {
32
+ readonly wire: Uint8Array;
33
+ readonly certificate: Uint8Array;
34
+ }
35
+
36
+ export interface SuccessionPage {
37
+ readonly successions: readonly SuccessionLink[];
38
+ /** On the first page of a non-empty chain: a certificate under the root key. */
39
+ readonly rootCertificate?: Uint8Array | undefined;
40
+ /** The generation to ask after for the next page; absent when the chain ends here. */
41
+ readonly nextGeneration?: bigint | undefined;
42
+ }
43
+
44
+ /** One circle of the authenticated account as the host lists it for bootstrap (amendment A1, section 5). Host-trusted. */
45
+ export interface CircleListing {
46
+ readonly circleId: Uint8Array;
47
+ readonly ownerAccountId: Uint8Array;
48
+ readonly role: "owner" | "member" | "pending";
49
+ /** The current snapshot; absent for a closed circle. */
50
+ readonly snapshot: Uint8Array | undefined;
51
+ /** The account's sharing preference the host holds; absent when none was ever set (disabled). */
52
+ readonly enabled: boolean | undefined;
53
+ readonly preferenceRevision: bigint;
54
+ }
55
+
56
+ export interface CirclePage {
57
+ readonly circles: readonly CircleListing[];
58
+ /** The cursor for the next page; absent on the last page. */
59
+ readonly cursor?: string | undefined;
60
+ }
61
+
62
+ export interface PreferenceOutcome {
63
+ readonly status: "COMMITTED" | "ALREADY_COMMITTED" | "CONFLICT" | "UNAUTHORIZED" | "RETRYABLE";
64
+ /** The revision the host now holds (the new one on success, the current one on conflict). */
65
+ readonly revision: bigint;
66
+ /** On CONFLICT: the value the host holds at that revision. */
67
+ readonly enabled?: boolean | undefined;
22
68
  }
23
69
 
24
70
  export interface JoinRequest {
@@ -41,7 +87,8 @@ export interface Directory {
41
87
  newCertificate: Uint8Array;
42
88
  newVault: Uint8Array;
43
89
  }): Promise<MutationOutcome>;
44
- registerAccount(input: { certificate: Uint8Array; vault: Uint8Array }): Promise<MutationOutcome>;
90
+ /** `vault` is absent for an account on a recovery-only profile without a recovery provider. */
91
+ registerAccount(input: { certificate: Uint8Array; vault: Uint8Array | undefined }): Promise<MutationOutcome>;
45
92
  getVault(accountName: string): Promise<Uint8Array | undefined>;
46
93
  publishVault(input: { expectedRevision: bigint; expectedHash: Uint8Array | undefined; newVault: Uint8Array }): Promise<MutationOutcome>;
47
94
  getCircle(circleId: Uint8Array): Promise<Uint8Array | undefined>;
@@ -55,10 +102,30 @@ export interface Directory {
55
102
  accountName: string;
56
103
  expectedOldIdentityId: Uint8Array;
57
104
  newCertificate: Uint8Array;
58
- newVault: Uint8Array;
105
+ newVault: Uint8Array | undefined;
59
106
  resetId: Uint8Array;
60
107
  reauthProof: Uint8Array;
61
108
  }): Promise<MutationOutcome & { receipt?: Uint8Array | undefined }>;
109
+ /**
110
+ * Account recovery (amendment A1): commit a prepared replacement. `ALREADY_COMMITTED` with the
111
+ * original document for a repeated `successionId` with identical bytes is answered before any
112
+ * authorization check; `CONFLICT` carries the current certificate; `UNAUTHORIZED` covers a
113
+ * missing, expired or mismatched authorization and a known id with different bytes.
114
+ */
115
+ succeedAccount(input: {
116
+ accountName: string;
117
+ expectedCertificateHash: Uint8Array;
118
+ newCertificate: Uint8Array;
119
+ newVault: Uint8Array | undefined;
120
+ successionId: Uint8Array;
121
+ authorization: Uint8Array;
122
+ }): Promise<MutationOutcome & { succession?: Uint8Array | undefined }>;
123
+ /** One page of an account's succession chain, in generation order, links whose new generation exceeds `afterGeneration`. */
124
+ getSuccessions(accountId: Uint8Array, afterGeneration: bigint, limit: number): Promise<SuccessionPage>;
125
+ /** One page of the authenticated account's circles, for bootstrap without a vault. */
126
+ listCircles(cursor: string | undefined): Promise<CirclePage>;
127
+ /** Compare-and-swap on the per-circle preference revision the host keeps for the authenticated account. */
128
+ setSharingPreference(circleId: Uint8Array, enabled: boolean, expectedRevision: bigint): Promise<PreferenceOutcome>;
62
129
  }
63
130
 
64
131
  export interface EpochBundle {