@peerbit/trusted-network 6.0.104 → 6.0.106
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/src/v2-policy-anchor.d.ts +75 -0
- package/dist/src/v2-policy-anchor.d.ts.map +1 -0
- package/dist/src/v2-policy-anchor.js +990 -0
- package/dist/src/v2-policy-anchor.js.map +1 -0
- package/dist/src/v2-policy-engine.d.ts +54 -11
- package/dist/src/v2-policy-engine.d.ts.map +1 -1
- package/dist/src/v2-policy-engine.js +431 -68
- package/dist/src/v2-policy-engine.js.map +1 -1
- package/dist/src/v2.d.ts +6 -0
- package/dist/src/v2.d.ts.map +1 -1
- package/dist/src/v2.js +6 -0
- package/dist/src/v2.js.map +1 -1
- package/package.json +4 -4
- package/src/v2-policy-anchor.ts +1152 -0
- package/src/v2-policy-engine.ts +621 -77
- package/src/v2.ts +7 -0
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deserialize,
|
|
3
|
+
field,
|
|
4
|
+
fixedArray,
|
|
5
|
+
serialize,
|
|
6
|
+
variant,
|
|
7
|
+
} from "@dao-xyz/borsh";
|
|
8
|
+
import { PublicSignKey, sha256Sync } from "@peerbit/crypto";
|
|
9
|
+
import { compare, concat, equals } from "uint8arrays";
|
|
10
|
+
import type {
|
|
11
|
+
PolicyAdmissionResultV2,
|
|
12
|
+
PolicyForkEvidenceV2,
|
|
13
|
+
PolicyHeadProjectionV2,
|
|
14
|
+
PolicyReducerDurableStateV2,
|
|
15
|
+
PolicySnapshotResolverV2,
|
|
16
|
+
} from "./v2-policy-engine.js";
|
|
17
|
+
import {
|
|
18
|
+
TrustedNetworkV2PolicyReducer,
|
|
19
|
+
authenticatePolicySnapshotEntryV2,
|
|
20
|
+
} from "./v2-policy-engine.js";
|
|
21
|
+
import {
|
|
22
|
+
NetworkDescriptorV2,
|
|
23
|
+
PolicySubjectBindingV2,
|
|
24
|
+
TRUSTED_NETWORK_V2_KNOWN_ROLE_BITS,
|
|
25
|
+
TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES,
|
|
26
|
+
assertNetworkDescriptorV2,
|
|
27
|
+
} from "./v2.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Internal crash-safe policy-anchor storage format.
|
|
31
|
+
*
|
|
32
|
+
* This module is intentionally absent from the package entry point. Its store
|
|
33
|
+
* must already be open and scoped to this namespace and descriptor. Exactly
|
|
34
|
+
* one wrapper may write that scope: this append-only format deliberately does
|
|
35
|
+
* not pretend that get-then-put is a multi-process compare-and-swap.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export const TRUSTED_NETWORK_V2_POLICY_ANCHOR_STORE_OWNER =
|
|
39
|
+
"peerbit/trusted-network/v2/policy-anchor/v1";
|
|
40
|
+
export const TRUSTED_NETWORK_V2_MAX_POLICY_ANCHOR_GENERATION_BYTES =
|
|
41
|
+
TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES * 4;
|
|
42
|
+
|
|
43
|
+
const GENERATION_KEY_PREFIX = `${TRUSTED_NETWORK_V2_POLICY_ANCHOR_STORE_OWNER}/generation/`;
|
|
44
|
+
const FORMAT_VERSION = 1;
|
|
45
|
+
const MAX_U64 = 0xffffffffffffffffn;
|
|
46
|
+
const MAX_UNAVAILABLE_REASON_LENGTH = 512;
|
|
47
|
+
const ZERO_DIGEST = new Uint8Array(32);
|
|
48
|
+
const textEncoder = new TextEncoder();
|
|
49
|
+
const DESCRIPTOR_DIGEST_DOMAIN = textEncoder.encode(
|
|
50
|
+
"peerbit/trusted-network/v2/policy-anchor/descriptor/v1",
|
|
51
|
+
);
|
|
52
|
+
const GENERATION_CHECKSUM_DOMAIN = textEncoder.encode(
|
|
53
|
+
"peerbit/trusted-network/v2/policy-anchor/generation/v1",
|
|
54
|
+
);
|
|
55
|
+
const ANCHOR_STATE = Object.freeze({
|
|
56
|
+
ACTIVE: 1,
|
|
57
|
+
UNAVAILABLE: 2,
|
|
58
|
+
FORKED: 3,
|
|
59
|
+
} as const);
|
|
60
|
+
const GENERATION_KIND = Object.freeze({
|
|
61
|
+
STATE: 1,
|
|
62
|
+
FORK_OBSERVATION: 2,
|
|
63
|
+
} as const);
|
|
64
|
+
const MAX_STATE_GENERATION_PAYLOAD_BYTES =
|
|
65
|
+
TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES * 4;
|
|
66
|
+
const MAX_OBSERVATION_GENERATION_PAYLOAD_BYTES =
|
|
67
|
+
TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES + 1024;
|
|
68
|
+
|
|
69
|
+
type MaybePromiseV2<T> = T | Promise<T>;
|
|
70
|
+
|
|
71
|
+
export type CrashSafePolicyAnchorStoreV2 = {
|
|
72
|
+
get(key: string): MaybePromiseV2<Uint8Array | undefined>;
|
|
73
|
+
put(key: string, value: Uint8Array): MaybePromiseV2<void>;
|
|
74
|
+
iterator(): AsyncIterable<[string, Uint8Array]>;
|
|
75
|
+
readonly crashSafeDurability: {
|
|
76
|
+
readonly crashSafe: true;
|
|
77
|
+
barrier(): MaybePromiseV2<void>;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type DurableReducerOptionsV2 = {
|
|
82
|
+
descriptor: NetworkDescriptorV2;
|
|
83
|
+
resolvePolicyEntry: PolicySnapshotResolverV2;
|
|
84
|
+
resolveTimeoutMs?: number;
|
|
85
|
+
signal?: AbortSignal;
|
|
86
|
+
maxPending?: number;
|
|
87
|
+
maxPendingPolicyBytes?: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type TrustedNetworkV2DurablePolicyReducerOptions =
|
|
91
|
+
DurableReducerOptionsV2 & {
|
|
92
|
+
store: CrashSafePolicyAnchorStoreV2;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
@variant([2, 16, 0])
|
|
96
|
+
class PolicyAnchorCoreStateRecordV2 {
|
|
97
|
+
@field({ type: "u8" })
|
|
98
|
+
state: number;
|
|
99
|
+
|
|
100
|
+
@field({ type: Uint8Array })
|
|
101
|
+
acceptedHeadEntryBytes: Uint8Array;
|
|
102
|
+
|
|
103
|
+
@field({ type: Uint8Array })
|
|
104
|
+
comparisonCandidateEntryBytes: Uint8Array;
|
|
105
|
+
|
|
106
|
+
@field({ type: fixedArray("u8", 32) })
|
|
107
|
+
acceptedAncestorDigest: Uint8Array;
|
|
108
|
+
|
|
109
|
+
@field({ type: "string" })
|
|
110
|
+
unavailableReason: string;
|
|
111
|
+
|
|
112
|
+
@field({ type: Uint8Array })
|
|
113
|
+
forkChildEntryBytes0: Uint8Array;
|
|
114
|
+
|
|
115
|
+
@field({ type: Uint8Array })
|
|
116
|
+
forkChildEntryBytes1: Uint8Array;
|
|
117
|
+
|
|
118
|
+
constructor(properties?: {
|
|
119
|
+
state: number;
|
|
120
|
+
acceptedHeadEntryBytes: Uint8Array;
|
|
121
|
+
comparisonCandidateEntryBytes: Uint8Array;
|
|
122
|
+
acceptedAncestorDigest: Uint8Array;
|
|
123
|
+
unavailableReason: string;
|
|
124
|
+
forkChildEntryBytes0: Uint8Array;
|
|
125
|
+
forkChildEntryBytes1: Uint8Array;
|
|
126
|
+
}) {
|
|
127
|
+
if (properties) Object.assign(this, properties);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@variant([2, 16, 3])
|
|
132
|
+
class PolicyAnchorStateGenerationPayloadV2 {
|
|
133
|
+
@field({ type: PolicyAnchorCoreStateRecordV2 })
|
|
134
|
+
coreState: PolicyAnchorCoreStateRecordV2;
|
|
135
|
+
|
|
136
|
+
constructor(properties?: { coreState: PolicyAnchorCoreStateRecordV2 }) {
|
|
137
|
+
if (properties) Object.assign(this, properties);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@variant([2, 16, 4])
|
|
142
|
+
class PolicyAnchorObservationGenerationPayloadV2 {
|
|
143
|
+
@field({ type: Uint8Array })
|
|
144
|
+
entryBytes: Uint8Array;
|
|
145
|
+
|
|
146
|
+
constructor(properties?: { entryBytes: Uint8Array }) {
|
|
147
|
+
if (properties) Object.assign(this, properties);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@variant([2, 16, 1])
|
|
152
|
+
class PolicyAnchorGenerationBodyV2 {
|
|
153
|
+
@field({ type: "u8" })
|
|
154
|
+
formatVersion: number;
|
|
155
|
+
|
|
156
|
+
@field({ type: "u64" })
|
|
157
|
+
generation: bigint;
|
|
158
|
+
|
|
159
|
+
@field({ type: fixedArray("u8", 32) })
|
|
160
|
+
descriptorDigest: Uint8Array;
|
|
161
|
+
|
|
162
|
+
@field({ type: fixedArray("u8", 32) })
|
|
163
|
+
previousGenerationChecksum: Uint8Array;
|
|
164
|
+
|
|
165
|
+
@field({ type: "u8" })
|
|
166
|
+
kind: number;
|
|
167
|
+
|
|
168
|
+
@field({ type: Uint8Array })
|
|
169
|
+
payloadBytes: Uint8Array;
|
|
170
|
+
|
|
171
|
+
constructor(properties?: {
|
|
172
|
+
formatVersion: number;
|
|
173
|
+
generation: bigint;
|
|
174
|
+
descriptorDigest: Uint8Array;
|
|
175
|
+
previousGenerationChecksum: Uint8Array;
|
|
176
|
+
kind: number;
|
|
177
|
+
payloadBytes: Uint8Array;
|
|
178
|
+
}) {
|
|
179
|
+
if (properties) Object.assign(this, properties);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@variant([2, 16, 2])
|
|
184
|
+
class PolicyAnchorGenerationRecordV2 {
|
|
185
|
+
@field({ type: PolicyAnchorGenerationBodyV2 })
|
|
186
|
+
body: PolicyAnchorGenerationBodyV2;
|
|
187
|
+
|
|
188
|
+
@field({ type: fixedArray("u8", 32) })
|
|
189
|
+
checksum: Uint8Array;
|
|
190
|
+
|
|
191
|
+
constructor(properties?: {
|
|
192
|
+
body: PolicyAnchorGenerationBodyV2;
|
|
193
|
+
checksum: Uint8Array;
|
|
194
|
+
}) {
|
|
195
|
+
if (properties) Object.assign(this, properties);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
type CanonicalForkChildV2 = {
|
|
200
|
+
digest: Uint8Array;
|
|
201
|
+
entryBytes: Uint8Array;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
type PublishedProjectionV2 = {
|
|
205
|
+
state: "EMPTY" | "ACTIVE" | "UNAVAILABLE" | "FORKED";
|
|
206
|
+
head?: PolicyHeadProjectionV2;
|
|
207
|
+
forkEvidence?: PolicyForkEvidenceV2;
|
|
208
|
+
roles: Map<string, number>;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const copyBytes = (bytes: Uint8Array): Uint8Array => Uint8Array.from(bytes);
|
|
212
|
+
|
|
213
|
+
const bytesKey = (bytes: Uint8Array): string => {
|
|
214
|
+
let key = "";
|
|
215
|
+
for (const byte of bytes) key += byte.toString(16).padStart(2, "0");
|
|
216
|
+
return key;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const copyBinding = (binding: PolicySubjectBindingV2): PolicySubjectBindingV2 =>
|
|
220
|
+
deserialize(serialize(binding), PolicySubjectBindingV2);
|
|
221
|
+
|
|
222
|
+
const copyHead = (
|
|
223
|
+
head: PolicyHeadProjectionV2 | undefined,
|
|
224
|
+
): PolicyHeadProjectionV2 | undefined =>
|
|
225
|
+
head === undefined
|
|
226
|
+
? undefined
|
|
227
|
+
: {
|
|
228
|
+
sequence: head.sequence,
|
|
229
|
+
digest: copyBytes(head.digest),
|
|
230
|
+
bindings: head.bindings.map(copyBinding),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const copyForkEvidence = (
|
|
234
|
+
evidence: PolicyForkEvidenceV2 | undefined,
|
|
235
|
+
): PolicyForkEvidenceV2 | undefined =>
|
|
236
|
+
evidence === undefined
|
|
237
|
+
? undefined
|
|
238
|
+
: {
|
|
239
|
+
commonParent: copyHead(evidence.commonParent)!,
|
|
240
|
+
children: [
|
|
241
|
+
{
|
|
242
|
+
sequence: evidence.children[0].sequence,
|
|
243
|
+
digest: copyBytes(evidence.children[0].digest),
|
|
244
|
+
entryBytes: copyBytes(evidence.children[0].entryBytes),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
sequence: evidence.children[1].sequence,
|
|
248
|
+
digest: copyBytes(evidence.children[1].digest),
|
|
249
|
+
entryBytes: copyBytes(evidence.children[1].entryBytes),
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const keyId = (key: PublicSignKey): string => bytesKey(serialize(key));
|
|
255
|
+
|
|
256
|
+
const descriptorDigest = (descriptor: NetworkDescriptorV2): Uint8Array =>
|
|
257
|
+
sha256Sync(concat([DESCRIPTOR_DIGEST_DOMAIN, serialize(descriptor)]));
|
|
258
|
+
|
|
259
|
+
const generationChecksum = (body: PolicyAnchorGenerationBodyV2): Uint8Array =>
|
|
260
|
+
sha256Sync(concat([GENERATION_CHECKSUM_DOMAIN, serialize(body)]));
|
|
261
|
+
|
|
262
|
+
const observationContentHash = (entryBytes: Uint8Array): Uint8Array =>
|
|
263
|
+
sha256Sync(entryBytes);
|
|
264
|
+
|
|
265
|
+
const generationKey = (generation: bigint): string =>
|
|
266
|
+
`${GENERATION_KEY_PREFIX}${generation.toString().padStart(20, "0")}`;
|
|
267
|
+
|
|
268
|
+
const assertOpenNotAborted = (signal: AbortSignal | undefined): void => {
|
|
269
|
+
if (signal?.aborted) {
|
|
270
|
+
throw new Error("TrustedNetwork v2 durable policy open was aborted");
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const assertEntryBytes = (bytes: Uint8Array, label: string): void => {
|
|
275
|
+
if (
|
|
276
|
+
!(bytes instanceof Uint8Array) ||
|
|
277
|
+
bytes.byteLength === 0 ||
|
|
278
|
+
bytes.byteLength > TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES
|
|
279
|
+
) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`${label} must contain 1-${TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES} bytes`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const retainCanonicalForkChild = (
|
|
287
|
+
children: CanonicalForkChildV2[],
|
|
288
|
+
candidate: CanonicalForkChildV2,
|
|
289
|
+
): void => {
|
|
290
|
+
const existingIndex = children.findIndex(({ digest }) =>
|
|
291
|
+
equals(digest, candidate.digest),
|
|
292
|
+
);
|
|
293
|
+
if (existingIndex >= 0) {
|
|
294
|
+
if (
|
|
295
|
+
compare(candidate.entryBytes, children[existingIndex]!.entryBytes) >= 0
|
|
296
|
+
) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
children.splice(existingIndex, 1);
|
|
300
|
+
}
|
|
301
|
+
children.push({
|
|
302
|
+
digest: copyBytes(candidate.digest),
|
|
303
|
+
entryBytes: copyBytes(candidate.entryBytes),
|
|
304
|
+
});
|
|
305
|
+
children.sort((left, right) => {
|
|
306
|
+
const digestOrder = compare(left.digest, right.digest);
|
|
307
|
+
return digestOrder === 0
|
|
308
|
+
? compare(left.entryBytes, right.entryBytes)
|
|
309
|
+
: digestOrder;
|
|
310
|
+
});
|
|
311
|
+
if (children.length > 2) children.length = 2;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const assertEmptyBytes = (bytes: Uint8Array, label: string): void => {
|
|
315
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== 0) {
|
|
316
|
+
throw new Error(`${label} must be empty`);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const coreRecordFromState = (
|
|
321
|
+
state: Exclude<PolicyReducerDurableStateV2, { state: "EMPTY" }>,
|
|
322
|
+
): PolicyAnchorCoreStateRecordV2 => {
|
|
323
|
+
if (state.state === "ACTIVE") {
|
|
324
|
+
return new PolicyAnchorCoreStateRecordV2({
|
|
325
|
+
state: ANCHOR_STATE.ACTIVE,
|
|
326
|
+
acceptedHeadEntryBytes: copyBytes(state.acceptedHeadEntryBytes),
|
|
327
|
+
comparisonCandidateEntryBytes: new Uint8Array(0),
|
|
328
|
+
acceptedAncestorDigest: copyBytes(ZERO_DIGEST),
|
|
329
|
+
unavailableReason: "",
|
|
330
|
+
forkChildEntryBytes0: new Uint8Array(0),
|
|
331
|
+
forkChildEntryBytes1: new Uint8Array(0),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
if (state.state === "UNAVAILABLE") {
|
|
335
|
+
return new PolicyAnchorCoreStateRecordV2({
|
|
336
|
+
state: ANCHOR_STATE.UNAVAILABLE,
|
|
337
|
+
acceptedHeadEntryBytes: copyBytes(state.acceptedHeadEntryBytes),
|
|
338
|
+
comparisonCandidateEntryBytes: copyBytes(
|
|
339
|
+
state.comparisonCandidateEntryBytes,
|
|
340
|
+
),
|
|
341
|
+
acceptedAncestorDigest: copyBytes(state.acceptedAncestorDigest),
|
|
342
|
+
unavailableReason: state.reason,
|
|
343
|
+
forkChildEntryBytes0: new Uint8Array(0),
|
|
344
|
+
forkChildEntryBytes1: new Uint8Array(0),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return new PolicyAnchorCoreStateRecordV2({
|
|
348
|
+
state: ANCHOR_STATE.FORKED,
|
|
349
|
+
acceptedHeadEntryBytes: copyBytes(state.commonParentEntryBytes),
|
|
350
|
+
comparisonCandidateEntryBytes: new Uint8Array(0),
|
|
351
|
+
acceptedAncestorDigest: copyBytes(ZERO_DIGEST),
|
|
352
|
+
unavailableReason: "",
|
|
353
|
+
forkChildEntryBytes0: copyBytes(state.childEntryBytes[0]),
|
|
354
|
+
forkChildEntryBytes1: copyBytes(state.childEntryBytes[1]),
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const durableStateFromCoreRecord = (
|
|
359
|
+
record: PolicyAnchorCoreStateRecordV2,
|
|
360
|
+
): Exclude<PolicyReducerDurableStateV2, { state: "EMPTY" }> => {
|
|
361
|
+
assertEntryBytes(record.acceptedHeadEntryBytes, "accepted head entry");
|
|
362
|
+
if (record.state === ANCHOR_STATE.ACTIVE) {
|
|
363
|
+
assertEmptyBytes(
|
|
364
|
+
record.comparisonCandidateEntryBytes,
|
|
365
|
+
"active comparison candidate",
|
|
366
|
+
);
|
|
367
|
+
if (!equals(record.acceptedAncestorDigest, ZERO_DIGEST)) {
|
|
368
|
+
throw new Error("Active accepted-ancestor digest must be zero");
|
|
369
|
+
}
|
|
370
|
+
if (record.unavailableReason !== "") {
|
|
371
|
+
throw new Error("Active unavailable reason must be empty");
|
|
372
|
+
}
|
|
373
|
+
assertEmptyBytes(record.forkChildEntryBytes0, "active fork child 0");
|
|
374
|
+
assertEmptyBytes(record.forkChildEntryBytes1, "active fork child 1");
|
|
375
|
+
return {
|
|
376
|
+
formatVersion: FORMAT_VERSION,
|
|
377
|
+
state: "ACTIVE",
|
|
378
|
+
acceptedHeadEntryBytes: copyBytes(record.acceptedHeadEntryBytes),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (record.state === ANCHOR_STATE.UNAVAILABLE) {
|
|
382
|
+
assertEntryBytes(
|
|
383
|
+
record.comparisonCandidateEntryBytes,
|
|
384
|
+
"unavailable comparison candidate",
|
|
385
|
+
);
|
|
386
|
+
if (record.acceptedAncestorDigest.byteLength !== 32) {
|
|
387
|
+
throw new Error("Unavailable accepted-ancestor digest must be 32 bytes");
|
|
388
|
+
}
|
|
389
|
+
if (
|
|
390
|
+
record.unavailableReason.length === 0 ||
|
|
391
|
+
record.unavailableReason.length > MAX_UNAVAILABLE_REASON_LENGTH
|
|
392
|
+
) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
"Unavailable reason is empty or exceeds its character ceiling",
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
assertEmptyBytes(record.forkChildEntryBytes0, "unavailable fork child 0");
|
|
398
|
+
assertEmptyBytes(record.forkChildEntryBytes1, "unavailable fork child 1");
|
|
399
|
+
return {
|
|
400
|
+
formatVersion: FORMAT_VERSION,
|
|
401
|
+
state: "UNAVAILABLE",
|
|
402
|
+
acceptedHeadEntryBytes: copyBytes(record.acceptedHeadEntryBytes),
|
|
403
|
+
comparisonCandidateEntryBytes: copyBytes(
|
|
404
|
+
record.comparisonCandidateEntryBytes,
|
|
405
|
+
),
|
|
406
|
+
acceptedAncestorDigest: copyBytes(record.acceptedAncestorDigest),
|
|
407
|
+
reason: record.unavailableReason,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (record.state === ANCHOR_STATE.FORKED) {
|
|
411
|
+
assertEmptyBytes(
|
|
412
|
+
record.comparisonCandidateEntryBytes,
|
|
413
|
+
"forked comparison candidate",
|
|
414
|
+
);
|
|
415
|
+
if (!equals(record.acceptedAncestorDigest, ZERO_DIGEST)) {
|
|
416
|
+
throw new Error("Forked accepted-ancestor digest must be zero");
|
|
417
|
+
}
|
|
418
|
+
if (record.unavailableReason !== "") {
|
|
419
|
+
throw new Error("Forked unavailable reason must be empty");
|
|
420
|
+
}
|
|
421
|
+
assertEntryBytes(record.forkChildEntryBytes0, "fork child 0");
|
|
422
|
+
assertEntryBytes(record.forkChildEntryBytes1, "fork child 1");
|
|
423
|
+
return {
|
|
424
|
+
formatVersion: FORMAT_VERSION,
|
|
425
|
+
state: "FORKED",
|
|
426
|
+
commonParentEntryBytes: copyBytes(record.acceptedHeadEntryBytes),
|
|
427
|
+
childEntryBytes: [
|
|
428
|
+
copyBytes(record.forkChildEntryBytes0),
|
|
429
|
+
copyBytes(record.forkChildEntryBytes1),
|
|
430
|
+
],
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
throw new Error("Unknown durable policy-anchor state");
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
type DecodedGenerationPayloadV2 =
|
|
437
|
+
| {
|
|
438
|
+
kind: "state";
|
|
439
|
+
payload: PolicyAnchorStateGenerationPayloadV2;
|
|
440
|
+
durableState: Exclude<PolicyReducerDurableStateV2, { state: "EMPTY" }>;
|
|
441
|
+
}
|
|
442
|
+
| {
|
|
443
|
+
kind: "fork-observation";
|
|
444
|
+
payload: PolicyAnchorObservationGenerationPayloadV2;
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const decodeCanonicalPayload = <T>(
|
|
448
|
+
input: Uint8Array,
|
|
449
|
+
type: new (...args: any[]) => T,
|
|
450
|
+
maxBytes: number,
|
|
451
|
+
label: string,
|
|
452
|
+
): T => {
|
|
453
|
+
if (
|
|
454
|
+
!(input instanceof Uint8Array) ||
|
|
455
|
+
input.byteLength === 0 ||
|
|
456
|
+
input.byteLength > maxBytes
|
|
457
|
+
) {
|
|
458
|
+
throw new Error(`${label} exceeds its byte ceiling`);
|
|
459
|
+
}
|
|
460
|
+
const bytes = copyBytes(input);
|
|
461
|
+
const payload = deserialize(bytes, type);
|
|
462
|
+
if (!equals(bytes, serialize(payload))) {
|
|
463
|
+
throw new Error(`${label} is not canonical`);
|
|
464
|
+
}
|
|
465
|
+
return payload;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const decodeGenerationPayload = (
|
|
469
|
+
body: PolicyAnchorGenerationBodyV2,
|
|
470
|
+
): DecodedGenerationPayloadV2 => {
|
|
471
|
+
if (body.kind === GENERATION_KIND.STATE) {
|
|
472
|
+
const payload = decodeCanonicalPayload(
|
|
473
|
+
body.payloadBytes,
|
|
474
|
+
PolicyAnchorStateGenerationPayloadV2,
|
|
475
|
+
MAX_STATE_GENERATION_PAYLOAD_BYTES,
|
|
476
|
+
"Policy-anchor state payload",
|
|
477
|
+
);
|
|
478
|
+
return {
|
|
479
|
+
kind: "state",
|
|
480
|
+
payload,
|
|
481
|
+
durableState: durableStateFromCoreRecord(payload.coreState),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
if (body.kind === GENERATION_KIND.FORK_OBSERVATION) {
|
|
485
|
+
const payload = decodeCanonicalPayload(
|
|
486
|
+
body.payloadBytes,
|
|
487
|
+
PolicyAnchorObservationGenerationPayloadV2,
|
|
488
|
+
MAX_OBSERVATION_GENERATION_PAYLOAD_BYTES,
|
|
489
|
+
"Policy fork-observation payload",
|
|
490
|
+
);
|
|
491
|
+
assertEntryBytes(payload.entryBytes, "fork observation entry");
|
|
492
|
+
return { kind: "fork-observation", payload };
|
|
493
|
+
}
|
|
494
|
+
throw new Error("Unknown policy-anchor generation kind");
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const decodeGenerationRecord = (
|
|
498
|
+
input: Uint8Array,
|
|
499
|
+
): {
|
|
500
|
+
record: PolicyAnchorGenerationRecordV2;
|
|
501
|
+
payload: DecodedGenerationPayloadV2;
|
|
502
|
+
} => {
|
|
503
|
+
if (
|
|
504
|
+
!(input instanceof Uint8Array) ||
|
|
505
|
+
input.byteLength === 0 ||
|
|
506
|
+
input.byteLength > TRUSTED_NETWORK_V2_MAX_POLICY_ANCHOR_GENERATION_BYTES
|
|
507
|
+
) {
|
|
508
|
+
throw new Error("Policy-anchor generation record exceeds its byte ceiling");
|
|
509
|
+
}
|
|
510
|
+
const bytes = copyBytes(input);
|
|
511
|
+
const record = deserialize(bytes, PolicyAnchorGenerationRecordV2);
|
|
512
|
+
if (!equals(bytes, serialize(record))) {
|
|
513
|
+
throw new Error("Policy-anchor generation record is not canonical");
|
|
514
|
+
}
|
|
515
|
+
if (record.body.formatVersion !== FORMAT_VERSION) {
|
|
516
|
+
throw new Error("Unsupported policy-anchor generation format");
|
|
517
|
+
}
|
|
518
|
+
if (!equals(record.checksum, generationChecksum(record.body))) {
|
|
519
|
+
throw new Error("Policy-anchor generation checksum mismatch");
|
|
520
|
+
}
|
|
521
|
+
return { record, payload: decodeGenerationPayload(record.body) };
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const publishedFromReducer = (
|
|
525
|
+
reducer: TrustedNetworkV2PolicyReducer,
|
|
526
|
+
): PublishedProjectionV2 => {
|
|
527
|
+
if (reducer.state === "HALTED") {
|
|
528
|
+
throw new Error("Cannot publish a halted policy reducer");
|
|
529
|
+
}
|
|
530
|
+
const head = copyHead(reducer.head);
|
|
531
|
+
const roles = new Map<string, number>();
|
|
532
|
+
for (const binding of head?.bindings ?? []) {
|
|
533
|
+
roles.set(keyId(binding.signingKey), binding.roles);
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
state: reducer.state,
|
|
537
|
+
head,
|
|
538
|
+
forkEvidence: copyForkEvidence(reducer.forkEvidence),
|
|
539
|
+
roles,
|
|
540
|
+
};
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const assertCanonicalCoreRestore = (
|
|
544
|
+
reducer: TrustedNetworkV2PolicyReducer,
|
|
545
|
+
storedCoreBytes: Uint8Array,
|
|
546
|
+
): void => {
|
|
547
|
+
const restoredState = reducer.exportDurableState();
|
|
548
|
+
if (restoredState.state === "EMPTY") {
|
|
549
|
+
throw new Error("Restored policy-anchor state is unexpectedly empty");
|
|
550
|
+
}
|
|
551
|
+
const restoredCoreBytes = serialize(coreRecordFromState(restoredState));
|
|
552
|
+
if (!equals(restoredCoreBytes, storedCoreBytes)) {
|
|
553
|
+
throw new Error("Restored policy-anchor state is not canonical");
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Crash-safe publication wrapper for the internal v2 reducer.
|
|
559
|
+
*
|
|
560
|
+
* The mutable reducer is never exposed. Authorization reads use only the last
|
|
561
|
+
* projection published after the store barrier. An admission places a
|
|
562
|
+
* fail-closed fence around both reducer mutation and durable publication.
|
|
563
|
+
*/
|
|
564
|
+
export class TrustedNetworkV2DurablePolicyReducer {
|
|
565
|
+
private readonly store: CrashSafePolicyAnchorStoreV2;
|
|
566
|
+
private readonly durability: CrashSafePolicyAnchorStoreV2["crashSafeDurability"];
|
|
567
|
+
private readonly descriptor: NetworkDescriptorV2;
|
|
568
|
+
private readonly descriptorHash: Uint8Array;
|
|
569
|
+
private core: TrustedNetworkV2PolicyReducer;
|
|
570
|
+
private published: PublishedProjectionV2;
|
|
571
|
+
private generation = 0n;
|
|
572
|
+
private previousGenerationChecksum = copyBytes(ZERO_DIGEST);
|
|
573
|
+
private durableCoreBytes?: Uint8Array;
|
|
574
|
+
private observedHashes = new Set<string>();
|
|
575
|
+
private operationTail: Promise<void> = Promise.resolve();
|
|
576
|
+
private authorizationFences = 0;
|
|
577
|
+
private terminalError?: Error;
|
|
578
|
+
|
|
579
|
+
private constructor(properties: {
|
|
580
|
+
store: CrashSafePolicyAnchorStoreV2;
|
|
581
|
+
durability: CrashSafePolicyAnchorStoreV2["crashSafeDurability"];
|
|
582
|
+
descriptor: NetworkDescriptorV2;
|
|
583
|
+
core: TrustedNetworkV2PolicyReducer;
|
|
584
|
+
generation?: bigint;
|
|
585
|
+
previousGenerationChecksum?: Uint8Array;
|
|
586
|
+
durableCoreBytes?: Uint8Array;
|
|
587
|
+
observedHashes?: ReadonlySet<string>;
|
|
588
|
+
}) {
|
|
589
|
+
this.store = properties.store;
|
|
590
|
+
this.durability = properties.durability;
|
|
591
|
+
this.descriptor = deserialize(
|
|
592
|
+
serialize(properties.descriptor),
|
|
593
|
+
NetworkDescriptorV2,
|
|
594
|
+
);
|
|
595
|
+
this.descriptorHash = descriptorDigest(this.descriptor);
|
|
596
|
+
this.core = properties.core;
|
|
597
|
+
this.published = publishedFromReducer(this.core);
|
|
598
|
+
this.generation = properties.generation ?? 0n;
|
|
599
|
+
this.previousGenerationChecksum = copyBytes(
|
|
600
|
+
properties.previousGenerationChecksum ?? ZERO_DIGEST,
|
|
601
|
+
);
|
|
602
|
+
this.durableCoreBytes =
|
|
603
|
+
properties.durableCoreBytes === undefined
|
|
604
|
+
? undefined
|
|
605
|
+
: copyBytes(properties.durableCoreBytes);
|
|
606
|
+
this.observedHashes = new Set(properties.observedHashes);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
static async open(
|
|
610
|
+
options: TrustedNetworkV2DurablePolicyReducerOptions,
|
|
611
|
+
): Promise<TrustedNetworkV2DurablePolicyReducer> {
|
|
612
|
+
assertNetworkDescriptorV2(options.descriptor);
|
|
613
|
+
const descriptor = deserialize(
|
|
614
|
+
serialize(options.descriptor),
|
|
615
|
+
NetworkDescriptorV2,
|
|
616
|
+
);
|
|
617
|
+
const signal = options.signal;
|
|
618
|
+
const store = options.store;
|
|
619
|
+
const coreProperties: DurableReducerOptionsV2 = {
|
|
620
|
+
descriptor,
|
|
621
|
+
resolvePolicyEntry: options.resolvePolicyEntry,
|
|
622
|
+
resolveTimeoutMs: options.resolveTimeoutMs,
|
|
623
|
+
signal,
|
|
624
|
+
maxPending: options.maxPending,
|
|
625
|
+
maxPendingPolicyBytes: options.maxPendingPolicyBytes,
|
|
626
|
+
};
|
|
627
|
+
assertOpenNotAborted(signal);
|
|
628
|
+
const durability = store?.crashSafeDurability;
|
|
629
|
+
if (
|
|
630
|
+
durability?.crashSafe !== true ||
|
|
631
|
+
typeof durability.barrier !== "function"
|
|
632
|
+
) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
"TrustedNetwork v2 durable policy requires a crash-safe store barrier",
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// A successful fresh barrier is required on every open, including an empty
|
|
639
|
+
// store; a generic flush acknowledgement is not this physical fence.
|
|
640
|
+
await durability.barrier();
|
|
641
|
+
assertOpenNotAborted(signal);
|
|
642
|
+
|
|
643
|
+
const expectedDescriptorHash = descriptorDigest(descriptor);
|
|
644
|
+
// Keep only immutable keys while discovering the history, then use get()
|
|
645
|
+
// to decode one record at a time. This avoids retaining the raw log and its
|
|
646
|
+
// decoded payloads together during restore.
|
|
647
|
+
const generationKeys: string[] = [];
|
|
648
|
+
for await (const [key, input] of store.iterator()) {
|
|
649
|
+
assertOpenNotAborted(signal);
|
|
650
|
+
if (key.startsWith(GENERATION_KEY_PREFIX)) {
|
|
651
|
+
const suffix = key.slice(GENERATION_KEY_PREFIX.length);
|
|
652
|
+
if (!/^\d{20}$/.test(suffix)) {
|
|
653
|
+
throw new Error("Malformed policy-anchor generation key");
|
|
654
|
+
}
|
|
655
|
+
if (
|
|
656
|
+
!(input instanceof Uint8Array) ||
|
|
657
|
+
input.byteLength === 0 ||
|
|
658
|
+
input.byteLength >
|
|
659
|
+
TRUSTED_NETWORK_V2_MAX_POLICY_ANCHOR_GENERATION_BYTES
|
|
660
|
+
) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
"Policy-anchor generation record exceeds its byte ceiling",
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
const generation = BigInt(suffix);
|
|
666
|
+
if (generation === 0n || generation > MAX_U64) {
|
|
667
|
+
throw new Error("Policy-anchor generation key is outside u64");
|
|
668
|
+
}
|
|
669
|
+
generationKeys.push(key);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (
|
|
673
|
+
key === TRUSTED_NETWORK_V2_POLICY_ANCHOR_STORE_OWNER ||
|
|
674
|
+
key.startsWith(`${TRUSTED_NETWORK_V2_POLICY_ANCHOR_STORE_OWNER}/`)
|
|
675
|
+
) {
|
|
676
|
+
throw new Error("Unknown policy-anchor record in the owned namespace");
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
assertOpenNotAborted(signal);
|
|
680
|
+
|
|
681
|
+
generationKeys.sort();
|
|
682
|
+
let previousChecksum = copyBytes(ZERO_DIGEST);
|
|
683
|
+
let latestCoreBytes: Uint8Array | undefined;
|
|
684
|
+
let latestDurableState:
|
|
685
|
+
| Exclude<PolicyReducerDurableStateV2, { state: "EMPTY" }>
|
|
686
|
+
| undefined;
|
|
687
|
+
let core: TrustedNetworkV2PolicyReducer | undefined;
|
|
688
|
+
let durableCoreBytes: Uint8Array | undefined;
|
|
689
|
+
let forkEvidence: PolicyForkEvidenceV2 | undefined;
|
|
690
|
+
const observedHashes = new Set<string>();
|
|
691
|
+
const authenticatedEvidenceEntryByHash = new Map<string, Uint8Array>();
|
|
692
|
+
const canonicalChildren: CanonicalForkChildV2[] = [];
|
|
693
|
+
try {
|
|
694
|
+
for (let index = 0; index < generationKeys.length; index++) {
|
|
695
|
+
assertOpenNotAborted(signal);
|
|
696
|
+
const key = generationKeys[index]!;
|
|
697
|
+
const expectedGeneration = BigInt(index + 1);
|
|
698
|
+
const keyedGeneration = BigInt(key.slice(GENERATION_KEY_PREFIX.length));
|
|
699
|
+
if (keyedGeneration !== expectedGeneration) {
|
|
700
|
+
throw new Error("Policy-anchor generation history is gapped");
|
|
701
|
+
}
|
|
702
|
+
const input = await store.get(key);
|
|
703
|
+
assertOpenNotAborted(signal);
|
|
704
|
+
if (input === undefined) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
"Policy-anchor generation disappeared during restore",
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
const { record, payload } = decodeGenerationRecord(input);
|
|
710
|
+
if (
|
|
711
|
+
record.body.generation !== keyedGeneration ||
|
|
712
|
+
generationKey(record.body.generation) !== key
|
|
713
|
+
) {
|
|
714
|
+
throw new Error("Policy-anchor generation key does not match record");
|
|
715
|
+
}
|
|
716
|
+
if (!equals(record.body.descriptorDigest, expectedDescriptorHash)) {
|
|
717
|
+
throw new Error(
|
|
718
|
+
"Policy-anchor generation belongs to another descriptor",
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
if (!equals(record.body.previousGenerationChecksum, previousChecksum)) {
|
|
722
|
+
throw new Error("Policy-anchor generation checksum chain is broken");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (payload.kind === "state") {
|
|
726
|
+
if (latestDurableState?.state === "FORKED") {
|
|
727
|
+
throw new Error(
|
|
728
|
+
"A FORKED policy anchor may only append observation deltas",
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
latestCoreBytes = serialize(payload.payload.coreState);
|
|
732
|
+
latestDurableState = payload.durableState;
|
|
733
|
+
if (latestDurableState.state === "FORKED") {
|
|
734
|
+
core = await TrustedNetworkV2PolicyReducer.restore({
|
|
735
|
+
...coreProperties,
|
|
736
|
+
durableState: latestDurableState,
|
|
737
|
+
});
|
|
738
|
+
assertOpenNotAborted(signal);
|
|
739
|
+
assertCanonicalCoreRestore(core, latestCoreBytes);
|
|
740
|
+
durableCoreBytes = copyBytes(latestCoreBytes);
|
|
741
|
+
forkEvidence = core.forkEvidence;
|
|
742
|
+
if (forkEvidence === undefined) {
|
|
743
|
+
throw new Error(
|
|
744
|
+
"Restored forked policy anchor has no fork evidence",
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
for (const child of forkEvidence.children) {
|
|
748
|
+
const hashKey = bytesKey(
|
|
749
|
+
observationContentHash(child.entryBytes),
|
|
750
|
+
);
|
|
751
|
+
const retained = authenticatedEvidenceEntryByHash.get(hashKey);
|
|
752
|
+
if (
|
|
753
|
+
retained !== undefined &&
|
|
754
|
+
!equals(retained, child.entryBytes)
|
|
755
|
+
) {
|
|
756
|
+
throw new Error(
|
|
757
|
+
"Policy fork-observation content hash collision",
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
authenticatedEvidenceEntryByHash.set(hashKey, child.entryBytes);
|
|
761
|
+
observedHashes.add(hashKey);
|
|
762
|
+
retainCanonicalForkChild(canonicalChildren, child);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
if (
|
|
767
|
+
latestDurableState?.state !== "FORKED" ||
|
|
768
|
+
core === undefined ||
|
|
769
|
+
forkEvidence === undefined
|
|
770
|
+
) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
"Policy fork-observation delta requires a preceding FORKED state",
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
const entryBytes = payload.payload.entryBytes;
|
|
776
|
+
const hashKey = bytesKey(observationContentHash(entryBytes));
|
|
777
|
+
if (observedHashes.has(hashKey)) {
|
|
778
|
+
const evidenceEntry = authenticatedEvidenceEntryByHash.get(hashKey);
|
|
779
|
+
if (
|
|
780
|
+
evidenceEntry !== undefined &&
|
|
781
|
+
!equals(evidenceEntry, entryBytes)
|
|
782
|
+
) {
|
|
783
|
+
throw new Error("Policy fork-observation content hash collision");
|
|
784
|
+
}
|
|
785
|
+
} else {
|
|
786
|
+
const authenticated = await authenticatePolicySnapshotEntryV2(
|
|
787
|
+
entryBytes,
|
|
788
|
+
descriptor,
|
|
789
|
+
);
|
|
790
|
+
assertOpenNotAborted(signal);
|
|
791
|
+
if (
|
|
792
|
+
authenticated.body.sequence !==
|
|
793
|
+
forkEvidence.commonParent.sequence + 1n ||
|
|
794
|
+
!equals(
|
|
795
|
+
authenticated.body.previousPolicyDigest,
|
|
796
|
+
forkEvidence.commonParent.digest,
|
|
797
|
+
)
|
|
798
|
+
) {
|
|
799
|
+
throw new Error(
|
|
800
|
+
"Stored policy fork observation is not a direct child of the common parent",
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
observedHashes.add(hashKey);
|
|
804
|
+
retainCanonicalForkChild(canonicalChildren, {
|
|
805
|
+
digest: authenticated.digest,
|
|
806
|
+
entryBytes,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
previousChecksum = copyBytes(record.checksum);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const highestGeneration =
|
|
814
|
+
generationKeys.length === 0 ? undefined : BigInt(generationKeys.length);
|
|
815
|
+
if (highestGeneration !== undefined && latestCoreBytes === undefined) {
|
|
816
|
+
throw new Error(
|
|
817
|
+
"Policy-anchor history has no durable state generation",
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
if (core === undefined) {
|
|
821
|
+
if (highestGeneration === undefined) {
|
|
822
|
+
core = new TrustedNetworkV2PolicyReducer(coreProperties);
|
|
823
|
+
} else {
|
|
824
|
+
core = await TrustedNetworkV2PolicyReducer.restore({
|
|
825
|
+
...coreProperties,
|
|
826
|
+
durableState: latestDurableState!,
|
|
827
|
+
});
|
|
828
|
+
assertOpenNotAborted(signal);
|
|
829
|
+
assertCanonicalCoreRestore(core, latestCoreBytes!);
|
|
830
|
+
durableCoreBytes = copyBytes(latestCoreBytes!);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (latestDurableState?.state === "FORKED") {
|
|
835
|
+
if (forkEvidence === undefined) {
|
|
836
|
+
throw new Error("Restored forked policy anchor has no fork evidence");
|
|
837
|
+
}
|
|
838
|
+
if (canonicalChildren.length !== 2) {
|
|
839
|
+
throw new Error(
|
|
840
|
+
"Stored policy fork evidence has fewer than two distinct children",
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
const normalizedForkState: Extract<
|
|
844
|
+
PolicyReducerDurableStateV2,
|
|
845
|
+
{ state: "FORKED" }
|
|
846
|
+
> = {
|
|
847
|
+
formatVersion: FORMAT_VERSION,
|
|
848
|
+
state: "FORKED",
|
|
849
|
+
commonParentEntryBytes: copyBytes(
|
|
850
|
+
latestDurableState.commonParentEntryBytes,
|
|
851
|
+
),
|
|
852
|
+
childEntryBytes: [
|
|
853
|
+
copyBytes(canonicalChildren[0]!.entryBytes),
|
|
854
|
+
copyBytes(canonicalChildren[1]!.entryBytes),
|
|
855
|
+
],
|
|
856
|
+
};
|
|
857
|
+
const pairChanged = canonicalChildren.some(
|
|
858
|
+
(child, index) =>
|
|
859
|
+
!equals(child.digest, forkEvidence!.children[index]!.digest) ||
|
|
860
|
+
!equals(
|
|
861
|
+
child.entryBytes,
|
|
862
|
+
forkEvidence!.children[index]!.entryBytes,
|
|
863
|
+
),
|
|
864
|
+
);
|
|
865
|
+
if (pairChanged) {
|
|
866
|
+
core.abort();
|
|
867
|
+
core = await TrustedNetworkV2PolicyReducer.restore({
|
|
868
|
+
...coreProperties,
|
|
869
|
+
durableState: normalizedForkState,
|
|
870
|
+
});
|
|
871
|
+
assertOpenNotAborted(signal);
|
|
872
|
+
assertCanonicalCoreRestore(
|
|
873
|
+
core,
|
|
874
|
+
serialize(coreRecordFromState(normalizedForkState)),
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
latestDurableState = normalizedForkState;
|
|
878
|
+
durableCoreBytes = serialize(coreRecordFromState(normalizedForkState));
|
|
879
|
+
} else if (observedHashes.size !== 0) {
|
|
880
|
+
throw new Error("Non-forked policy anchor has fork observations");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return new TrustedNetworkV2DurablePolicyReducer({
|
|
884
|
+
store,
|
|
885
|
+
durability,
|
|
886
|
+
descriptor,
|
|
887
|
+
core,
|
|
888
|
+
generation: highestGeneration,
|
|
889
|
+
previousGenerationChecksum: previousChecksum,
|
|
890
|
+
durableCoreBytes,
|
|
891
|
+
observedHashes,
|
|
892
|
+
});
|
|
893
|
+
} catch (error) {
|
|
894
|
+
core?.abort();
|
|
895
|
+
throw error;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
get state(): "EMPTY" | "ACTIVE" | "UNAVAILABLE" | "FORKED" | "HALTED" {
|
|
900
|
+
if (this.terminalError !== undefined || this.core.state === "HALTED") {
|
|
901
|
+
return "HALTED";
|
|
902
|
+
}
|
|
903
|
+
return this.published.state;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
get head(): PolicyHeadProjectionV2 | undefined {
|
|
907
|
+
return copyHead(this.published.head);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
get forkEvidence(): PolicyForkEvidenceV2 | undefined {
|
|
911
|
+
return copyForkEvidence(this.published.forkEvidence);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
get pendingCount(): number {
|
|
915
|
+
return this.core.pendingCount;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
get pendingBytes(): number {
|
|
919
|
+
return this.core.pendingBytes;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
get pendingDigests(): Uint8Array[] {
|
|
923
|
+
return this.core.pendingDigests.map(copyBytes);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Projection query only; use isAuthorized() for the fail-closed gate. */
|
|
927
|
+
rolesFor(subject: PublicSignKey): number {
|
|
928
|
+
return this.published.roles.get(keyId(subject)) ?? 0;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
isAuthorized(subject: PublicSignKey, roles: number): boolean {
|
|
932
|
+
if (
|
|
933
|
+
this.authorizationFences !== 0 ||
|
|
934
|
+
this.state !== "ACTIVE" ||
|
|
935
|
+
!Number.isInteger(roles) ||
|
|
936
|
+
roles === 0 ||
|
|
937
|
+
(roles & ~TRUSTED_NETWORK_V2_KNOWN_ROLE_BITS) !== 0
|
|
938
|
+
) {
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
return (this.rolesFor(subject) & roles) === roles;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
abort(): void {
|
|
945
|
+
this.core.abort();
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
ingest(entryBytes: Uint8Array): Promise<PolicyAdmissionResultV2> {
|
|
949
|
+
const captured =
|
|
950
|
+
entryBytes instanceof Uint8Array &&
|
|
951
|
+
entryBytes.byteLength <= TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES
|
|
952
|
+
? copyBytes(entryBytes)
|
|
953
|
+
: entryBytes;
|
|
954
|
+
return this.enqueue(async () => {
|
|
955
|
+
const result = await this.core.ingest(captured);
|
|
956
|
+
await this.persistCorePublication(result);
|
|
957
|
+
return result;
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
retryUnavailable(): Promise<PolicyAdmissionResultV2> {
|
|
962
|
+
return this.enqueue(async () => {
|
|
963
|
+
const result = await this.core.retryUnavailable();
|
|
964
|
+
await this.persistCorePublication(result);
|
|
965
|
+
return result;
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
|
970
|
+
this.authorizationFences++;
|
|
971
|
+
const result = this.operationTail.then(async () => {
|
|
972
|
+
if (this.terminalError !== undefined) throw this.terminalError;
|
|
973
|
+
try {
|
|
974
|
+
return await operation();
|
|
975
|
+
} catch (error) {
|
|
976
|
+
throw this.halt(error);
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
this.operationTail = result.then(
|
|
980
|
+
(): void => {},
|
|
981
|
+
(): void => {},
|
|
982
|
+
);
|
|
983
|
+
return result.finally(() => {
|
|
984
|
+
this.authorizationFences--;
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
private halt(error: unknown): Error {
|
|
989
|
+
if (this.terminalError !== undefined) return this.terminalError;
|
|
990
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
991
|
+
const terminal = new Error(
|
|
992
|
+
`TrustedNetwork v2 durable policy publication is ambiguous and halted: ${cause.message}`,
|
|
993
|
+
);
|
|
994
|
+
terminal.cause = cause;
|
|
995
|
+
this.terminalError = terminal;
|
|
996
|
+
this.core.abort();
|
|
997
|
+
return terminal;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
private async persistCorePublication(
|
|
1001
|
+
result: PolicyAdmissionResultV2,
|
|
1002
|
+
): Promise<void> {
|
|
1003
|
+
const durableState = this.core.exportDurableState();
|
|
1004
|
+
if (durableState.state === "EMPTY") {
|
|
1005
|
+
if (this.durableCoreBytes !== undefined) {
|
|
1006
|
+
throw this.halt("Durable policy state cannot return to EMPTY");
|
|
1007
|
+
}
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const coreRecord = coreRecordFromState(durableState);
|
|
1012
|
+
const coreBytes = serialize(coreRecord);
|
|
1013
|
+
// FORKED is terminal. Once its common-parent anchor exists, later child
|
|
1014
|
+
// proofs are observation deltas even when they change the live canonical
|
|
1015
|
+
// pair; reopen derives that pair from the complete delta history.
|
|
1016
|
+
const stateChanged =
|
|
1017
|
+
!(this.published.state === "FORKED" && durableState.state === "FORKED") &&
|
|
1018
|
+
(this.durableCoreBytes === undefined ||
|
|
1019
|
+
!equals(this.durableCoreBytes, coreBytes));
|
|
1020
|
+
const suppliedObservations = new Map<string, Uint8Array>();
|
|
1021
|
+
for (const proof of result.forkObservations ?? []) {
|
|
1022
|
+
const contentHash = observationContentHash(proof.entryBytes);
|
|
1023
|
+
const hashKey = bytesKey(contentHash);
|
|
1024
|
+
const retained = suppliedObservations.get(hashKey);
|
|
1025
|
+
if (retained !== undefined && !equals(retained, proof.entryBytes)) {
|
|
1026
|
+
throw this.halt("Policy fork-observation content hash collision");
|
|
1027
|
+
}
|
|
1028
|
+
suppliedObservations.set(hashKey, copyBytes(proof.entryBytes));
|
|
1029
|
+
}
|
|
1030
|
+
if (durableState.state !== "FORKED") {
|
|
1031
|
+
if (suppliedObservations.size !== 0 || this.observedHashes.size !== 0) {
|
|
1032
|
+
throw this.halt("Only a forked policy anchor may contain observations");
|
|
1033
|
+
}
|
|
1034
|
+
} else {
|
|
1035
|
+
for (const entryBytes of durableState.childEntryBytes) {
|
|
1036
|
+
const hashKey = bytesKey(observationContentHash(entryBytes));
|
|
1037
|
+
if (stateChanged) suppliedObservations.delete(hashKey);
|
|
1038
|
+
else if (
|
|
1039
|
+
!this.observedHashes.has(hashKey) &&
|
|
1040
|
+
!suppliedObservations.has(hashKey)
|
|
1041
|
+
) {
|
|
1042
|
+
throw this.halt(
|
|
1043
|
+
"Published fork state is missing a canonical observation",
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
for (const hashKey of this.observedHashes) {
|
|
1049
|
+
suppliedObservations.delete(hashKey);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const observationEntries = [...suppliedObservations.entries()]
|
|
1053
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
1054
|
+
.map(([hashKey, entryBytes]) => ({ hashKey, entryBytes }));
|
|
1055
|
+
const recordCount = (stateChanged ? 1 : 0) + observationEntries.length;
|
|
1056
|
+
if (recordCount === 0) return;
|
|
1057
|
+
if (BigInt(recordCount) > MAX_U64 - this.generation) {
|
|
1058
|
+
throw this.halt("Policy-anchor generation exhausted u64");
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
let nextGeneration = this.generation;
|
|
1062
|
+
let previousChecksum = copyBytes(this.previousGenerationChecksum);
|
|
1063
|
+
const stagedObservedHashes = new Set(this.observedHashes);
|
|
1064
|
+
const drafts: Array<{
|
|
1065
|
+
kind: number;
|
|
1066
|
+
payloadBytes: Uint8Array;
|
|
1067
|
+
applyObservationHash?: string;
|
|
1068
|
+
}> = [];
|
|
1069
|
+
if (stateChanged) {
|
|
1070
|
+
const payloadBytes = serialize(
|
|
1071
|
+
new PolicyAnchorStateGenerationPayloadV2({ coreState: coreRecord }),
|
|
1072
|
+
);
|
|
1073
|
+
if (payloadBytes.byteLength > MAX_STATE_GENERATION_PAYLOAD_BYTES) {
|
|
1074
|
+
throw this.halt("Policy-anchor state payload exceeds its byte ceiling");
|
|
1075
|
+
}
|
|
1076
|
+
drafts.push({ kind: GENERATION_KIND.STATE, payloadBytes });
|
|
1077
|
+
}
|
|
1078
|
+
for (const { hashKey, entryBytes } of observationEntries) {
|
|
1079
|
+
const payloadBytes = serialize(
|
|
1080
|
+
new PolicyAnchorObservationGenerationPayloadV2({
|
|
1081
|
+
entryBytes: copyBytes(entryBytes),
|
|
1082
|
+
}),
|
|
1083
|
+
);
|
|
1084
|
+
if (payloadBytes.byteLength > MAX_OBSERVATION_GENERATION_PAYLOAD_BYTES) {
|
|
1085
|
+
throw this.halt(
|
|
1086
|
+
"Policy fork-observation payload exceeds its byte ceiling",
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
drafts.push({
|
|
1090
|
+
kind: GENERATION_KIND.FORK_OBSERVATION,
|
|
1091
|
+
payloadBytes,
|
|
1092
|
+
applyObservationHash: hashKey,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
for (const draft of drafts) {
|
|
1097
|
+
nextGeneration += 1n;
|
|
1098
|
+
if (draft.kind === GENERATION_KIND.STATE) {
|
|
1099
|
+
if (durableState.state === "FORKED") {
|
|
1100
|
+
for (const entryBytes of durableState.childEntryBytes) {
|
|
1101
|
+
stagedObservedHashes.add(
|
|
1102
|
+
bytesKey(observationContentHash(entryBytes)),
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
} else {
|
|
1107
|
+
stagedObservedHashes.add(draft.applyObservationHash!);
|
|
1108
|
+
}
|
|
1109
|
+
const body = new PolicyAnchorGenerationBodyV2({
|
|
1110
|
+
formatVersion: FORMAT_VERSION,
|
|
1111
|
+
generation: nextGeneration,
|
|
1112
|
+
descriptorDigest: copyBytes(this.descriptorHash),
|
|
1113
|
+
previousGenerationChecksum: copyBytes(previousChecksum),
|
|
1114
|
+
kind: draft.kind,
|
|
1115
|
+
payloadBytes: copyBytes(draft.payloadBytes),
|
|
1116
|
+
});
|
|
1117
|
+
const record = new PolicyAnchorGenerationRecordV2({
|
|
1118
|
+
body,
|
|
1119
|
+
checksum: generationChecksum(body),
|
|
1120
|
+
});
|
|
1121
|
+
const recordBytes = serialize(record);
|
|
1122
|
+
if (
|
|
1123
|
+
recordBytes.byteLength >
|
|
1124
|
+
TRUSTED_NETWORK_V2_MAX_POLICY_ANCHOR_GENERATION_BYTES
|
|
1125
|
+
) {
|
|
1126
|
+
throw this.halt(
|
|
1127
|
+
"Policy-anchor generation record exceeds its byte ceiling",
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const key = generationKey(nextGeneration);
|
|
1132
|
+
if ((await this.store.get(key)) !== undefined) {
|
|
1133
|
+
throw this.halt("Policy-anchor generation must never be overwritten");
|
|
1134
|
+
}
|
|
1135
|
+
await this.store.put(key, copyBytes(recordBytes));
|
|
1136
|
+
// Each immutable generation gets one physical fence. The projection is
|
|
1137
|
+
// published only after every state/observation delta is fenced.
|
|
1138
|
+
await this.durability.barrier();
|
|
1139
|
+
previousChecksum = copyBytes(record.checksum);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
this.generation = nextGeneration;
|
|
1143
|
+
this.previousGenerationChecksum = previousChecksum;
|
|
1144
|
+
this.durableCoreBytes = copyBytes(coreBytes);
|
|
1145
|
+
this.observedHashes = stagedObservedHashes;
|
|
1146
|
+
try {
|
|
1147
|
+
this.published = publishedFromReducer(this.core);
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
throw this.halt(error);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|