@plane31/blind-relay 0.1.1 → 0.1.3
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/crypto/sodium.d.ts +5 -3
- package/dist/crypto/sodium.d.ts.map +1 -1
- package/dist/crypto/sodium.js +13 -1
- package/dist/crypto/sodium.js.map +1 -1
- package/package.json +3 -2
- package/src/adapters/index.ts +102 -0
- package/src/audience.ts +123 -0
- package/src/bytes.ts +209 -0
- package/src/circle.ts +127 -0
- package/src/client.ts +1442 -0
- package/src/codec.ts +642 -0
- package/src/crypto/backend.ts +48 -0
- package/src/crypto/noble.ts +104 -0
- package/src/crypto/node-module.d.ts +11 -0
- package/src/crypto/sodium.ts +53 -0
- package/src/crypto.ts +266 -0
- package/src/epoch.ts +201 -0
- package/src/errors.ts +78 -0
- package/src/historyIndex.ts +61 -0
- package/src/identity.ts +158 -0
- package/src/inbox.ts +560 -0
- package/src/index.ts +59 -0
- package/src/outbox.ts +385 -0
- package/src/policy.ts +88 -0
- package/src/record.ts +106 -0
- package/src/reset.ts +176 -0
- package/src/rows.ts +116 -0
- package/src/slots.ts +55 -0
- package/src/store.ts +557 -0
- package/src/testing/clock.ts +29 -0
- package/src/testing/fakeHost.ts +482 -0
- package/src/testing/faults.ts +321 -0
- package/src/testing/fixtures.ts +35 -0
- package/src/testing/index.ts +6 -0
- package/src/testing/providers.ts +39 -0
- package/src/testing/sweep.ts +407 -0
- package/src/transactions.ts +231 -0
- package/src/transport.ts +106 -0
- package/src/vault.ts +126 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory host: the directory and the cumulative-cursor relay of protocol sections 6, 10
|
|
3
|
+
* and 13, bound per caller device. It is the behavioural reference for the Worker in
|
|
4
|
+
* examples/host-worker and the test double for the client. Not a production server, and
|
|
5
|
+
* exported under `@plane31/blind-relay/testing` only.
|
|
6
|
+
*/
|
|
7
|
+
import { equalBytes, fromHex, toHex } from "../bytes.js";
|
|
8
|
+
import {
|
|
9
|
+
DocumentType,
|
|
10
|
+
buildResetReceipt,
|
|
11
|
+
expectType,
|
|
12
|
+
parseDocument,
|
|
13
|
+
parseVaultHeader,
|
|
14
|
+
verifyDocument,
|
|
15
|
+
type AccountDevice,
|
|
16
|
+
type CircleSnapshot,
|
|
17
|
+
type EncryptedRecord,
|
|
18
|
+
type EpochManifest,
|
|
19
|
+
type RecordKind
|
|
20
|
+
} from "../codec.js";
|
|
21
|
+
import { bodyHash, identityId, streamId as deriveStreamId, type SigningKeypair } from "../crypto.js";
|
|
22
|
+
import type {
|
|
23
|
+
AppendOutcome,
|
|
24
|
+
Directory,
|
|
25
|
+
EpochBundle,
|
|
26
|
+
JoinRequest,
|
|
27
|
+
MutationOutcome,
|
|
28
|
+
ReadRecordsResult,
|
|
29
|
+
Relay,
|
|
30
|
+
ResolvedAccount,
|
|
31
|
+
Transport
|
|
32
|
+
} from "../transport.js";
|
|
33
|
+
|
|
34
|
+
interface Account {
|
|
35
|
+
accountName: string;
|
|
36
|
+
accountKey: Uint8Array;
|
|
37
|
+
identityId: string;
|
|
38
|
+
certificate: Uint8Array;
|
|
39
|
+
certificateHash: string;
|
|
40
|
+
generation: bigint;
|
|
41
|
+
deviceIds: Set<string>;
|
|
42
|
+
retiredCertificates: Set<string>;
|
|
43
|
+
vault: { wire: Uint8Array; revision: bigint; hash: string } | undefined;
|
|
44
|
+
retiredIdentity: boolean;
|
|
45
|
+
previousIdentities: Set<string>;
|
|
46
|
+
resetReceipt: Uint8Array | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface StoredRecord {
|
|
50
|
+
recordId: string;
|
|
51
|
+
wire: Uint8Array;
|
|
52
|
+
acceptedAt: bigint;
|
|
53
|
+
epoch: number;
|
|
54
|
+
counter: number;
|
|
55
|
+
kind: RecordKind;
|
|
56
|
+
required: Set<string>; // reader certificate hashes
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface StreamState {
|
|
60
|
+
authorIdentityId: string;
|
|
61
|
+
accountName: string;
|
|
62
|
+
currentEpoch: number;
|
|
63
|
+
currentManifestHash: string | undefined;
|
|
64
|
+
epochs: Map<number, { manifest: Uint8Array; wraps: Map<string, Uint8Array>; rows: { identityId: string; certificateHash: string }[] }>;
|
|
65
|
+
records: Map<bigint, StoredRecord>;
|
|
66
|
+
tail: bigint;
|
|
67
|
+
expiredThrough: bigint;
|
|
68
|
+
lastAcceptedAt: bigint;
|
|
69
|
+
highWater: { epoch: number; counter: number } | undefined;
|
|
70
|
+
lastAppend: { recordId: string; hash: string; seq: bigint; at: bigint; outcome: AppendOutcome } | undefined;
|
|
71
|
+
subscriptions: Map<string, { identityId: string; cursor: bigint; startSeq: bigint }>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Notification {
|
|
75
|
+
kind: "joinRequest" | "leaveRequest" | "visible" | "wake";
|
|
76
|
+
to: string;
|
|
77
|
+
from: string;
|
|
78
|
+
circleId?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class FakeHost {
|
|
82
|
+
now: () => bigint = () => 0n;
|
|
83
|
+
deliveryWindowMs = 86_400_000n;
|
|
84
|
+
appendRetryWindowMs = 604_800_000n;
|
|
85
|
+
readonly accounts = new Map<string, Account>();
|
|
86
|
+
readonly certificates = new Map<string, Uint8Array>();
|
|
87
|
+
readonly circles = new Map<string, { snapshot: Uint8Array; revision: bigint; hash: string; ownerIdentityId: string; closed: boolean }>();
|
|
88
|
+
readonly requests = new Map<string, JoinRequest[]>();
|
|
89
|
+
readonly streams = new Map<string, StreamState>();
|
|
90
|
+
readonly notifications: Notification[] = [];
|
|
91
|
+
readonly receipts = new Map<string, Uint8Array>();
|
|
92
|
+
resetKeyList: Uint8Array | undefined;
|
|
93
|
+
resetSigner: { key: SigningKeypair; keyId: Uint8Array } | undefined;
|
|
94
|
+
/** Test switches. */
|
|
95
|
+
failAppends = false;
|
|
96
|
+
dropAppendResponses = false;
|
|
97
|
+
withholdSnapshots = false;
|
|
98
|
+
withholdEpochs = false;
|
|
99
|
+
appendCalls = 0;
|
|
100
|
+
/** Every append the relay answered, in order: what the author's stream has been asked to accept. */
|
|
101
|
+
readonly appendLog: { streamId: string; recordId: string; hash: string; status: AppendOutcome["status"]; epoch: number; counter: number }[] = [];
|
|
102
|
+
|
|
103
|
+
// Helpers ---------------------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
accountByIdentity(iid: string): Account | undefined {
|
|
106
|
+
return [...this.accounts.values()].find((a) => a.identityId === iid);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private stream(streamHex: string, authorIdentityId: string, accountName: string): StreamState {
|
|
110
|
+
let s = this.streams.get(streamHex);
|
|
111
|
+
if (!s) {
|
|
112
|
+
s = {
|
|
113
|
+
authorIdentityId,
|
|
114
|
+
accountName,
|
|
115
|
+
currentEpoch: 0,
|
|
116
|
+
currentManifestHash: undefined,
|
|
117
|
+
epochs: new Map(),
|
|
118
|
+
records: new Map(),
|
|
119
|
+
tail: 0n,
|
|
120
|
+
expiredThrough: 0n,
|
|
121
|
+
lastAcceptedAt: 0n,
|
|
122
|
+
highWater: undefined,
|
|
123
|
+
lastAppend: undefined,
|
|
124
|
+
subscriptions: new Map()
|
|
125
|
+
};
|
|
126
|
+
this.streams.set(streamHex, s);
|
|
127
|
+
}
|
|
128
|
+
return s;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The expiry alarm: delete the expired prefix and advance the watermark. */
|
|
132
|
+
expire(): void {
|
|
133
|
+
const now = this.now();
|
|
134
|
+
for (const s of this.streams.values()) {
|
|
135
|
+
for (const [seq, r] of [...s.records]) if (r.acceptedAt + this.deliveryWindowMs <= now) s.records.delete(seq);
|
|
136
|
+
const retained = [...s.records.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
137
|
+
s.expiredThrough = retained.length ? (retained[0] ?? 0n) - 1n : s.tail;
|
|
138
|
+
this.purgeEpochs(s);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private purgeEpochs(s: StreamState): void {
|
|
143
|
+
const referenced = new Set([...s.records.values()].map((r) => r.epoch));
|
|
144
|
+
for (const e of [...s.epochs.keys()]) if (e !== s.currentEpoch && !referenced.has(e)) s.epochs.delete(e);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private collect(s: StreamState, upTo: bigint): void {
|
|
148
|
+
for (const [seq, r] of [...s.records]) {
|
|
149
|
+
if (seq > upTo) continue;
|
|
150
|
+
let done = true;
|
|
151
|
+
for (const cert of r.required) {
|
|
152
|
+
const sub = s.subscriptions.get(cert);
|
|
153
|
+
if (!sub || sub.cursor < seq) {
|
|
154
|
+
done = false;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (done) s.records.delete(seq);
|
|
159
|
+
}
|
|
160
|
+
this.purgeEpochs(s);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
retainedCount(streamId: Uint8Array): number {
|
|
164
|
+
return this.streams.get(toHex(streamId))?.records.size ?? 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Transport per caller ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
asDevice(accountName: string, certificateHash: Uint8Array): Transport {
|
|
170
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias -- the transport closures outlive this call
|
|
171
|
+
const host = this;
|
|
172
|
+
const callerCert = toHex(certificateHash);
|
|
173
|
+
const caller = (): Account => {
|
|
174
|
+
const a = host.accounts.get(accountName);
|
|
175
|
+
if (!a) throw new Error("unknown account");
|
|
176
|
+
return a;
|
|
177
|
+
};
|
|
178
|
+
const current = (): boolean => caller().certificateHash === callerCert;
|
|
179
|
+
|
|
180
|
+
const directory: Directory = {
|
|
181
|
+
async resolveAccounts(names): Promise<ResolvedAccount[]> {
|
|
182
|
+
return names.flatMap((n) => {
|
|
183
|
+
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];
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
async resolveIdentities(identityIds): Promise<ResolvedAccount[]> {
|
|
190
|
+
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];
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
async getCertificate(hash) {
|
|
198
|
+
return host.certificates.get(toHex(hash));
|
|
199
|
+
},
|
|
200
|
+
async registerAccount({ certificate, vault }): Promise<MutationOutcome> {
|
|
201
|
+
const doc = expectType<AccountDevice>(verifyDocument(certificate), DocumentType.AccountDevice);
|
|
202
|
+
const taken = host.accounts.get(doc.accountName);
|
|
203
|
+
if (taken) return equalBytes(taken.certificate, certificate) ? { status: "ALREADY_COMMITTED" } : { status: "CONFLICT" };
|
|
204
|
+
const vh = parseVaultHeader(vault);
|
|
205
|
+
const iid = toHex(identityId(doc.accountName, doc.accountKey));
|
|
206
|
+
host.accounts.set(doc.accountName, {
|
|
207
|
+
accountName: doc.accountName,
|
|
208
|
+
accountKey: doc.accountKey,
|
|
209
|
+
identityId: iid,
|
|
210
|
+
certificate,
|
|
211
|
+
certificateHash: toHex(doc.bodyHash),
|
|
212
|
+
generation: doc.generation,
|
|
213
|
+
deviceIds: new Set([toHex(doc.deviceId)]),
|
|
214
|
+
retiredCertificates: new Set(),
|
|
215
|
+
vault: { wire: vault, revision: vh.vaultRevision, hash: toHex(vh.hash) },
|
|
216
|
+
retiredIdentity: false,
|
|
217
|
+
previousIdentities: new Set(),
|
|
218
|
+
resetReceipt: undefined
|
|
219
|
+
});
|
|
220
|
+
host.certificates.set(toHex(doc.bodyHash), certificate);
|
|
221
|
+
host.stream(toHex(deriveStreamId(fromHex(iid), doc.deviceId)), iid, doc.accountName);
|
|
222
|
+
return { status: "COMMITTED" };
|
|
223
|
+
},
|
|
224
|
+
async activateDevice(input): Promise<MutationOutcome> {
|
|
225
|
+
const a = host.accounts.get(input.accountName);
|
|
226
|
+
if (!a) return { status: "UNAUTHORIZED" };
|
|
227
|
+
const doc = expectType<AccountDevice>(verifyDocument(input.newCertificate), DocumentType.AccountDevice);
|
|
228
|
+
if (toHex(doc.bodyHash) === a.certificateHash) return { status: "ALREADY_COMMITTED" };
|
|
229
|
+
if (a.certificateHash !== toHex(input.expectedCertificateHash)) return { status: "CONFLICT", current: a.certificate };
|
|
230
|
+
if (!a.vault || a.vault.revision !== input.expectedVaultRevision || a.vault.hash !== toHex(input.expectedVaultHash)) return { status: "CONFLICT", current: a.vault?.wire, currentRevision: a.vault?.revision };
|
|
231
|
+
if (!equalBytes(doc.accountKey, a.accountKey) || doc.generation !== a.generation + 1n || a.deviceIds.has(toHex(doc.deviceId))) return { status: "UNAUTHORIZED" };
|
|
232
|
+
const vh = parseVaultHeader(input.newVault);
|
|
233
|
+
a.retiredCertificates.add(a.certificateHash);
|
|
234
|
+
a.certificate = input.newCertificate;
|
|
235
|
+
a.certificateHash = toHex(doc.bodyHash);
|
|
236
|
+
a.generation = doc.generation;
|
|
237
|
+
a.deviceIds.add(toHex(doc.deviceId));
|
|
238
|
+
a.vault = { wire: input.newVault, revision: vh.vaultRevision, hash: toHex(vh.hash) };
|
|
239
|
+
host.certificates.set(a.certificateHash, input.newCertificate);
|
|
240
|
+
host.stream(toHex(deriveStreamId(fromHex(a.identityId), doc.deviceId)), a.identityId, a.accountName);
|
|
241
|
+
return { status: "COMMITTED" };
|
|
242
|
+
},
|
|
243
|
+
async getVault(name) {
|
|
244
|
+
return host.accounts.get(name)?.vault?.wire;
|
|
245
|
+
},
|
|
246
|
+
async publishVault({ expectedRevision, expectedHash, newVault }): Promise<MutationOutcome> {
|
|
247
|
+
const a = caller();
|
|
248
|
+
if (!current()) return { status: "UNAUTHORIZED" };
|
|
249
|
+
const vh = parseVaultHeader(newVault);
|
|
250
|
+
if (a.vault && (a.vault.revision !== expectedRevision || (expectedHash && a.vault.hash !== toHex(expectedHash)))) {
|
|
251
|
+
if (a.vault.hash === toHex(vh.hash)) return { status: "ALREADY_COMMITTED" };
|
|
252
|
+
return { status: "CONFLICT", current: a.vault.wire, currentRevision: a.vault.revision };
|
|
253
|
+
}
|
|
254
|
+
if (vh.vaultRevision !== expectedRevision + 1n) return { status: "CONFLICT", current: a.vault?.wire, currentRevision: a.vault?.revision };
|
|
255
|
+
a.vault = { wire: newVault, revision: vh.vaultRevision, hash: toHex(vh.hash) };
|
|
256
|
+
return { status: "COMMITTED" };
|
|
257
|
+
},
|
|
258
|
+
async getCircle(circleId) {
|
|
259
|
+
if (host.withholdSnapshots) return undefined;
|
|
260
|
+
const c = host.circles.get(toHex(circleId));
|
|
261
|
+
return c && !c.closed ? c.snapshot : undefined;
|
|
262
|
+
},
|
|
263
|
+
async publishOwnedCircle({ circleId, expectedRevision, expectedHash, newSnapshot }): Promise<MutationOutcome> {
|
|
264
|
+
const a = caller();
|
|
265
|
+
if (!current()) return { status: "UNAUTHORIZED" };
|
|
266
|
+
const doc = expectType<CircleSnapshot>(verifyDocument(newSnapshot, a.accountKey), DocumentType.CircleSnapshot);
|
|
267
|
+
if (toHex(doc.ownerIdentityId) !== a.identityId || !equalBytes(doc.circleId, circleId)) return { status: "UNAUTHORIZED" };
|
|
268
|
+
const key = toHex(circleId);
|
|
269
|
+
const existing = host.circles.get(key);
|
|
270
|
+
if (existing) {
|
|
271
|
+
if (existing.hash === toHex(doc.bodyHash)) return { status: "ALREADY_COMMITTED" };
|
|
272
|
+
if (existing.revision !== expectedRevision || (expectedHash && existing.hash !== toHex(expectedHash)) || doc.revision !== existing.revision + 1n) {
|
|
273
|
+
return { status: "CONFLICT", current: existing.snapshot, currentRevision: existing.revision };
|
|
274
|
+
}
|
|
275
|
+
} 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 });
|
|
277
|
+
// Requests for accounts now admitted are cleared.
|
|
278
|
+
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 ?? "")));
|
|
280
|
+
return { status: "COMMITTED" };
|
|
281
|
+
},
|
|
282
|
+
async requestJoin(circleId): Promise<MutationOutcome> {
|
|
283
|
+
const a = caller();
|
|
284
|
+
const c = host.circles.get(toHex(circleId));
|
|
285
|
+
if (!c) return { status: "PERMANENT_FAILURE" };
|
|
286
|
+
const list = (host.requests.get(toHex(circleId)) ?? []).filter((r) => !(r.accountName === a.accountName && r.action === "join"));
|
|
287
|
+
// A repeated request refreshes the pending one; the owner is notified once per live request.
|
|
288
|
+
const hadLive = (host.requests.get(toHex(circleId)) ?? []).some((r) => r.accountName === a.accountName && r.action === "join" && r.requestedAt > host.now() - 7n * 86_400_000n);
|
|
289
|
+
list.push({ circleId, accountName: a.accountName, action: "join", requestedAt: host.now() });
|
|
290
|
+
host.requests.set(toHex(circleId), list);
|
|
291
|
+
if (!hadLive) {
|
|
292
|
+
const owner = host.accountByIdentity(c.ownerIdentityId);
|
|
293
|
+
if (owner) host.notifications.push({ kind: "joinRequest", to: owner.accountName, from: a.accountName, circleId: toHex(circleId) });
|
|
294
|
+
}
|
|
295
|
+
return { status: "COMMITTED" };
|
|
296
|
+
},
|
|
297
|
+
async requestLeave(circleId): Promise<MutationOutcome> {
|
|
298
|
+
const a = caller();
|
|
299
|
+
const c = host.circles.get(toHex(circleId));
|
|
300
|
+
if (!c) return { status: "PERMANENT_FAILURE" };
|
|
301
|
+
const list = host.requests.get(toHex(circleId)) ?? [];
|
|
302
|
+
list.push({ circleId, accountName: a.accountName, action: "leave", requestedAt: host.now() });
|
|
303
|
+
host.requests.set(toHex(circleId), list);
|
|
304
|
+
const owner = host.accountByIdentity(c.ownerIdentityId);
|
|
305
|
+
if (owner) host.notifications.push({ kind: "leaveRequest", to: owner.accountName, from: a.accountName, circleId: toHex(circleId) });
|
|
306
|
+
return { status: "COMMITTED" };
|
|
307
|
+
},
|
|
308
|
+
async readRequests(circleId) {
|
|
309
|
+
const a = caller();
|
|
310
|
+
const c = host.circles.get(toHex(circleId));
|
|
311
|
+
if (!c || c.ownerIdentityId !== a.identityId) return [];
|
|
312
|
+
const cutoff = host.now() - 7n * 86_400_000n;
|
|
313
|
+
return (host.requests.get(toHex(circleId)) ?? []).filter((r) => r.requestedAt > cutoff);
|
|
314
|
+
},
|
|
315
|
+
async getResetKeyList() {
|
|
316
|
+
return host.resetKeyList;
|
|
317
|
+
},
|
|
318
|
+
async getResetReceipt(resetId) {
|
|
319
|
+
return host.receipts.get(toHex(resetId));
|
|
320
|
+
},
|
|
321
|
+
async resetAccount(input) {
|
|
322
|
+
// The reset id answers first: a device whose response was lost retries after the account
|
|
323
|
+
// already carries the new identity, and must get its receipt, not UNAUTHORIZED.
|
|
324
|
+
const existing = host.receipts.get(toHex(input.resetId));
|
|
325
|
+
if (existing) return { status: "ALREADY_COMMITTED" as const, receipt: existing };
|
|
326
|
+
const a = host.accounts.get(input.accountName);
|
|
327
|
+
if (!a || a.identityId !== toHex(input.expectedOldIdentityId) || !host.resetSigner) return { status: "UNAUTHORIZED" as const };
|
|
328
|
+
const doc = expectType<AccountDevice>(verifyDocument(input.newCertificate), DocumentType.AccountDevice);
|
|
329
|
+
const newIid = identityId(doc.accountName, doc.accountKey);
|
|
330
|
+
const receipt = buildResetReceipt(
|
|
331
|
+
{ accountName: input.accountName, oldIdentityId: input.expectedOldIdentityId, newIdentityId: newIid, resetId: input.resetId, issuedAt: host.now(), authorityKeyId: host.resetSigner.keyId },
|
|
332
|
+
host.resetSigner.key.secretKey
|
|
333
|
+
);
|
|
334
|
+
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);
|
|
337
|
+
a.previousIdentities.add(a.identityId);
|
|
338
|
+
a.resetReceipt = receipt;
|
|
339
|
+
a.retiredCertificates.add(a.certificateHash);
|
|
340
|
+
a.accountKey = doc.accountKey;
|
|
341
|
+
a.identityId = toHex(newIid);
|
|
342
|
+
a.certificate = input.newCertificate;
|
|
343
|
+
a.certificateHash = toHex(doc.bodyHash);
|
|
344
|
+
a.generation = doc.generation;
|
|
345
|
+
a.deviceIds.add(toHex(doc.deviceId));
|
|
346
|
+
a.vault = { wire: input.newVault, revision: vh.vaultRevision, hash: toHex(vh.hash) };
|
|
347
|
+
host.certificates.set(a.certificateHash, input.newCertificate);
|
|
348
|
+
host.stream(toHex(deriveStreamId(newIid, doc.deviceId)), a.identityId, a.accountName);
|
|
349
|
+
return { status: "COMMITTED" as const, receipt };
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const relay: Relay = {
|
|
354
|
+
async getCurrentEpoch(streamId) {
|
|
355
|
+
const s = host.streams.get(toHex(streamId));
|
|
356
|
+
if (!s || s.currentEpoch === 0 || !s.currentManifestHash) return undefined;
|
|
357
|
+
return { epoch: s.currentEpoch, manifestHash: fromHex(s.currentManifestHash) };
|
|
358
|
+
},
|
|
359
|
+
async getEpoch(streamId, epoch): Promise<EpochBundle | undefined> {
|
|
360
|
+
if (host.withholdEpochs) return undefined;
|
|
361
|
+
const s = host.streams.get(toHex(streamId));
|
|
362
|
+
const e = s?.epochs.get(epoch);
|
|
363
|
+
if (!e) return undefined;
|
|
364
|
+
return { manifest: e.manifest, wrap: e.wraps.get(caller().identityId) };
|
|
365
|
+
},
|
|
366
|
+
async publishEpoch({ streamId, expectedEpoch, manifest, wraps }) {
|
|
367
|
+
const a = caller();
|
|
368
|
+
const s = host.streams.get(toHex(streamId));
|
|
369
|
+
if (!s || s.authorIdentityId !== a.identityId) return { status: "UNAUTHORIZED" as const };
|
|
370
|
+
if (!current()) return { status: "UNAUTHORIZED" as const };
|
|
371
|
+
const doc = expectType<EpochManifest>(parseDocument(manifest), DocumentType.EpochManifest);
|
|
372
|
+
const cur = s.epochs.get(s.currentEpoch);
|
|
373
|
+
if (doc.epoch === s.currentEpoch && cur && equalBytes(cur.manifest, manifest)) return { status: "ALREADY_COMMITTED" as const };
|
|
374
|
+
if (expectedEpoch !== s.currentEpoch || doc.epoch !== s.currentEpoch + 1) {
|
|
375
|
+
return { status: "CONFLICT" as const, currentEpoch: s.currentEpoch, currentManifest: cur?.manifest };
|
|
376
|
+
}
|
|
377
|
+
const rows = doc.audience.map((r) => ({ identityId: toHex(r.identityId), certificateHash: toHex(r.certificateHash) }));
|
|
378
|
+
s.epochs.set(doc.epoch, { manifest, wraps: new Map(wraps), rows });
|
|
379
|
+
s.currentEpoch = doc.epoch;
|
|
380
|
+
s.currentManifestHash = toHex(doc.bodyHash);
|
|
381
|
+
for (const r of rows) {
|
|
382
|
+
if (r.identityId === a.identityId) continue;
|
|
383
|
+
if (!s.subscriptions.has(r.certificateHash)) s.subscriptions.set(r.certificateHash, { identityId: r.identityId, cursor: s.tail, startSeq: s.tail });
|
|
384
|
+
}
|
|
385
|
+
host.purgeEpochs(s);
|
|
386
|
+
return { status: "COMMITTED" as const };
|
|
387
|
+
},
|
|
388
|
+
appendRecord(recordId, wire): Promise<AppendOutcome> {
|
|
389
|
+
host.appendCalls++;
|
|
390
|
+
if (host.failAppends) throw new Error("transport down");
|
|
391
|
+
const run = (): AppendOutcome => {
|
|
392
|
+
const a = caller();
|
|
393
|
+
const rec = expectType<EncryptedRecord>(parseDocument(wire), DocumentType.EncryptedRecord);
|
|
394
|
+
const s = host.streams.get(toHex(rec.streamId));
|
|
395
|
+
if (!s || s.authorIdentityId !== a.identityId) return { status: "UNAUTHORIZED" };
|
|
396
|
+
if (!current()) return { status: "SUPERSEDED_DEVICE" };
|
|
397
|
+
const id = toHex(recordId);
|
|
398
|
+
const hash = toHex(bodyHash(wire)); // exact bytes, signature included
|
|
399
|
+
// Checkpoint first, then the epoch fence.
|
|
400
|
+
if (s.lastAppend && s.lastAppend.recordId === id) {
|
|
401
|
+
if (s.lastAppend.hash !== hash) return { status: "EQUIVOCATION" };
|
|
402
|
+
if (s.lastAppend.at + host.appendRetryWindowMs > host.now()) return { status: "ALREADY_ACCEPTED", deliverySeq: s.lastAppend.seq };
|
|
403
|
+
}
|
|
404
|
+
const retained = [...s.records.values()].find((r) => r.recordId === id);
|
|
405
|
+
if (retained) return hash === toHex(bodyHash(retained.wire)) ? { status: "ALREADY_ACCEPTED" } : { status: "EQUIVOCATION" };
|
|
406
|
+
if (s.highWater && (rec.epoch < s.highWater.epoch || (rec.epoch === s.highWater.epoch && rec.counter <= s.highWater.counter))) return { status: "STALE_APPEND" };
|
|
407
|
+
if (rec.epoch !== s.currentEpoch) return rec.epoch < s.currentEpoch ? { status: "EPOCH_FENCED" } : { status: "UNAUTHORIZED" };
|
|
408
|
+
const epoch = s.epochs.get(rec.epoch);
|
|
409
|
+
if (!epoch) return { status: "UNAUTHORIZED" };
|
|
410
|
+
const seq = ++s.tail;
|
|
411
|
+
const acceptedAt = host.now() > s.lastAcceptedAt ? host.now() : s.lastAcceptedAt;
|
|
412
|
+
s.lastAcceptedAt = acceptedAt;
|
|
413
|
+
const required = new Set(epoch.rows.filter((r) => r.identityId !== a.identityId).map((r) => r.certificateHash));
|
|
414
|
+
s.records.set(seq, { recordId: id, wire, acceptedAt, epoch: rec.epoch, counter: rec.counter, kind: rec.headerClass, required });
|
|
415
|
+
s.highWater = { epoch: rec.epoch, counter: rec.counter };
|
|
416
|
+
const outcome: AppendOutcome = { status: "ACCEPTED", deliverySeq: seq };
|
|
417
|
+
s.lastAppend = { recordId: id, hash, seq, at: acceptedAt, outcome };
|
|
418
|
+
for (const r of epoch.rows) {
|
|
419
|
+
if (r.identityId === a.identityId) continue;
|
|
420
|
+
const to = host.accountByIdentity(r.identityId)?.accountName ?? r.identityId;
|
|
421
|
+
// By header class, as the test profile declares them: 1 is a visible notification, 2 is
|
|
422
|
+
// silent and needs no wake-up, anything else is a wake-up. The host never sees the kind.
|
|
423
|
+
if (rec.headerClass !== 2) host.notifications.push({ kind: rec.headerClass === 1 ? "visible" : "wake", to, from: a.accountName });
|
|
424
|
+
}
|
|
425
|
+
if (required.size === 0) s.records.delete(seq);
|
|
426
|
+
return outcome;
|
|
427
|
+
};
|
|
428
|
+
const result = run();
|
|
429
|
+
try {
|
|
430
|
+
const rec = expectType<EncryptedRecord>(parseDocument(wire), DocumentType.EncryptedRecord);
|
|
431
|
+
host.appendLog.push({ streamId: toHex(rec.streamId), recordId: toHex(recordId), hash: toHex(bodyHash(wire)), status: result.status, epoch: rec.epoch, counter: rec.counter });
|
|
432
|
+
} catch {
|
|
433
|
+
/* unparsable wires are refused above */
|
|
434
|
+
}
|
|
435
|
+
if (host.dropAppendResponses) return new Promise<AppendOutcome>((_, reject) => reject(new Error("response lost")));
|
|
436
|
+
return Promise.resolve(result);
|
|
437
|
+
},
|
|
438
|
+
async readRecords(streamId, afterSeq, limit): Promise<ReadRecordsResult> {
|
|
439
|
+
const s = host.streams.get(toHex(streamId));
|
|
440
|
+
if (!s) return { records: [], nextSeq: afterSeq, tail: 0n, expiredThrough: 0n, committedCursor: 0n };
|
|
441
|
+
if (afterSeq > s.tail) throw new Error("CURSOR_OUT_OF_RANGE");
|
|
442
|
+
const sub = s.subscriptions.get(callerCert);
|
|
443
|
+
if (!sub) return { records: [], nextSeq: s.tail, tail: s.tail, expiredThrough: s.expiredThrough, committedCursor: 0n };
|
|
444
|
+
let scan = afterSeq;
|
|
445
|
+
if (sub.cursor > scan) scan = sub.cursor;
|
|
446
|
+
if (s.expiredThrough > scan) scan = s.expiredThrough;
|
|
447
|
+
const records: { deliverySeq: bigint; wire: Uint8Array }[] = [];
|
|
448
|
+
let next = scan;
|
|
449
|
+
for (let seq = scan + 1n; seq <= s.tail; seq++) {
|
|
450
|
+
const r = s.records.get(seq);
|
|
451
|
+
if (r && r.required.has(callerCert)) {
|
|
452
|
+
if (records.length >= limit) break;
|
|
453
|
+
records.push({ deliverySeq: seq, wire: r.wire });
|
|
454
|
+
}
|
|
455
|
+
next = seq;
|
|
456
|
+
}
|
|
457
|
+
return { records, nextSeq: next, tail: s.tail, expiredThrough: s.expiredThrough, committedCursor: sub.cursor };
|
|
458
|
+
},
|
|
459
|
+
async ackThrough(streamId, cursor) {
|
|
460
|
+
const s = host.streams.get(toHex(streamId));
|
|
461
|
+
const sub = s?.subscriptions.get(callerCert);
|
|
462
|
+
if (!s || !sub) return { status: "UNAUTHORIZED" as const };
|
|
463
|
+
if (cursor > s.tail) return { status: "CURSOR_OUT_OF_RANGE" as const };
|
|
464
|
+
if (cursor > sub.cursor) {
|
|
465
|
+
sub.cursor = cursor;
|
|
466
|
+
host.collect(s, cursor);
|
|
467
|
+
}
|
|
468
|
+
return { status: "COMMITTED" as const, committedCursor: sub.cursor };
|
|
469
|
+
},
|
|
470
|
+
async listSubscriptions() {
|
|
471
|
+
const out: { streamId: Uint8Array; authorIdentityId: Uint8Array }[] = [];
|
|
472
|
+
for (const [sid, s] of host.streams) if (s.subscriptions.has(callerCert)) out.push({ streamId: fromHex(sid), authorIdentityId: fromHex(s.authorIdentityId) });
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
directory,
|
|
479
|
+
relay
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|