This document describes the canonical JSON serialization implementation used for audit log payload hashing. This ensures deterministic hash generation across different operating systems, Go versions, and runtime environments.
Standard json.Marshal in Go does not guarantee consistent key ordering in JSON objects. While the Go specification states that map iteration order is randomized for security reasons, the encoding/json package does maintain stable ordering within a single process. However, to ensure absolute determinism across:
- Different OS targets (Linux, macOS, Windows)
- Different Go compiler versions
- Different architectures (amd64, arm64)
- Different runtime environments
We implement a canonical JSON serialization that enforces strict ordering rules.
The canonical JSON implementation follows these rules:
- Sorted Keys: All object keys are sorted alphabetically (lexicographically)
- No Whitespace: No extra whitespace, indentation, or newlines
- Consistent Encoding: Strings, numbers, booleans, and null values use standard JSON encoding
- Recursive Application: Nested objects and arrays are also canonicalized
- Array Order Preservation: Array element order is preserved (not sorted)
The implementation consists of three main functions:
The primary entry point that:
- Converts the input struct to a generic
interface{}via standard JSON marshaling - Applies canonical encoding rules
- Returns the deterministic byte representation
Handles the canonical encoding by dispatching to type-specific encoders.
Encodes JSON objects with sorted keys:
// Keys are sorted alphabetically
keys := make([]string, 0, len(obj))
for k := range obj {
keys = append(keys, k)
}
sort.Strings(keys)When generating an audit log, the payload is serialized using canonical JSON before hashing:
// Serialize Payload to calculate hash using canonical JSON
payloadBytes, err := marshalCanonical(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err)
}
// Calculate SHA256 hash
hash := sha256.Sum256(payloadBytes)When verifying an audit log, the same canonical serialization is used:
// Re-calculate Trace Hash using canonical JSON
payloadBytes, err := marshalCanonical(log.Payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
hash := sha256.Sum256(payloadBytes)The implementation includes comprehensive tests:
- Key Ordering Tests: Verify alphabetical sorting of object keys
- Array Tests: Ensure array order is preserved
- Data Type Tests: Validate all JSON data types
- Struct Tests: Test with Go structs (like
Payload) - Determinism Tests: Verify multiple serializations produce identical output
- Cross-Platform Tests: Ensure consistency regardless of field declaration order
- Edge Cases: Empty values, nil arrays, nested structures
# Run all canonical JSON tests
go test -v ./internal/cmd -run TestCanonicalJSON
# Run audit-specific tests
go test -v ./internal/cmd -run TestGenerate
go test -v ./internal/cmd -run TestVerify
# Run the complete test suite
./test_canonical_json.shGiven this payload:
payload := Payload{
EnvelopeXdr: "envelope_data",
ResultMetaXdr: "result_data",
Events: []string{"event1", "event2"},
Logs: []string{"log1"},
}Canonical JSON output (keys sorted alphabetically):
{
"envelope_xdr": "envelope_data",
"events": ["event1", "event2"],
"logs": ["log1"],
"result_meta_xdr": "result_data"
}Note that regardless of the order fields are declared in the struct or assigned in code, the JSON output will always have keys in alphabetical order: envelope_xdr, events, logs, result_meta_xdr.
- Cross-Platform Consistency: Same payload always produces the same hash
- Reproducible Audits: Audit logs can be verified on any system
- Version Independence: Works across different Go versions
- Security: Prevents hash mismatches that could be exploited
- Debugging: Easier to compare and debug serialized payloads
- RFC 8785: JSON Canonicalization Scheme (JCS)
- Go
encoding/jsonpackage documentation - Issue #178: Implement Audit payload canonical JSON serialization