@fluxpointstudios/orynq-sdk-process-trace 0.2.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.
@@ -0,0 +1,366 @@
1
+ /**
2
+ * @fileoverview Rolling hash computation for trace events using domain-separated SHA-256.
3
+ *
4
+ * Location: packages/process-trace/src/rolling-hash.ts
5
+ *
6
+ * This module implements cryptographic rolling hash computation for the process-trace
7
+ * package. Rolling hashes provide tamper-evident sequencing of trace events, ensuring
8
+ * that any modification to the event sequence is detectable.
9
+ *
10
+ * Domain Separation:
11
+ * - Event hashes use prefix "poi-trace:event:v1|" to prevent cross-context collisions
12
+ * - Rolling hashes use prefix "poi-trace:roll:v1|" for chain linking
13
+ * - Root hashes use prefix "poi-trace:root:v1|" for final commitment
14
+ *
15
+ * The rolling hash forms a hash chain: each hash incorporates the previous hash,
16
+ * creating an ordered, tamper-evident sequence. This is similar to blockchain
17
+ * block linking but at the event level.
18
+ *
19
+ * Used by:
20
+ * - TraceBuilder: Incrementally updates rolling hash as events are added
21
+ * - TraceBundle: Computes final root hash for the complete trace
22
+ * - TraceVerifier: Validates that event sequences have not been tampered with
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * // Initialize rolling hash state
27
+ * const state = await initRollingHash();
28
+ *
29
+ * // Add events incrementally
30
+ * for (const event of events) {
31
+ * const eventHash = await computeEventHash(event);
32
+ * state = await updateRollingHash(state, eventHash);
33
+ * }
34
+ *
35
+ * // Or compute in batch
36
+ * const finalHash = await computeRollingHash(events);
37
+ *
38
+ * // Compute root hash including span hashes
39
+ * const rootHash = await computeRootHash(finalHash, spans);
40
+ * ```
41
+ */
42
+
43
+ import {
44
+ sha256StringHex,
45
+ canonicalize,
46
+ } from "@fluxpointstudios/orynq-sdk-core/utils";
47
+
48
+ import type { RollingHashState, TraceEvent, TraceSpan } from "./types.js";
49
+ import { HASH_DOMAIN_PREFIXES } from "./types.js";
50
+
51
+ // -----------------------------------------------------------------------------
52
+ // Constants
53
+ // -----------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Genesis seed for the initial rolling hash state.
57
+ * The first rolling hash is H("poi-trace:roll:v1|genesis").
58
+ */
59
+ const GENESIS_SEED = "genesis";
60
+
61
+ // -----------------------------------------------------------------------------
62
+ // Event Hash Functions
63
+ // -----------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Compute hash for a single event using domain separation.
67
+ *
68
+ * The event hash is computed as:
69
+ * `H("poi-trace:event:v1|" + canonicalize(eventWithoutHash))`
70
+ *
71
+ * The 'hash' field is removed before hashing to avoid circularity - otherwise
72
+ * computing the hash would require knowing the hash.
73
+ *
74
+ * @param event - The trace event to hash
75
+ * @returns Promise resolving to the event hash as a lowercase hex string
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * const event: TraceEvent = {
80
+ * kind: "command",
81
+ * id: "550e8400-e29b-41d4-a716-446655440000",
82
+ * seq: 1,
83
+ * timestamp: "2024-01-15T10:30:00.000Z",
84
+ * visibility: "public",
85
+ * command: "npm install",
86
+ * };
87
+ * const hash = await computeEventHash(event);
88
+ * // Returns 64-character hex string
89
+ * ```
90
+ */
91
+ export async function computeEventHash(event: TraceEvent): Promise<string> {
92
+ // Create a copy without the hash field to avoid circularity
93
+ const eventWithoutHash = removeHashField(event);
94
+
95
+ // Canonicalize for deterministic serialization
96
+ const canonical = canonicalize(eventWithoutHash);
97
+
98
+ // Apply domain separation and hash
99
+ const prefixedData = HASH_DOMAIN_PREFIXES.event + canonical;
100
+
101
+ return sha256StringHex(prefixedData);
102
+ }
103
+
104
+ /**
105
+ * Remove the 'hash' field from an event object.
106
+ * Returns a shallow copy with all fields except 'hash'.
107
+ *
108
+ * @param event - Event to process
109
+ * @returns Event copy without the hash field
110
+ */
111
+ function removeHashField<T extends { hash?: string }>(event: T): Omit<T, "hash"> {
112
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
113
+ const { hash: _, ...rest } = event;
114
+ return rest;
115
+ }
116
+
117
+ // -----------------------------------------------------------------------------
118
+ // Rolling Hash State Management
119
+ // -----------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Initialize rolling hash state with the genesis hash.
123
+ *
124
+ * The genesis hash is computed as:
125
+ * `H("poi-trace:roll:v1|genesis")`
126
+ *
127
+ * This provides a well-known starting point for all rolling hash chains,
128
+ * ensuring that empty traces have a deterministic hash value.
129
+ *
130
+ * @returns Promise resolving to the initial rolling hash state
131
+ *
132
+ * @example
133
+ * ```typescript
134
+ * const state = await initRollingHash();
135
+ * console.log(state.currentHash); // Genesis hash
136
+ * console.log(state.itemCount); // 0
137
+ * ```
138
+ */
139
+ export async function initRollingHash(): Promise<RollingHashState> {
140
+ const genesisInput = HASH_DOMAIN_PREFIXES.roll + GENESIS_SEED;
141
+ const genesisHash = await sha256StringHex(genesisInput);
142
+
143
+ return {
144
+ currentHash: genesisHash,
145
+ itemCount: 0,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Update rolling hash state with a new event hash.
151
+ *
152
+ * The new rolling hash is computed as:
153
+ * `H("poi-trace:roll:v1|" + prevHash + "|" + eventHash)`
154
+ *
155
+ * This creates a hash chain where each hash depends on all previous hashes,
156
+ * making it impossible to modify earlier events without invalidating all
157
+ * subsequent hashes.
158
+ *
159
+ * @param state - Current rolling hash state
160
+ * @param eventHash - Hash of the event to add (from computeEventHash)
161
+ * @returns Promise resolving to the updated rolling hash state
162
+ *
163
+ * @example
164
+ * ```typescript
165
+ * let state = await initRollingHash();
166
+ *
167
+ * const eventHash = await computeEventHash(event);
168
+ * state = await updateRollingHash(state, eventHash);
169
+ *
170
+ * console.log(state.currentHash); // New rolling hash
171
+ * console.log(state.itemCount); // 1
172
+ * ```
173
+ */
174
+ export async function updateRollingHash(
175
+ state: RollingHashState,
176
+ eventHash: string
177
+ ): Promise<RollingHashState> {
178
+ // Construct the input: prefix + prevHash + "|" + eventHash
179
+ const input = HASH_DOMAIN_PREFIXES.roll + state.currentHash + "|" + eventHash;
180
+ const newHash = await sha256StringHex(input);
181
+
182
+ return {
183
+ currentHash: newHash,
184
+ itemCount: state.itemCount + 1,
185
+ };
186
+ }
187
+
188
+ // -----------------------------------------------------------------------------
189
+ // Batch Rolling Hash Computation
190
+ // -----------------------------------------------------------------------------
191
+
192
+ /**
193
+ * Compute rolling hash for a sequence of events (batch mode).
194
+ *
195
+ * This function processes all events and returns the final rolling hash.
196
+ * Events are sorted by their `seq` field before processing to ensure
197
+ * deterministic ordering.
198
+ *
199
+ * The result is identical to calling `updateRollingHash` sequentially
200
+ * for each event, making it suitable for verification.
201
+ *
202
+ * @param events - Array of trace events to hash
203
+ * @returns Promise resolving to the final rolling hash as a hex string
204
+ *
205
+ * @example
206
+ * ```typescript
207
+ * const events: TraceEvent[] = [
208
+ * { kind: "command", seq: 1, ... },
209
+ * { kind: "output", seq: 2, ... },
210
+ * { kind: "decision", seq: 3, ... },
211
+ * ];
212
+ *
213
+ * const finalHash = await computeRollingHash(events);
214
+ * ```
215
+ */
216
+ export async function computeRollingHash(events: TraceEvent[]): Promise<string> {
217
+ // Sort events by seq to ensure deterministic ordering
218
+ const sortedEvents = [...events].sort((a, b) => a.seq - b.seq);
219
+
220
+ // Initialize with genesis hash
221
+ let state = await initRollingHash();
222
+
223
+ // Process each event in sequence
224
+ for (const event of sortedEvents) {
225
+ const eventHash = await computeEventHash(event);
226
+ state = await updateRollingHash(state, eventHash);
227
+ }
228
+
229
+ return state.currentHash;
230
+ }
231
+
232
+ // -----------------------------------------------------------------------------
233
+ // Verification Functions
234
+ // -----------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Verify that a rolling hash matches the expected value for given events.
238
+ *
239
+ * This function recomputes the rolling hash from the events and compares
240
+ * it to the expected value. Used to verify trace integrity.
241
+ *
242
+ * @param events - Array of trace events to verify
243
+ * @param expectedHash - The expected rolling hash value
244
+ * @returns Promise resolving to true if the hash matches, false otherwise
245
+ *
246
+ * @example
247
+ * ```typescript
248
+ * const isValid = await verifyRollingHash(events, storedRollingHash);
249
+ * if (!isValid) {
250
+ * console.error("Trace has been tampered with!");
251
+ * }
252
+ * ```
253
+ */
254
+ export async function verifyRollingHash(
255
+ events: TraceEvent[],
256
+ expectedHash: string
257
+ ): Promise<boolean> {
258
+ const computedHash = await computeRollingHash(events);
259
+ return constantTimeCompare(computedHash, expectedHash.toLowerCase());
260
+ }
261
+
262
+ /**
263
+ * Constant-time string comparison to prevent timing attacks.
264
+ *
265
+ * @param a - First string
266
+ * @param b - Second string
267
+ * @returns true if strings are equal
268
+ */
269
+ function constantTimeCompare(a: string, b: string): boolean {
270
+ if (a.length !== b.length) {
271
+ return false;
272
+ }
273
+
274
+ let result = 0;
275
+ for (let i = 0; i < a.length; i++) {
276
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
277
+ }
278
+
279
+ return result === 0;
280
+ }
281
+
282
+ // -----------------------------------------------------------------------------
283
+ // Root Hash Computation
284
+ // -----------------------------------------------------------------------------
285
+
286
+ /**
287
+ * Compute the final root hash from rolling hash and span hashes.
288
+ *
289
+ * The root hash is computed as:
290
+ * `H("poi-trace:root:v1|" + rollingHash + "|" + spanHash1 + "|" + spanHash2 + ...)`
291
+ *
292
+ * Spans are sorted by their `spanSeq` field before joining to ensure
293
+ * deterministic ordering. This creates a single commitment that covers
294
+ * both the event sequence (via rolling hash) and the span structure.
295
+ *
296
+ * @param rollingHash - The final rolling hash from all events
297
+ * @param spans - Array of trace spans (must have hash field populated)
298
+ * @returns Promise resolving to the root hash as a hex string
299
+ *
300
+ * @example
301
+ * ```typescript
302
+ * const rollingHash = await computeRollingHash(events);
303
+ * const rootHash = await computeRootHash(rollingHash, spans);
304
+ *
305
+ * // rootHash can now be published as the trace commitment
306
+ * ```
307
+ */
308
+ export async function computeRootHash(
309
+ rollingHash: string,
310
+ spans: TraceSpan[]
311
+ ): Promise<string> {
312
+ // Sort spans by spanSeq for deterministic ordering
313
+ const sortedSpans = [...spans].sort((a, b) => a.spanSeq - b.spanSeq);
314
+
315
+ // Extract span hashes in order
316
+ const spanHashes = sortedSpans.map((span) => {
317
+ if (!span.hash) {
318
+ throw new Error(`Span ${span.id} is missing hash field`);
319
+ }
320
+ return span.hash;
321
+ });
322
+
323
+ // Build the input string
324
+ // Format: prefix + rollingHash + "|" + spanHash1 + "|" + spanHash2 + ...
325
+ let input = HASH_DOMAIN_PREFIXES.root + rollingHash;
326
+
327
+ if (spanHashes.length > 0) {
328
+ input += "|" + spanHashes.join("|");
329
+ }
330
+
331
+ return sha256StringHex(input);
332
+ }
333
+
334
+ // -----------------------------------------------------------------------------
335
+ // Utility Exports for Testing
336
+ // -----------------------------------------------------------------------------
337
+
338
+ /**
339
+ * Compute event hashes for multiple events in batch.
340
+ * Useful for pre-computing hashes before building a Merkle tree.
341
+ *
342
+ * @param events - Array of trace events
343
+ * @returns Promise resolving to array of event hashes in seq order
344
+ */
345
+ export async function computeEventHashes(
346
+ events: TraceEvent[]
347
+ ): Promise<string[]> {
348
+ // Sort by seq for deterministic ordering
349
+ const sortedEvents = [...events].sort((a, b) => a.seq - b.seq);
350
+
351
+ // Compute hashes in parallel for performance
352
+ const hashPromises = sortedEvents.map((event) => computeEventHash(event));
353
+
354
+ return Promise.all(hashPromises);
355
+ }
356
+
357
+ /**
358
+ * Get the genesis hash for testing and verification.
359
+ * This is the initial hash value before any events are added.
360
+ *
361
+ * @returns Promise resolving to the genesis hash
362
+ */
363
+ export async function getGenesisHash(): Promise<string> {
364
+ const state = await initRollingHash();
365
+ return state.currentHash;
366
+ }