@plane31/blind-relay 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/adapters/index.d.ts +4 -3
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +2 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/audience.d.ts +1 -1
- package/dist/audience.d.ts.map +1 -1
- package/dist/audience.js +7 -6
- package/dist/audience.js.map +1 -1
- package/dist/client.d.ts +159 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1112 -102
- package/dist/client.js.map +1 -1
- package/dist/codec.d.ts +25 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +44 -0
- package/dist/codec.js.map +1 -1
- package/dist/crypto/noble.d.ts.map +1 -1
- package/dist/crypto/noble.js +19 -1
- package/dist/crypto/noble.js.map +1 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js.map +1 -1
- package/dist/errors.d.ts +9 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -1
- package/dist/errors.js.map +1 -1
- package/dist/identity.d.ts +8 -2
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +7 -3
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/policy.d.ts +13 -0
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +12 -1
- package/dist/policy.js.map +1 -1
- package/dist/rows.d.ts.map +1 -1
- package/dist/rows.js +2 -0
- package/dist/rows.js.map +1 -1
- package/dist/store.d.ts +116 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2 -0
- package/dist/store.js.map +1 -1
- package/dist/succession.d.ts +108 -0
- package/dist/succession.d.ts.map +1 -0
- package/dist/succession.js +205 -0
- package/dist/succession.js.map +1 -0
- package/dist/testing/clock.d.ts.map +1 -1
- package/dist/testing/clock.js +1 -0
- package/dist/testing/clock.js.map +1 -1
- package/dist/testing/fakeHost.d.ts +60 -1
- package/dist/testing/fakeHost.d.ts.map +1 -1
- package/dist/testing/fakeHost.js +201 -31
- package/dist/testing/fakeHost.js.map +1 -1
- package/dist/testing/faults.d.ts +7 -3
- package/dist/testing/faults.d.ts.map +1 -1
- package/dist/testing/faults.js +27 -3
- package/dist/testing/faults.js.map +1 -1
- package/dist/transactions.d.ts +6 -0
- package/dist/transactions.d.ts.map +1 -1
- package/dist/transactions.js +11 -5
- package/dist/transactions.js.map +1 -1
- package/dist/transport.d.ts +66 -2
- package/dist/transport.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/index.ts +14 -0
- package/src/audience.ts +7 -6
- package/src/client.ts +1165 -108
- package/src/codec.ts +64 -1
- package/src/crypto/noble.ts +18 -1
- package/src/crypto.ts +1 -0
- package/src/errors.ts +13 -1
- package/src/identity.ts +13 -3
- package/src/index.ts +10 -3
- package/src/policy.ts +23 -1
- package/src/rows.ts +2 -0
- package/src/store.ts +113 -3
- package/src/succession.ts +270 -0
- package/src/testing/clock.ts +1 -0
- package/src/testing/fakeHost.ts +204 -28
- package/src/testing/faults.ts +32 -7
- package/src/transactions.ts +13 -6
- package/src/transport.ts +69 -2
package/src/testing/fakeHost.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
192
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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)?.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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().
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
420
|
-
const to = host.
|
|
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);
|
package/src/testing/faults.ts
CHANGED
|
@@ -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 {
|
|
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). */
|
|
@@ -110,8 +111,10 @@ export interface MaliciousKnobs {
|
|
|
110
111
|
readonly refuse: Set<MutationName>;
|
|
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>>;
|
|
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;
|
|
113
116
|
/** Reads that answer "nothing" while present. */
|
|
114
|
-
readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates">;
|
|
117
|
+
readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates" | "successions" | "circleList" | "subscriptions">;
|
|
115
118
|
/** Older documents served instead of the current ones. */
|
|
116
119
|
readonly replay: {
|
|
117
120
|
vault?: Uint8Array;
|
|
@@ -187,6 +190,7 @@ export class MaliciousTransport implements Transport {
|
|
|
187
190
|
const gate = this.knobs.hold.get(name);
|
|
188
191
|
if (gate) {
|
|
189
192
|
this.knobs.hold.delete(name);
|
|
193
|
+
this.knobs.onHeld?.(name);
|
|
190
194
|
await gate;
|
|
191
195
|
}
|
|
192
196
|
if (this.knobs.loseResponseOf.has(name)) {
|
|
@@ -197,6 +201,13 @@ export class MaliciousTransport implements Transport {
|
|
|
197
201
|
return result;
|
|
198
202
|
}
|
|
199
203
|
|
|
204
|
+
/** A resolved account as served: a forked or tampered certificate, and a tampered chain, where the knobs say so. */
|
|
205
|
+
private servedAccount(r: ResolvedAccount): ResolvedAccount {
|
|
206
|
+
const certificate = this.knobs.fork.certificates.get(r.accountName) ?? this.serve("certificate", r.certificate) ?? r.certificate;
|
|
207
|
+
const successions = r.successions?.map((l) => ({ ...l, wire: this.serve("succession", l.wire) ?? l.wire }));
|
|
208
|
+
return { ...r, certificate, ...(successions ? { successions } : {}) };
|
|
209
|
+
}
|
|
210
|
+
|
|
200
211
|
private wrapDirectory(): Directory {
|
|
201
212
|
// The inner transport may rebind itself per call (a device's certificate changes at
|
|
202
213
|
// activation), so it is read on every call, never captured.
|
|
@@ -207,13 +218,13 @@ export class MaliciousTransport implements Transport {
|
|
|
207
218
|
this.calls.push("resolveAccounts");
|
|
208
219
|
if (k.withhold.has("certificates")) return [];
|
|
209
220
|
const rs = await d().resolveAccounts(names);
|
|
210
|
-
return rs.map((r) =>
|
|
221
|
+
return rs.map((r) => this.servedAccount(r));
|
|
211
222
|
},
|
|
212
223
|
resolveIdentities: async (ids) => {
|
|
213
224
|
this.calls.push("resolveIdentities");
|
|
214
225
|
if (k.withhold.has("certificates")) return [];
|
|
215
226
|
const rs = await d().resolveIdentities(ids);
|
|
216
|
-
return rs.map((r) =>
|
|
227
|
+
return rs.map((r) => this.servedAccount(r));
|
|
217
228
|
},
|
|
218
229
|
getCertificate: async (hash) => {
|
|
219
230
|
this.calls.push("getCertificate");
|
|
@@ -248,7 +259,20 @@ export class MaliciousTransport implements Transport {
|
|
|
248
259
|
this.calls.push("getResetReceipt");
|
|
249
260
|
return this.serve("receipt", await d().getResetReceipt(id));
|
|
250
261
|
},
|
|
251
|
-
resetAccount: (input) => this.mutate("resetAccount", () => d().resetAccount(input))
|
|
262
|
+
resetAccount: (input) => this.mutate("resetAccount", () => d().resetAccount(input)),
|
|
263
|
+
succeedAccount: (input) => this.mutate("succeedAccount", () => d().succeedAccount(input)),
|
|
264
|
+
getSuccessions: async (accountId, after, limit) => {
|
|
265
|
+
this.calls.push("getSuccessions");
|
|
266
|
+
if (k.withhold.has("successions")) return { successions: [] };
|
|
267
|
+
const page = await d().getSuccessions(accountId, after, limit);
|
|
268
|
+
return { ...page, successions: page.successions.map((l) => ({ ...l, wire: this.serve("succession", l.wire) ?? l.wire })) };
|
|
269
|
+
},
|
|
270
|
+
listCircles: async (cursor) => {
|
|
271
|
+
this.calls.push("listCircles");
|
|
272
|
+
if (k.withhold.has("circleList")) throw new KavachError("NETWORK", "transport", "network unreachable");
|
|
273
|
+
return d().listCircles(cursor);
|
|
274
|
+
},
|
|
275
|
+
setSharingPreference: (circleId, enabled, expected) => this.mutate("setSharingPreference", () => d().setSharingPreference(circleId, enabled, expected))
|
|
252
276
|
};
|
|
253
277
|
}
|
|
254
278
|
|
|
@@ -307,6 +331,7 @@ export class MaliciousTransport implements Transport {
|
|
|
307
331
|
ackThrough: (streamId, cursor) => this.mutate("ackThrough", () => r().ackThrough(streamId, cursor)),
|
|
308
332
|
listSubscriptions: async () => {
|
|
309
333
|
this.calls.push("listSubscriptions");
|
|
334
|
+
if (k.withhold.has("subscriptions")) throw new KavachError("NETWORK", "transport", "network unreachable");
|
|
310
335
|
return r().listSubscriptions();
|
|
311
336
|
}
|
|
312
337
|
};
|
package/src/transactions.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
92
|
+
identityId: toHex(pin.accountId),
|
|
86
93
|
certificateHash: toHex(pin.certificateHash),
|
|
87
94
|
signingKey: toHex(pin.signingKey),
|
|
88
95
|
boxKey: toHex(pin.boxKey),
|