@fluxpointstudios/orynq-sdk-process-trace 0.1.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/src/merkle.ts ADDED
@@ -0,0 +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
+ }