Skip to main content

northroot_proof_engine/
canonical.rs

1//! Canonicalization for v0.2 proof types
2//!
3//! This module provides deterministic JSON canonicalization using BTreeMap
4//! for sorted keys. All canonicalization functions produce identical output
5//! for identical inputs, ensuring hash determinism across platforms.
6
7use chrono::{DateTime, Utc};
8use std::collections::BTreeMap;
9use thiserror::Error;
10
11use crate::types::{
12    IsoTime, MethodRef, MethodShapeManifest, SpanCommitment, SpanMeta, SpanShape, SpanStatus,
13    Trace, TraceRoots,
14};
15
16/// Error returned when canonicalization fails
17#[derive(Debug, Error, PartialEq, Eq)]
18pub enum CanonicalError {
19    /// Failed to serialize to canonical form
20    #[error("serialization error: {0}")]
21    Serialization(String),
22
23    /// Invalid timestamp format
24    #[error("invalid timestamp: {0}")]
25    InvalidTimestamp(String),
26}
27
28/// Normalizes a timestamp to RFC3339 format with millisecond precision (UTC).
29///
30/// Converts any RFC3339 timestamp to UTC and normalizes to millisecond precision.
31/// Format: `YYYY-MM-DDTHH:MM:SS.sssZ` (always Z suffix, no timezone offset).
32///
33/// # Examples
34///
35/// ```
36/// use northroot_proof_engine::canonical::normalize_timestamp;
37///
38/// // UTC conversion
39/// assert_eq!(
40///     normalize_timestamp("2025-11-04T05:00:00-05:00").unwrap(),
41///     "2025-11-04T10:00:00.000Z"
42/// );
43///
44/// // Millisecond precision
45/// assert_eq!(
46///     normalize_timestamp("2025-11-04T10:00:00.123456Z").unwrap(),
47///     "2025-11-04T10:00:00.123Z"
48/// );
49/// ```
50pub fn normalize_timestamp(timestamp: &str) -> Result<IsoTime, CanonicalError> {
51    // Try parsing as RFC3339 (handles both with and without timezone offset)
52    match DateTime::parse_from_rfc3339(timestamp) {
53        Ok(dt) => {
54            // Convert to UTC
55            let utc_dt = dt.with_timezone(&Utc);
56            Ok(format_timestamp_millis(utc_dt))
57        }
58        Err(_) => {
59            // If parsing fails, try parsing as UTC directly
60            match timestamp.parse::<DateTime<Utc>>() {
61                Ok(dt) => Ok(format_timestamp_millis(dt)),
62                Err(_) => Err(CanonicalError::InvalidTimestamp(timestamp.to_string())),
63            }
64        }
65    }
66}
67
68/// Formats a UTC DateTime to RFC3339 with millisecond precision.
69fn format_timestamp_millis(dt: DateTime<Utc>) -> String {
70    let nanos = dt.timestamp_subsec_nanos();
71    let millis = nanos / 1_000_000; // Truncate to milliseconds
72    format!("{}.{:03}Z", dt.format("%Y-%m-%dT%H:%M:%S"), millis)
73}
74
75/// Canonicalizes a span shape for hashing.
76///
77/// Creates a deterministic JSON representation of the span shape.
78/// Fields are sorted lexicographically by key.
79pub fn canonicalize_span_shape(shape: &SpanShape) -> Result<String, CanonicalError> {
80    let mut map = BTreeMap::new();
81    map.insert("tool_id", serde_json::Value::String(shape.tool_id.clone()));
82    map.insert(
83        "tool_version",
84        serde_json::Value::String(shape.tool_version.clone()),
85    );
86    map.insert("param_schema", shape.param_schema.clone());
87    if let Some(ref checker_ref) = shape.checker_ref {
88        map.insert(
89            "checker_ref",
90            serde_json::Value::String(checker_ref.clone()),
91        );
92    }
93
94    serde_json::to_string(&serde_json::Value::Object(
95        map.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
96    ))
97    .map_err(|e| CanonicalError::Serialization(e.to_string()))
98}
99
100/// Canonicalizes a method shape manifest for hashing.
101///
102/// Note: This excludes the publisher_sig field (signatures are not hashed).
103pub fn canonicalize_method_shape(manifest: &MethodShapeManifest) -> Result<String, CanonicalError> {
104    let mut map = BTreeMap::new();
105    map.insert(
106        "schema_version",
107        serde_json::Value::String(manifest.schema_version.clone()),
108    );
109    map.insert(
110        "method_id",
111        serde_json::Value::String(manifest.method_id.clone()),
112    );
113    map.insert(
114        "version",
115        serde_json::Value::String(manifest.version.clone()),
116    );
117    map.insert(
118        "method_shape_root",
119        serde_json::Value::String(manifest.method_shape_root.clone()),
120    );
121    if let Some(ref dag_hash) = manifest.method_dag_hash {
122        map.insert(
123            "method_dag_hash",
124            serde_json::Value::String(dag_hash.clone()),
125        );
126    }
127
128    // Runtime pins
129    let mut runtime = BTreeMap::new();
130    runtime.insert(
131        "image_digest",
132        serde_json::Value::String(manifest.runtime.image_digest.clone()),
133    );
134    runtime.insert(
135        "method_fingerprint",
136        serde_json::Value::String(manifest.runtime.method_fingerprint.clone()),
137    );
138    map.insert(
139        "runtime",
140        serde_json::Value::Object(
141            runtime
142                .into_iter()
143                .map(|(k, v)| (k.to_string(), v))
144                .collect(),
145        ),
146    );
147
148    if !manifest.checker_refs.is_empty() {
149        map.insert(
150            "checker_refs",
151            serde_json::Value::Array(
152                manifest
153                    .checker_refs
154                    .iter()
155                    .map(|s| serde_json::Value::String(s.clone()))
156                    .collect(),
157            ),
158        );
159    }
160    if let Some(ref policy_id) = manifest.default_policy_id {
161        map.insert(
162            "default_policy_id",
163            serde_json::Value::String(policy_id.clone()),
164        );
165    }
166
167    // Note: publisher_sig is excluded (signatures are not hashed)
168
169    serde_json::to_string(&serde_json::Value::Object(
170        map.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
171    ))
172    .map_err(|e| CanonicalError::Serialization(e.to_string()))
173}
174
175/// Canonicalizes a span commitment for hashing.
176///
177/// This is the core commitment that gets hashed and signed.
178/// All timestamps are normalized to UTC with millisecond precision.
179pub fn canonicalize_span_commitment(commitment: &SpanCommitment) -> Result<String, CanonicalError> {
180    let normalized_start = normalize_timestamp(&commitment.start_time)?;
181    let normalized_end = normalize_timestamp(&commitment.end_time)?;
182
183    let mut map = BTreeMap::new();
184    map.insert(
185        "schema_version",
186        serde_json::Value::String(commitment.schema_version.clone()),
187    );
188    map.insert(
189        "trace_id",
190        serde_json::Value::String(commitment.trace_id.clone()),
191    );
192    map.insert(
193        "span_id",
194        serde_json::Value::String(commitment.span_id.clone()),
195    );
196
197    // Method ref
198    let method_ref_json = canonicalize_method_ref(&commitment.method_ref)?;
199    let method_ref_value: serde_json::Value =
200        serde_json::from_str(&method_ref_json).map_err(|e| {
201            CanonicalError::Serialization(format!("method_ref canonicalization: {}", e))
202        })?;
203    map.insert("method_ref", method_ref_value);
204
205    map.insert(
206        "data_shape_hash",
207        serde_json::Value::String(commitment.data_shape_hash.clone()),
208    );
209    map.insert(
210        "span_shape_hash",
211        serde_json::Value::String(commitment.span_shape_hash.clone()),
212    );
213
214    // Inputs
215    let inputs: Vec<serde_json::Value> = commitment
216        .inputs
217        .iter()
218        .map(|input| {
219            let mut input_map = BTreeMap::new();
220            input_map.insert("name", serde_json::Value::String(input.name.clone()));
221            input_map.insert(
222                "content_hash",
223                serde_json::Value::String(input.content_hash.clone()),
224            );
225            if let Some(ref uri) = input.uri {
226                input_map.insert("uri", serde_json::Value::String(uri.clone()));
227            }
228            serde_json::Value::Object(
229                input_map
230                    .into_iter()
231                    .map(|(k, v)| (k.to_string(), v))
232                    .collect(),
233            )
234        })
235        .collect();
236    map.insert("inputs", serde_json::Value::Array(inputs));
237
238    // Outputs
239    let outputs: Vec<serde_json::Value> = commitment
240        .outputs
241        .iter()
242        .map(|output| {
243            let mut output_map = BTreeMap::new();
244            output_map.insert("name", serde_json::Value::String(output.name.clone()));
245            output_map.insert(
246                "content_hash",
247                serde_json::Value::String(output.content_hash.clone()),
248            );
249            if let Some(ref uri) = output.uri {
250                output_map.insert("uri", serde_json::Value::String(uri.clone()));
251            }
252            serde_json::Value::Object(
253                output_map
254                    .into_iter()
255                    .map(|(k, v)| (k.to_string(), v))
256                    .collect(),
257            )
258        })
259        .collect();
260    map.insert("outputs", serde_json::Value::Array(outputs));
261
262    map.insert("start_time", serde_json::Value::String(normalized_start));
263    map.insert("end_time", serde_json::Value::String(normalized_end));
264    map.insert(
265        "status",
266        serde_json::Value::String(match commitment.status {
267            SpanStatus::OK => "OK".to_string(),
268            SpanStatus::ERROR => "ERROR".to_string(),
269        }),
270    );
271
272    if let Some(ref meta) = commitment.meta {
273        let meta_json = canonicalize_span_meta(meta)?;
274        let meta_value: serde_json::Value = serde_json::from_str(&meta_json).map_err(|e| {
275            CanonicalError::Serialization(format!("span_meta canonicalization: {}", e))
276        })?;
277        map.insert("meta", meta_value);
278    }
279
280    serde_json::to_string(&serde_json::Value::Object(
281        map.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
282    ))
283    .map_err(|e| CanonicalError::Serialization(e.to_string()))
284}
285
286/// Canonicalizes a method reference.
287fn canonicalize_method_ref(method_ref: &MethodRef) -> Result<String, CanonicalError> {
288    let mut map = BTreeMap::new();
289    map.insert(
290        "method_id",
291        serde_json::Value::String(method_ref.method_id.clone()),
292    );
293    map.insert(
294        "version",
295        serde_json::Value::String(method_ref.version.clone()),
296    );
297    map.insert(
298        "method_shape_root",
299        serde_json::Value::String(method_ref.method_shape_root.clone()),
300    );
301    map.insert(
302        "image_digest",
303        serde_json::Value::String(method_ref.image_digest.clone()),
304    );
305    map.insert(
306        "method_fingerprint",
307        serde_json::Value::String(method_ref.method_fingerprint.clone()),
308    );
309
310    serde_json::to_string(&serde_json::Value::Object(
311        map.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
312    ))
313    .map_err(|e| CanonicalError::Serialization(e.to_string()))
314}
315
316/// Canonicalizes span metadata.
317fn canonicalize_span_meta(meta: &SpanMeta) -> Result<String, CanonicalError> {
318    let mut map = BTreeMap::new();
319    if let Some(lamport) = meta.lamport {
320        map.insert("lamport", serde_json::Value::Number(lamport.into()));
321    }
322    if let Some(ref parents) = meta.parents {
323        map.insert(
324            "parents",
325            serde_json::Value::Array(
326                parents
327                    .iter()
328                    .map(|s| serde_json::Value::String(s.clone()))
329                    .collect(),
330            ),
331        );
332    }
333    if let Some(order_index) = meta.order_index {
334        map.insert("order_index", serde_json::Value::Number(order_index.into()));
335    }
336
337    serde_json::to_string(&serde_json::Value::Object(
338        map.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
339    ))
340    .map_err(|e| CanonicalError::Serialization(e.to_string()))
341}
342
343/// Canonicalizes a trace for hashing.
344pub fn canonicalize_trace(trace: &Trace) -> Result<String, CanonicalError> {
345    let mut map = BTreeMap::new();
346    map.insert(
347        "schema_version",
348        serde_json::Value::String(trace.schema_version.clone()),
349    );
350    map.insert(
351        "trace_id",
352        serde_json::Value::String(trace.trace_id.clone()),
353    );
354
355    // Method ref
356    let method_ref_json = canonicalize_method_ref(&trace.method_ref)?;
357    let method_ref_value: serde_json::Value =
358        serde_json::from_str(&method_ref_json).map_err(|e| {
359            CanonicalError::Serialization(format!("method_ref canonicalization: {}", e))
360        })?;
361    map.insert("method_ref", method_ref_value);
362
363    map.insert(
364        "data_shape_hash",
365        serde_json::Value::String(trace.data_shape_hash.clone()),
366    );
367    map.insert(
368        "policy_id",
369        serde_json::Value::String(trace.policy_id.clone()),
370    );
371    map.insert(
372        "identity_route",
373        serde_json::Value::String(trace.identity_route.clone()),
374    );
375    map.insert(
376        "content_route",
377        serde_json::Value::String(trace.content_route.clone()),
378    );
379
380    if let Some(ref exec_route) = trace.execution_route {
381        let mut exec_map = BTreeMap::new();
382        exec_map.insert(
383            "provider",
384            serde_json::Value::String(exec_route.provider.clone()),
385        );
386        if let Some(ref account) = exec_route.account {
387            exec_map.insert("account", serde_json::Value::String(account.clone()));
388        }
389        exec_map.insert(
390            "region",
391            serde_json::Value::String(exec_route.region.clone()),
392        );
393        exec_map.insert(
394            "runtime_digest",
395            serde_json::Value::String(exec_route.runtime_digest.clone()),
396        );
397        map.insert(
398            "execution_route",
399            serde_json::Value::Object(
400                exec_map
401                    .into_iter()
402                    .map(|(k, v)| (k.to_string(), v))
403                    .collect(),
404            ),
405        );
406    }
407
408    serde_json::to_string(&serde_json::Value::Object(
409        map.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
410    ))
411    .map_err(|e| CanonicalError::Serialization(e.to_string()))
412}
413
414/// Canonicalizes trace roots for hashing.
415///
416/// Note: This excludes server_sig (signatures are not hashed).
417pub fn canonicalize_trace_roots(roots: &TraceRoots) -> Result<String, CanonicalError> {
418    let mut map = BTreeMap::new();
419    map.insert(
420        "trace_id",
421        serde_json::Value::String(roots.trace_id.clone()),
422    );
423    map.insert(
424        "span_commitments",
425        serde_json::Value::Array(
426            roots
427                .span_commitments
428                .iter()
429                .map(|s| serde_json::Value::String(s.clone()))
430                .collect(),
431        ),
432    );
433    map.insert(
434        "trace_set_root",
435        serde_json::Value::String(roots.trace_set_root.clone()),
436    );
437    if let Some(ref identity_root) = roots.identity_root {
438        map.insert(
439            "identity_root",
440            serde_json::Value::String(identity_root.clone()),
441        );
442    }
443    if let Some(ref trace_seq_root) = roots.trace_seq_root {
444        map.insert(
445            "trace_seq_root",
446            serde_json::Value::String(trace_seq_root.clone()),
447        );
448    }
449    map.insert(
450        "method_shape_root",
451        serde_json::Value::String(roots.method_shape_root.clone()),
452    );
453    if let Some(ref tier01_status) = roots.tier01_status {
454        map.insert(
455            "tier01_status",
456            serde_json::Value::String(tier01_status.clone()),
457        );
458    }
459
460    // Note: server_sig is excluded (signatures are not hashed)
461
462    serde_json::to_string(&serde_json::Value::Object(
463        map.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
464    ))
465    .map_err(|e| CanonicalError::Serialization(e.to_string()))
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_normalize_timestamp_utc_conversion() {
474        let t1 = normalize_timestamp("2025-11-04T05:00:00-05:00").unwrap();
475        let t2 = normalize_timestamp("2025-11-04T10:00:00Z").unwrap();
476        assert_eq!(t1, t2);
477        assert_eq!(t1, "2025-11-04T10:00:00.000Z");
478    }
479
480    #[test]
481    fn test_normalize_timestamp_millisecond_precision() {
482        let normalized = normalize_timestamp("2025-11-04T10:00:00.123456Z").unwrap();
483        assert_eq!(normalized, "2025-11-04T10:00:00.123Z");
484    }
485
486    #[test]
487    fn test_normalize_timestamp_sub_millisecond_truncation() {
488        let normalized = normalize_timestamp("2025-11-04T10:00:00.000999Z").unwrap();
489        assert_eq!(normalized, "2025-11-04T10:00:00.000Z");
490    }
491
492    #[test]
493    fn test_canonicalize_span_shape_determinism() {
494        let shape = SpanShape {
495            tool_id: "io.read_csv".to_string(),
496            tool_version: "1".to_string(),
497            param_schema: serde_json::json!({"delimiter": {"type": "string", "default": ","}}),
498            checker_ref: Some("chk:csv-valid".to_string()),
499        };
500
501        let c1 = canonicalize_span_shape(&shape).unwrap();
502        let c2 = canonicalize_span_shape(&shape).unwrap();
503        assert_eq!(c1, c2);
504    }
505}