@fluxpointstudios/orynq-sdk-process-trace 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/dist/index.cjs +574 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +573 -9
- package/dist/index.d.ts +573 -9
- package/dist/index.js +562 -9
- package/dist/index.js.map +1 -1
- package/package.json +17 -3
- package/src/__tests__/bundle.test.ts +942 -860
- package/src/__tests__/governance-round4.test.ts +149 -0
- package/src/__tests__/governance.test.ts +382 -0
- package/src/__tests__/hardening-round2.test.ts +173 -0
- package/src/__tests__/hardening-round3.test.ts +208 -0
- package/src/__tests__/integration.test.ts +611 -611
- package/src/__tests__/merkle.test.ts +756 -756
- package/src/__tests__/model-manifest.test.ts +136 -0
- package/src/__tests__/rolling-hash.test.ts +622 -622
- package/src/__tests__/trace-builder.test.ts +1012 -1012
- package/src/__tests__/types.test.ts +420 -414
- package/src/bundle.ts +1004 -810
- package/src/disclosure.ts +527 -527
- package/src/governance.ts +711 -0
- package/src/index.ts +334 -265
- package/src/manifest.ts +687 -687
- package/src/merkle.ts +428 -428
- package/src/model-manifest.ts +0 -0
- package/src/rolling-hash.ts +375 -366
- package/src/trace-builder.ts +791 -725
- package/src/types.ts +713 -522
package/src/merkle.ts
CHANGED
|
@@ -1,428 +1,428 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Merkle tree implementation for span-level selective disclosure.
|
|
3
|
-
*
|
|
4
|
-
* Location: packages/process-trace/src/merkle.ts
|
|
5
|
-
*
|
|
6
|
-
* This module implements a span-level Merkle tree that enables selective disclosure
|
|
7
|
-
* of trace spans. Verifiers can prove inclusion of specific spans without revealing
|
|
8
|
-
* the entire trace, supporting privacy-preserving audit and compliance workflows.
|
|
9
|
-
*
|
|
10
|
-
* Domain Separation Rules:
|
|
11
|
-
* - spanHash = H("poi-trace:span:v1|" + canon(spanHeader) + "|" + eventHash1 + "|" + eventHash2 + ...)
|
|
12
|
-
* - merkleLeaf = H("poi-trace:leaf:v1|" + spanHash)
|
|
13
|
-
* - merkleNode = H("poi-trace:node:v1|" + left + "|" + right)
|
|
14
|
-
*
|
|
15
|
-
* Used by:
|
|
16
|
-
* - TraceBuilder: builds Merkle tree when finalizing traces
|
|
17
|
-
* - Verification: verifies span inclusion proofs
|
|
18
|
-
* - Selective disclosure: generates proofs for specific spans
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
sha256StringHex,
|
|
23
|
-
canonicalize,
|
|
24
|
-
} from "@fluxpointstudios/orynq-sdk-core/utils";
|
|
25
|
-
|
|
26
|
-
import type {
|
|
27
|
-
TraceMerkleTree,
|
|
28
|
-
MerkleProof,
|
|
29
|
-
TraceSpan,
|
|
30
|
-
TraceEvent,
|
|
31
|
-
} from "./types.js";
|
|
32
|
-
import { HASH_DOMAIN_PREFIXES } from "./types.js";
|
|
33
|
-
|
|
34
|
-
// =============================================================================
|
|
35
|
-
// SPAN HASH COMPUTATION
|
|
36
|
-
// =============================================================================
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Compute the hash for a span including its event hashes.
|
|
40
|
-
*
|
|
41
|
-
* The span hash is computed as:
|
|
42
|
-
* H("poi-trace:span:v1|" + canon(spanHeaderWithoutHash) + "|" + eventHash1 + "|" + eventHash2 + ...)
|
|
43
|
-
*
|
|
44
|
-
* The span header includes all fields except the `hash` field itself.
|
|
45
|
-
* Event hashes are concatenated in sequence order, joined by "|".
|
|
46
|
-
*
|
|
47
|
-
* @param span - The span to compute hash for
|
|
48
|
-
* @param eventHashes - Array of event hashes in sequence order
|
|
49
|
-
* @returns Promise resolving to the span hash as a hex string
|
|
50
|
-
*
|
|
51
|
-
* @example
|
|
52
|
-
* const spanHash = await computeSpanHash(span, ["abc123...", "def456..."]);
|
|
53
|
-
*/
|
|
54
|
-
export async function computeSpanHash(
|
|
55
|
-
span: TraceSpan,
|
|
56
|
-
eventHashes: string[]
|
|
57
|
-
): Promise<string> {
|
|
58
|
-
// Extract span header (all fields except hash)
|
|
59
|
-
const spanHeader: Omit<TraceSpan, "hash"> = {
|
|
60
|
-
id: span.id,
|
|
61
|
-
spanSeq: span.spanSeq,
|
|
62
|
-
name: span.name,
|
|
63
|
-
status: span.status,
|
|
64
|
-
visibility: span.visibility,
|
|
65
|
-
startedAt: span.startedAt,
|
|
66
|
-
eventIds: span.eventIds,
|
|
67
|
-
childSpanIds: span.childSpanIds,
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// Include optional fields only if they exist
|
|
71
|
-
if (span.parentSpanId !== undefined) {
|
|
72
|
-
(spanHeader as Record<string, unknown>).parentSpanId = span.parentSpanId;
|
|
73
|
-
}
|
|
74
|
-
if (span.endedAt !== undefined) {
|
|
75
|
-
(spanHeader as Record<string, unknown>).endedAt = span.endedAt;
|
|
76
|
-
}
|
|
77
|
-
if (span.durationMs !== undefined) {
|
|
78
|
-
(spanHeader as Record<string, unknown>).durationMs = span.durationMs;
|
|
79
|
-
}
|
|
80
|
-
if (span.metadata !== undefined) {
|
|
81
|
-
(spanHeader as Record<string, unknown>).metadata = span.metadata;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Canonicalize the span header
|
|
85
|
-
const canonicalHeader = canonicalize(spanHeader, { removeNulls: true });
|
|
86
|
-
|
|
87
|
-
// Build the hash input: prefix + canon(header) + "|" + eventHashes joined by "|"
|
|
88
|
-
let hashInput = HASH_DOMAIN_PREFIXES.span + canonicalHeader;
|
|
89
|
-
|
|
90
|
-
// Append event hashes if any exist
|
|
91
|
-
if (eventHashes.length > 0) {
|
|
92
|
-
hashInput += "|" + eventHashes.join("|");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return sha256StringHex(hashInput);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// =============================================================================
|
|
99
|
-
// MERKLE TREE BUILDING
|
|
100
|
-
// =============================================================================
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Build a Merkle tree from spans.
|
|
104
|
-
*
|
|
105
|
-
* Leaves are computed as H("poi-trace:leaf:v1|" + spanHash) in spanSeq order.
|
|
106
|
-
* Internal nodes are computed as H("poi-trace:node:v1|" + left + "|" + right).
|
|
107
|
-
*
|
|
108
|
-
* ODD-LEAF RULE: If there is an odd number of nodes at any level, the last
|
|
109
|
-
* hash is duplicated to create a balanced tree.
|
|
110
|
-
*
|
|
111
|
-
* @param spans - Array of spans (will be sorted by spanSeq)
|
|
112
|
-
* @param events - Array of all events (used to get hashes for spans)
|
|
113
|
-
* @returns Promise resolving to the complete Merkle tree
|
|
114
|
-
*
|
|
115
|
-
* @example
|
|
116
|
-
* const tree = await buildSpanMerkleTree(spans, events);
|
|
117
|
-
* console.log(tree.rootHash); // Merkle root for disclosure commitment
|
|
118
|
-
*/
|
|
119
|
-
export async function buildSpanMerkleTree(
|
|
120
|
-
spans: TraceSpan[],
|
|
121
|
-
events: TraceEvent[]
|
|
122
|
-
): Promise<TraceMerkleTree> {
|
|
123
|
-
// Handle empty spans case
|
|
124
|
-
if (spans.length === 0) {
|
|
125
|
-
return {
|
|
126
|
-
rootHash: "",
|
|
127
|
-
leafCount: 0,
|
|
128
|
-
depth: 0,
|
|
129
|
-
leafHashes: [],
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Sort spans by spanSeq for deterministic ordering
|
|
134
|
-
const sortedSpans = [...spans].sort((a, b) => a.spanSeq - b.spanSeq);
|
|
135
|
-
|
|
136
|
-
// Create a map of event ID to event for quick lookup
|
|
137
|
-
const eventMap = new Map<string, TraceEvent>();
|
|
138
|
-
for (const event of events) {
|
|
139
|
-
eventMap.set(event.id, event);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Compute leaf hashes for each span
|
|
143
|
-
const leafHashes: string[] = [];
|
|
144
|
-
for (const span of sortedSpans) {
|
|
145
|
-
// Get event hashes for this span in seq order
|
|
146
|
-
const spanEvents = span.eventIds
|
|
147
|
-
.map((id) => eventMap.get(id))
|
|
148
|
-
.filter((e): e is TraceEvent => e !== undefined)
|
|
149
|
-
.sort((a, b) => a.seq - b.seq);
|
|
150
|
-
|
|
151
|
-
const eventHashes = spanEvents.map((e) => e.hash ?? "");
|
|
152
|
-
|
|
153
|
-
// Compute span hash
|
|
154
|
-
const spanHash = await computeSpanHash(span, eventHashes);
|
|
155
|
-
|
|
156
|
-
// Compute leaf hash: H("poi-trace:leaf:v1|" + spanHash)
|
|
157
|
-
const leafHash = await sha256StringHex(HASH_DOMAIN_PREFIXES.leaf + spanHash);
|
|
158
|
-
leafHashes.push(leafHash);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Build tree bottom-up
|
|
162
|
-
const tree = await buildTreeFromLeaves(leafHashes);
|
|
163
|
-
|
|
164
|
-
return {
|
|
165
|
-
rootHash: tree.rootHash,
|
|
166
|
-
leafCount: leafHashes.length,
|
|
167
|
-
depth: tree.depth,
|
|
168
|
-
leafHashes,
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Internal helper to build the Merkle tree from leaf hashes.
|
|
174
|
-
* Returns the root hash and tree depth.
|
|
175
|
-
*/
|
|
176
|
-
async function buildTreeFromLeaves(
|
|
177
|
-
leafHashes: string[]
|
|
178
|
-
): Promise<{ rootHash: string; depth: number }> {
|
|
179
|
-
// Handle single leaf case
|
|
180
|
-
if (leafHashes.length === 1) {
|
|
181
|
-
const rootHash = leafHashes[0];
|
|
182
|
-
if (rootHash === undefined) {
|
|
183
|
-
throw new Error("Unexpected empty leaf hash array");
|
|
184
|
-
}
|
|
185
|
-
return {
|
|
186
|
-
rootHash,
|
|
187
|
-
depth: 0,
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
let currentLevel = [...leafHashes];
|
|
192
|
-
let depth = 0;
|
|
193
|
-
|
|
194
|
-
// Build tree bottom-up until we reach the root
|
|
195
|
-
while (currentLevel.length > 1) {
|
|
196
|
-
const nextLevel: string[] = [];
|
|
197
|
-
|
|
198
|
-
// Process pairs of nodes
|
|
199
|
-
for (let i = 0; i < currentLevel.length; i += 2) {
|
|
200
|
-
const left = currentLevel[i];
|
|
201
|
-
if (left === undefined) {
|
|
202
|
-
throw new Error("Unexpected undefined hash in tree level");
|
|
203
|
-
}
|
|
204
|
-
// If odd number of nodes, duplicate the last one
|
|
205
|
-
const right = i + 1 < currentLevel.length ? (currentLevel[i + 1] ?? left) : left;
|
|
206
|
-
|
|
207
|
-
// Compute parent: H("poi-trace:node:v1|" + left + "|" + right)
|
|
208
|
-
const parentHash = await sha256StringHex(
|
|
209
|
-
HASH_DOMAIN_PREFIXES.node + left + "|" + right
|
|
210
|
-
);
|
|
211
|
-
nextLevel.push(parentHash);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
currentLevel = nextLevel;
|
|
215
|
-
depth++;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const finalRoot = currentLevel[0];
|
|
219
|
-
if (finalRoot === undefined) {
|
|
220
|
-
throw new Error("Unexpected empty tree level");
|
|
221
|
-
}
|
|
222
|
-
return {
|
|
223
|
-
rootHash: finalRoot,
|
|
224
|
-
depth,
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// =============================================================================
|
|
229
|
-
// MERKLE PROOF GENERATION
|
|
230
|
-
// =============================================================================
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Generate a Merkle proof for a specific span by index.
|
|
234
|
-
*
|
|
235
|
-
* The proof contains the sibling hashes along the path from the leaf to the root,
|
|
236
|
-
* with position hints ("left" or "right") indicating which side each sibling is on.
|
|
237
|
-
*
|
|
238
|
-
* @param tree - The complete Merkle tree
|
|
239
|
-
* @param spanIndex - 0-indexed position of the span in the tree
|
|
240
|
-
* @returns MerkleProof for the specified span
|
|
241
|
-
* @throws Error if spanIndex is out of bounds
|
|
242
|
-
*
|
|
243
|
-
* @example
|
|
244
|
-
* const proof = generateMerkleProof(tree, 2);
|
|
245
|
-
* console.log(proof.siblings); // [{hash: "...", position: "right"}, ...]
|
|
246
|
-
*/
|
|
247
|
-
export function generateMerkleProof(
|
|
248
|
-
tree: TraceMerkleTree,
|
|
249
|
-
spanIndex: number
|
|
250
|
-
): MerkleProof {
|
|
251
|
-
// Validate index bounds
|
|
252
|
-
if (spanIndex < 0 || spanIndex >= tree.leafCount) {
|
|
253
|
-
throw new Error(
|
|
254
|
-
`Span index ${spanIndex} is out of bounds (0-${tree.leafCount - 1})`
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Handle single leaf case - no siblings needed
|
|
259
|
-
if (tree.leafCount === 1) {
|
|
260
|
-
const leafHash = tree.leafHashes[0];
|
|
261
|
-
if (leafHash === undefined) {
|
|
262
|
-
throw new Error("Unexpected empty leaf hash array in tree");
|
|
263
|
-
}
|
|
264
|
-
return {
|
|
265
|
-
leafHash,
|
|
266
|
-
leafIndex: 0,
|
|
267
|
-
siblings: [],
|
|
268
|
-
rootHash: tree.rootHash,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const siblings: Array<{ hash: string; position: "left" | "right" }> = [];
|
|
273
|
-
|
|
274
|
-
// We need to rebuild the tree structure to get sibling hashes
|
|
275
|
-
// Start with leaf level and work up
|
|
276
|
-
let currentLevel = [...tree.leafHashes];
|
|
277
|
-
let currentIndex = spanIndex;
|
|
278
|
-
|
|
279
|
-
while (currentLevel.length > 1) {
|
|
280
|
-
// Get sibling index and position
|
|
281
|
-
const isLeftChild = currentIndex % 2 === 0;
|
|
282
|
-
const siblingIndex = isLeftChild ? currentIndex + 1 : currentIndex - 1;
|
|
283
|
-
|
|
284
|
-
// Handle odd-leaf case: if no sibling exists, it's a duplicate of current
|
|
285
|
-
let siblingHash: string;
|
|
286
|
-
if (siblingIndex >= currentLevel.length) {
|
|
287
|
-
// This is the duplicate case - sibling is the same as current
|
|
288
|
-
const currentHash = currentLevel[currentIndex];
|
|
289
|
-
if (currentHash === undefined) {
|
|
290
|
-
throw new Error("Unexpected undefined hash at current index");
|
|
291
|
-
}
|
|
292
|
-
siblingHash = currentHash;
|
|
293
|
-
} else {
|
|
294
|
-
const hash = currentLevel[siblingIndex];
|
|
295
|
-
if (hash === undefined) {
|
|
296
|
-
throw new Error("Unexpected undefined hash at sibling index");
|
|
297
|
-
}
|
|
298
|
-
siblingHash = hash;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Position is where the sibling sits relative to current node
|
|
302
|
-
siblings.push({
|
|
303
|
-
hash: siblingHash,
|
|
304
|
-
position: isLeftChild ? "right" : "left",
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
// Build next level (synchronously since we already have hashes)
|
|
308
|
-
// We need to compute the next level to continue traversal
|
|
309
|
-
const nextLevel: string[] = [];
|
|
310
|
-
for (let i = 0; i < currentLevel.length; i += 2) {
|
|
311
|
-
const left = currentLevel[i] ?? "";
|
|
312
|
-
const right = i + 1 < currentLevel.length ? (currentLevel[i + 1] ?? left) : left;
|
|
313
|
-
// We don't actually need to compute the hash here, just track structure
|
|
314
|
-
nextLevel.push(`${left}|${right}`); // Placeholder for structure tracking
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Move up the tree
|
|
318
|
-
currentLevel = nextLevel;
|
|
319
|
-
currentIndex = Math.floor(currentIndex / 2);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const leafHash = tree.leafHashes[spanIndex];
|
|
323
|
-
if (leafHash === undefined) {
|
|
324
|
-
throw new Error(`Unexpected undefined leaf hash at index ${spanIndex}`);
|
|
325
|
-
}
|
|
326
|
-
return {
|
|
327
|
-
leafHash,
|
|
328
|
-
leafIndex: spanIndex,
|
|
329
|
-
siblings,
|
|
330
|
-
rootHash: tree.rootHash,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// =============================================================================
|
|
335
|
-
// MERKLE PROOF VERIFICATION
|
|
336
|
-
// =============================================================================
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Verify a Merkle proof against the expected root.
|
|
340
|
-
*
|
|
341
|
-
* Starting from the leaf hash, the proof is recomputed by combining with
|
|
342
|
-
* sibling hashes according to their positions. The final computed root
|
|
343
|
-
* must match the expected rootHash in the proof.
|
|
344
|
-
*
|
|
345
|
-
* @param proof - The Merkle proof to verify
|
|
346
|
-
* @returns Promise resolving to true if the proof is valid
|
|
347
|
-
*
|
|
348
|
-
* @example
|
|
349
|
-
* const valid = await verifyMerkleProof(proof);
|
|
350
|
-
* if (!valid) {
|
|
351
|
-
* throw new Error("Merkle proof verification failed");
|
|
352
|
-
* }
|
|
353
|
-
*/
|
|
354
|
-
export async function verifyMerkleProof(proof: MerkleProof): Promise<boolean> {
|
|
355
|
-
// Start with the leaf hash
|
|
356
|
-
let currentHash = proof.leafHash;
|
|
357
|
-
|
|
358
|
-
// Traverse up the tree using the sibling hashes
|
|
359
|
-
for (const sibling of proof.siblings) {
|
|
360
|
-
// Combine hashes based on sibling position
|
|
361
|
-
let left: string;
|
|
362
|
-
let right: string;
|
|
363
|
-
|
|
364
|
-
if (sibling.position === "left") {
|
|
365
|
-
// Sibling is on the left, current is on the right
|
|
366
|
-
left = sibling.hash;
|
|
367
|
-
right = currentHash;
|
|
368
|
-
} else {
|
|
369
|
-
// Sibling is on the right, current is on the left
|
|
370
|
-
left = currentHash;
|
|
371
|
-
right = sibling.hash;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// Compute parent hash: H("poi-trace:node:v1|" + left + "|" + right)
|
|
375
|
-
currentHash = await sha256StringHex(
|
|
376
|
-
HASH_DOMAIN_PREFIXES.node + left + "|" + right
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Verify computed root matches expected root
|
|
381
|
-
return currentHash === proof.rootHash;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Verify a span's inclusion using its proof and data.
|
|
386
|
-
*
|
|
387
|
-
* This function recomputes the span hash from the provided span and events,
|
|
388
|
-
* then verifies that the resulting leaf hash matches the proof and that
|
|
389
|
-
* the proof is valid against the expected root.
|
|
390
|
-
*
|
|
391
|
-
* @param proof - The Merkle proof for the span
|
|
392
|
-
* @param span - The span data to verify
|
|
393
|
-
* @param events - The events belonging to this span
|
|
394
|
-
* @returns Promise resolving to true if span is validly included
|
|
395
|
-
*
|
|
396
|
-
* @example
|
|
397
|
-
* const valid = await verifySpanInclusion(proof, span, spanEvents);
|
|
398
|
-
* if (valid) {
|
|
399
|
-
* console.log("Span is cryptographically included in the trace");
|
|
400
|
-
* }
|
|
401
|
-
*/
|
|
402
|
-
export async function verifySpanInclusion(
|
|
403
|
-
proof: MerkleProof,
|
|
404
|
-
span: TraceSpan,
|
|
405
|
-
events: TraceEvent[]
|
|
406
|
-
): Promise<boolean> {
|
|
407
|
-
// Sort events by seq to get deterministic order
|
|
408
|
-
const sortedEvents = [...events].sort((a, b) => a.seq - b.seq);
|
|
409
|
-
|
|
410
|
-
// Get event hashes in seq order
|
|
411
|
-
const eventHashes = sortedEvents.map((e) => e.hash ?? "");
|
|
412
|
-
|
|
413
|
-
// Recompute the span hash
|
|
414
|
-
const spanHash = await computeSpanHash(span, eventHashes);
|
|
415
|
-
|
|
416
|
-
// Compute the expected leaf hash
|
|
417
|
-
const computedLeafHash = await sha256StringHex(
|
|
418
|
-
HASH_DOMAIN_PREFIXES.leaf + spanHash
|
|
419
|
-
);
|
|
420
|
-
|
|
421
|
-
// Verify the leaf hash matches what's in the proof
|
|
422
|
-
if (computedLeafHash !== proof.leafHash) {
|
|
423
|
-
return false;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Verify the Merkle proof itself
|
|
427
|
-
return verifyMerkleProof(proof);
|
|
428
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Merkle tree implementation for span-level selective disclosure.
|
|
3
|
+
*
|
|
4
|
+
* Location: packages/process-trace/src/merkle.ts
|
|
5
|
+
*
|
|
6
|
+
* This module implements a span-level Merkle tree that enables selective disclosure
|
|
7
|
+
* of trace spans. Verifiers can prove inclusion of specific spans without revealing
|
|
8
|
+
* the entire trace, supporting privacy-preserving audit and compliance workflows.
|
|
9
|
+
*
|
|
10
|
+
* Domain Separation Rules:
|
|
11
|
+
* - spanHash = H("poi-trace:span:v1|" + canon(spanHeader) + "|" + eventHash1 + "|" + eventHash2 + ...)
|
|
12
|
+
* - merkleLeaf = H("poi-trace:leaf:v1|" + spanHash)
|
|
13
|
+
* - merkleNode = H("poi-trace:node:v1|" + left + "|" + right)
|
|
14
|
+
*
|
|
15
|
+
* Used by:
|
|
16
|
+
* - TraceBuilder: builds Merkle tree when finalizing traces
|
|
17
|
+
* - Verification: verifies span inclusion proofs
|
|
18
|
+
* - Selective disclosure: generates proofs for specific spans
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
sha256StringHex,
|
|
23
|
+
canonicalize,
|
|
24
|
+
} from "@fluxpointstudios/orynq-sdk-core/utils";
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
TraceMerkleTree,
|
|
28
|
+
MerkleProof,
|
|
29
|
+
TraceSpan,
|
|
30
|
+
TraceEvent,
|
|
31
|
+
} from "./types.js";
|
|
32
|
+
import { HASH_DOMAIN_PREFIXES } from "./types.js";
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// SPAN HASH COMPUTATION
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compute the hash for a span including its event hashes.
|
|
40
|
+
*
|
|
41
|
+
* The span hash is computed as:
|
|
42
|
+
* H("poi-trace:span:v1|" + canon(spanHeaderWithoutHash) + "|" + eventHash1 + "|" + eventHash2 + ...)
|
|
43
|
+
*
|
|
44
|
+
* The span header includes all fields except the `hash` field itself.
|
|
45
|
+
* Event hashes are concatenated in sequence order, joined by "|".
|
|
46
|
+
*
|
|
47
|
+
* @param span - The span to compute hash for
|
|
48
|
+
* @param eventHashes - Array of event hashes in sequence order
|
|
49
|
+
* @returns Promise resolving to the span hash as a hex string
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* const spanHash = await computeSpanHash(span, ["abc123...", "def456..."]);
|
|
53
|
+
*/
|
|
54
|
+
export async function computeSpanHash(
|
|
55
|
+
span: TraceSpan,
|
|
56
|
+
eventHashes: string[]
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
// Extract span header (all fields except hash)
|
|
59
|
+
const spanHeader: Omit<TraceSpan, "hash"> = {
|
|
60
|
+
id: span.id,
|
|
61
|
+
spanSeq: span.spanSeq,
|
|
62
|
+
name: span.name,
|
|
63
|
+
status: span.status,
|
|
64
|
+
visibility: span.visibility,
|
|
65
|
+
startedAt: span.startedAt,
|
|
66
|
+
eventIds: span.eventIds,
|
|
67
|
+
childSpanIds: span.childSpanIds,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Include optional fields only if they exist
|
|
71
|
+
if (span.parentSpanId !== undefined) {
|
|
72
|
+
(spanHeader as Record<string, unknown>).parentSpanId = span.parentSpanId;
|
|
73
|
+
}
|
|
74
|
+
if (span.endedAt !== undefined) {
|
|
75
|
+
(spanHeader as Record<string, unknown>).endedAt = span.endedAt;
|
|
76
|
+
}
|
|
77
|
+
if (span.durationMs !== undefined) {
|
|
78
|
+
(spanHeader as Record<string, unknown>).durationMs = span.durationMs;
|
|
79
|
+
}
|
|
80
|
+
if (span.metadata !== undefined) {
|
|
81
|
+
(spanHeader as Record<string, unknown>).metadata = span.metadata;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Canonicalize the span header
|
|
85
|
+
const canonicalHeader = canonicalize(spanHeader, { removeNulls: true });
|
|
86
|
+
|
|
87
|
+
// Build the hash input: prefix + canon(header) + "|" + eventHashes joined by "|"
|
|
88
|
+
let hashInput = HASH_DOMAIN_PREFIXES.span + canonicalHeader;
|
|
89
|
+
|
|
90
|
+
// Append event hashes if any exist
|
|
91
|
+
if (eventHashes.length > 0) {
|
|
92
|
+
hashInput += "|" + eventHashes.join("|");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return sha256StringHex(hashInput);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// =============================================================================
|
|
99
|
+
// MERKLE TREE BUILDING
|
|
100
|
+
// =============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build a Merkle tree from spans.
|
|
104
|
+
*
|
|
105
|
+
* Leaves are computed as H("poi-trace:leaf:v1|" + spanHash) in spanSeq order.
|
|
106
|
+
* Internal nodes are computed as H("poi-trace:node:v1|" + left + "|" + right).
|
|
107
|
+
*
|
|
108
|
+
* ODD-LEAF RULE: If there is an odd number of nodes at any level, the last
|
|
109
|
+
* hash is duplicated to create a balanced tree.
|
|
110
|
+
*
|
|
111
|
+
* @param spans - Array of spans (will be sorted by spanSeq)
|
|
112
|
+
* @param events - Array of all events (used to get hashes for spans)
|
|
113
|
+
* @returns Promise resolving to the complete Merkle tree
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* const tree = await buildSpanMerkleTree(spans, events);
|
|
117
|
+
* console.log(tree.rootHash); // Merkle root for disclosure commitment
|
|
118
|
+
*/
|
|
119
|
+
export async function buildSpanMerkleTree(
|
|
120
|
+
spans: TraceSpan[],
|
|
121
|
+
events: TraceEvent[]
|
|
122
|
+
): Promise<TraceMerkleTree> {
|
|
123
|
+
// Handle empty spans case
|
|
124
|
+
if (spans.length === 0) {
|
|
125
|
+
return {
|
|
126
|
+
rootHash: "",
|
|
127
|
+
leafCount: 0,
|
|
128
|
+
depth: 0,
|
|
129
|
+
leafHashes: [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Sort spans by spanSeq for deterministic ordering
|
|
134
|
+
const sortedSpans = [...spans].sort((a, b) => a.spanSeq - b.spanSeq);
|
|
135
|
+
|
|
136
|
+
// Create a map of event ID to event for quick lookup
|
|
137
|
+
const eventMap = new Map<string, TraceEvent>();
|
|
138
|
+
for (const event of events) {
|
|
139
|
+
eventMap.set(event.id, event);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Compute leaf hashes for each span
|
|
143
|
+
const leafHashes: string[] = [];
|
|
144
|
+
for (const span of sortedSpans) {
|
|
145
|
+
// Get event hashes for this span in seq order
|
|
146
|
+
const spanEvents = span.eventIds
|
|
147
|
+
.map((id) => eventMap.get(id))
|
|
148
|
+
.filter((e): e is TraceEvent => e !== undefined)
|
|
149
|
+
.sort((a, b) => a.seq - b.seq);
|
|
150
|
+
|
|
151
|
+
const eventHashes = spanEvents.map((e) => e.hash ?? "");
|
|
152
|
+
|
|
153
|
+
// Compute span hash
|
|
154
|
+
const spanHash = await computeSpanHash(span, eventHashes);
|
|
155
|
+
|
|
156
|
+
// Compute leaf hash: H("poi-trace:leaf:v1|" + spanHash)
|
|
157
|
+
const leafHash = await sha256StringHex(HASH_DOMAIN_PREFIXES.leaf + spanHash);
|
|
158
|
+
leafHashes.push(leafHash);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Build tree bottom-up
|
|
162
|
+
const tree = await buildTreeFromLeaves(leafHashes);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
rootHash: tree.rootHash,
|
|
166
|
+
leafCount: leafHashes.length,
|
|
167
|
+
depth: tree.depth,
|
|
168
|
+
leafHashes,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Internal helper to build the Merkle tree from leaf hashes.
|
|
174
|
+
* Returns the root hash and tree depth.
|
|
175
|
+
*/
|
|
176
|
+
async function buildTreeFromLeaves(
|
|
177
|
+
leafHashes: string[]
|
|
178
|
+
): Promise<{ rootHash: string; depth: number }> {
|
|
179
|
+
// Handle single leaf case
|
|
180
|
+
if (leafHashes.length === 1) {
|
|
181
|
+
const rootHash = leafHashes[0];
|
|
182
|
+
if (rootHash === undefined) {
|
|
183
|
+
throw new Error("Unexpected empty leaf hash array");
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
rootHash,
|
|
187
|
+
depth: 0,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let currentLevel = [...leafHashes];
|
|
192
|
+
let depth = 0;
|
|
193
|
+
|
|
194
|
+
// Build tree bottom-up until we reach the root
|
|
195
|
+
while (currentLevel.length > 1) {
|
|
196
|
+
const nextLevel: string[] = [];
|
|
197
|
+
|
|
198
|
+
// Process pairs of nodes
|
|
199
|
+
for (let i = 0; i < currentLevel.length; i += 2) {
|
|
200
|
+
const left = currentLevel[i];
|
|
201
|
+
if (left === undefined) {
|
|
202
|
+
throw new Error("Unexpected undefined hash in tree level");
|
|
203
|
+
}
|
|
204
|
+
// If odd number of nodes, duplicate the last one
|
|
205
|
+
const right = i + 1 < currentLevel.length ? (currentLevel[i + 1] ?? left) : left;
|
|
206
|
+
|
|
207
|
+
// Compute parent: H("poi-trace:node:v1|" + left + "|" + right)
|
|
208
|
+
const parentHash = await sha256StringHex(
|
|
209
|
+
HASH_DOMAIN_PREFIXES.node + left + "|" + right
|
|
210
|
+
);
|
|
211
|
+
nextLevel.push(parentHash);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
currentLevel = nextLevel;
|
|
215
|
+
depth++;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const finalRoot = currentLevel[0];
|
|
219
|
+
if (finalRoot === undefined) {
|
|
220
|
+
throw new Error("Unexpected empty tree level");
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
rootHash: finalRoot,
|
|
224
|
+
depth,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// =============================================================================
|
|
229
|
+
// MERKLE PROOF GENERATION
|
|
230
|
+
// =============================================================================
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Generate a Merkle proof for a specific span by index.
|
|
234
|
+
*
|
|
235
|
+
* The proof contains the sibling hashes along the path from the leaf to the root,
|
|
236
|
+
* with position hints ("left" or "right") indicating which side each sibling is on.
|
|
237
|
+
*
|
|
238
|
+
* @param tree - The complete Merkle tree
|
|
239
|
+
* @param spanIndex - 0-indexed position of the span in the tree
|
|
240
|
+
* @returns MerkleProof for the specified span
|
|
241
|
+
* @throws Error if spanIndex is out of bounds
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* const proof = generateMerkleProof(tree, 2);
|
|
245
|
+
* console.log(proof.siblings); // [{hash: "...", position: "right"}, ...]
|
|
246
|
+
*/
|
|
247
|
+
export function generateMerkleProof(
|
|
248
|
+
tree: TraceMerkleTree,
|
|
249
|
+
spanIndex: number
|
|
250
|
+
): MerkleProof {
|
|
251
|
+
// Validate index bounds
|
|
252
|
+
if (spanIndex < 0 || spanIndex >= tree.leafCount) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`Span index ${spanIndex} is out of bounds (0-${tree.leafCount - 1})`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Handle single leaf case - no siblings needed
|
|
259
|
+
if (tree.leafCount === 1) {
|
|
260
|
+
const leafHash = tree.leafHashes[0];
|
|
261
|
+
if (leafHash === undefined) {
|
|
262
|
+
throw new Error("Unexpected empty leaf hash array in tree");
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
leafHash,
|
|
266
|
+
leafIndex: 0,
|
|
267
|
+
siblings: [],
|
|
268
|
+
rootHash: tree.rootHash,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const siblings: Array<{ hash: string; position: "left" | "right" }> = [];
|
|
273
|
+
|
|
274
|
+
// We need to rebuild the tree structure to get sibling hashes
|
|
275
|
+
// Start with leaf level and work up
|
|
276
|
+
let currentLevel = [...tree.leafHashes];
|
|
277
|
+
let currentIndex = spanIndex;
|
|
278
|
+
|
|
279
|
+
while (currentLevel.length > 1) {
|
|
280
|
+
// Get sibling index and position
|
|
281
|
+
const isLeftChild = currentIndex % 2 === 0;
|
|
282
|
+
const siblingIndex = isLeftChild ? currentIndex + 1 : currentIndex - 1;
|
|
283
|
+
|
|
284
|
+
// Handle odd-leaf case: if no sibling exists, it's a duplicate of current
|
|
285
|
+
let siblingHash: string;
|
|
286
|
+
if (siblingIndex >= currentLevel.length) {
|
|
287
|
+
// This is the duplicate case - sibling is the same as current
|
|
288
|
+
const currentHash = currentLevel[currentIndex];
|
|
289
|
+
if (currentHash === undefined) {
|
|
290
|
+
throw new Error("Unexpected undefined hash at current index");
|
|
291
|
+
}
|
|
292
|
+
siblingHash = currentHash;
|
|
293
|
+
} else {
|
|
294
|
+
const hash = currentLevel[siblingIndex];
|
|
295
|
+
if (hash === undefined) {
|
|
296
|
+
throw new Error("Unexpected undefined hash at sibling index");
|
|
297
|
+
}
|
|
298
|
+
siblingHash = hash;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Position is where the sibling sits relative to current node
|
|
302
|
+
siblings.push({
|
|
303
|
+
hash: siblingHash,
|
|
304
|
+
position: isLeftChild ? "right" : "left",
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Build next level (synchronously since we already have hashes)
|
|
308
|
+
// We need to compute the next level to continue traversal
|
|
309
|
+
const nextLevel: string[] = [];
|
|
310
|
+
for (let i = 0; i < currentLevel.length; i += 2) {
|
|
311
|
+
const left = currentLevel[i] ?? "";
|
|
312
|
+
const right = i + 1 < currentLevel.length ? (currentLevel[i + 1] ?? left) : left;
|
|
313
|
+
// We don't actually need to compute the hash here, just track structure
|
|
314
|
+
nextLevel.push(`${left}|${right}`); // Placeholder for structure tracking
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Move up the tree
|
|
318
|
+
currentLevel = nextLevel;
|
|
319
|
+
currentIndex = Math.floor(currentIndex / 2);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const leafHash = tree.leafHashes[spanIndex];
|
|
323
|
+
if (leafHash === undefined) {
|
|
324
|
+
throw new Error(`Unexpected undefined leaf hash at index ${spanIndex}`);
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
leafHash,
|
|
328
|
+
leafIndex: spanIndex,
|
|
329
|
+
siblings,
|
|
330
|
+
rootHash: tree.rootHash,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// =============================================================================
|
|
335
|
+
// MERKLE PROOF VERIFICATION
|
|
336
|
+
// =============================================================================
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Verify a Merkle proof against the expected root.
|
|
340
|
+
*
|
|
341
|
+
* Starting from the leaf hash, the proof is recomputed by combining with
|
|
342
|
+
* sibling hashes according to their positions. The final computed root
|
|
343
|
+
* must match the expected rootHash in the proof.
|
|
344
|
+
*
|
|
345
|
+
* @param proof - The Merkle proof to verify
|
|
346
|
+
* @returns Promise resolving to true if the proof is valid
|
|
347
|
+
*
|
|
348
|
+
* @example
|
|
349
|
+
* const valid = await verifyMerkleProof(proof);
|
|
350
|
+
* if (!valid) {
|
|
351
|
+
* throw new Error("Merkle proof verification failed");
|
|
352
|
+
* }
|
|
353
|
+
*/
|
|
354
|
+
export async function verifyMerkleProof(proof: MerkleProof): Promise<boolean> {
|
|
355
|
+
// Start with the leaf hash
|
|
356
|
+
let currentHash = proof.leafHash;
|
|
357
|
+
|
|
358
|
+
// Traverse up the tree using the sibling hashes
|
|
359
|
+
for (const sibling of proof.siblings) {
|
|
360
|
+
// Combine hashes based on sibling position
|
|
361
|
+
let left: string;
|
|
362
|
+
let right: string;
|
|
363
|
+
|
|
364
|
+
if (sibling.position === "left") {
|
|
365
|
+
// Sibling is on the left, current is on the right
|
|
366
|
+
left = sibling.hash;
|
|
367
|
+
right = currentHash;
|
|
368
|
+
} else {
|
|
369
|
+
// Sibling is on the right, current is on the left
|
|
370
|
+
left = currentHash;
|
|
371
|
+
right = sibling.hash;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Compute parent hash: H("poi-trace:node:v1|" + left + "|" + right)
|
|
375
|
+
currentHash = await sha256StringHex(
|
|
376
|
+
HASH_DOMAIN_PREFIXES.node + left + "|" + right
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Verify computed root matches expected root
|
|
381
|
+
return currentHash === proof.rootHash;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Verify a span's inclusion using its proof and data.
|
|
386
|
+
*
|
|
387
|
+
* This function recomputes the span hash from the provided span and events,
|
|
388
|
+
* then verifies that the resulting leaf hash matches the proof and that
|
|
389
|
+
* the proof is valid against the expected root.
|
|
390
|
+
*
|
|
391
|
+
* @param proof - The Merkle proof for the span
|
|
392
|
+
* @param span - The span data to verify
|
|
393
|
+
* @param events - The events belonging to this span
|
|
394
|
+
* @returns Promise resolving to true if span is validly included
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* const valid = await verifySpanInclusion(proof, span, spanEvents);
|
|
398
|
+
* if (valid) {
|
|
399
|
+
* console.log("Span is cryptographically included in the trace");
|
|
400
|
+
* }
|
|
401
|
+
*/
|
|
402
|
+
export async function verifySpanInclusion(
|
|
403
|
+
proof: MerkleProof,
|
|
404
|
+
span: TraceSpan,
|
|
405
|
+
events: TraceEvent[]
|
|
406
|
+
): Promise<boolean> {
|
|
407
|
+
// Sort events by seq to get deterministic order
|
|
408
|
+
const sortedEvents = [...events].sort((a, b) => a.seq - b.seq);
|
|
409
|
+
|
|
410
|
+
// Get event hashes in seq order
|
|
411
|
+
const eventHashes = sortedEvents.map((e) => e.hash ?? "");
|
|
412
|
+
|
|
413
|
+
// Recompute the span hash
|
|
414
|
+
const spanHash = await computeSpanHash(span, eventHashes);
|
|
415
|
+
|
|
416
|
+
// Compute the expected leaf hash
|
|
417
|
+
const computedLeafHash = await sha256StringHex(
|
|
418
|
+
HASH_DOMAIN_PREFIXES.leaf + spanHash
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
// Verify the leaf hash matches what's in the proof
|
|
422
|
+
if (computedLeafHash !== proof.leafHash) {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Verify the Merkle proof itself
|
|
427
|
+
return verifyMerkleProof(proof);
|
|
428
|
+
}
|