A trimmed (~1.7 KB) copy of the cross-language parity vectors now ships inside
each package (Python package-data JSON, Go go:embed, TS generated module). On
the first hashing operation per process each SDK recomputes the commitment
hash, the receipt domain-hash, and an inclusion fold against the vendored
vectors and fails closed (AttestoSelfTestError / ErrSelfTest) on any mismatch
— a corrupted install or diverging runtime can never silently produce wrong
evidence. Result is cached (including failure); cost <5 ms once. Corrupting a
vendored vector is test-asserted to fail closed in all three languages. The
frozen canonical primitives are untouched; the gate lives in the commitment/
verify entry points built on top of them.
attesto doctor: Go CLI subcommand and Python attesto.doctor(), producing a
deterministic {"ok", "checks"} report — vendored self-test, head-store
writability, number-policy dry-run on a sample payload, Ed25519 availability
(Python), and with credentials: reachability, protocol-header acceptance, and
clock skew vs the server Date header (warn >30 s; webhooks break at 300 s).
package_artifact_policy allows exactly attesto/_selftest_vectors.json in the
wheel (verified: built wheel contains it, policy green). READMEs updated.
This completes the last Phase-1 build item.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
674 lines
19 KiB
Go
674 lines
19 KiB
Go
package attesto
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var ProofstreamDomains = map[string]string{
|
|
"anchor": "attesto.v2.anchor",
|
|
"bundle": "attesto.v2.bundle",
|
|
"checkpoint": "attesto.v2.checkpoint",
|
|
"consistency": "attesto.v2.consistency",
|
|
"event": "attesto.v2.event",
|
|
"fork": "attesto.v2.fork",
|
|
"ivc": "attesto.v2.ivc",
|
|
"receipt": "attesto.v2.receipt",
|
|
"stream": "attesto.v2.stream",
|
|
"window": "attesto.v2.window",
|
|
"witness": "attesto.v2.witness",
|
|
"witness_policy": "attesto.v2.witness_policy",
|
|
}
|
|
|
|
func CanonicalJSON(value any) ([]byte, error) {
|
|
normalized, err := normalizeCanonical(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var buf bytes.Buffer
|
|
encoder := json.NewEncoder(&buf)
|
|
encoder.SetEscapeHTML(false)
|
|
if err := encoder.Encode(normalized); err != nil {
|
|
return nil, err
|
|
}
|
|
return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil
|
|
}
|
|
|
|
func CanonicalJSONHex(value any) (string, error) {
|
|
raw, err := CanonicalJSON(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(raw), nil
|
|
}
|
|
|
|
func DomainHashHex(domain string, value any) (string, error) {
|
|
raw, err := CanonicalJSON(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
h := sha256.New()
|
|
h.Write([]byte(domain))
|
|
h.Write([]byte{0})
|
|
h.Write(raw)
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
func SHA256Hex(value []byte) string {
|
|
sum := sha256.Sum256(value)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// maxSafeInteger mirrors JavaScript's Number.MAX_SAFE_INTEGER (2^53 - 1).
|
|
// Integers beyond this lose precision when a JS verifier parses them, so they
|
|
// would re-serialize to different canonical bytes across languages.
|
|
const maxSafeInteger = int64(1)<<53 - 1
|
|
|
|
// UnsafeNumberError reports a committed payload/metadata number whose
|
|
// canonical-JSON bytes diverge across Python, Go, and JavaScript (a non-integer,
|
|
// or an integer outside +/-(2^53 - 1)). Path is the JSON path to the offender.
|
|
type UnsafeNumberError struct {
|
|
Path string
|
|
Message string
|
|
}
|
|
|
|
func (e *UnsafeNumberError) Error() string { return e.Message }
|
|
|
|
func nonIntegerError(path string) *UnsafeNumberError {
|
|
return &UnsafeNumberError{
|
|
Path: path,
|
|
Message: fmt.Sprintf(
|
|
"non-integer numbers are not permitted in committed payloads (%s); "+
|
|
"encode decimals as strings", path),
|
|
}
|
|
}
|
|
|
|
func unsafeIntegerError(path string) *UnsafeNumberError {
|
|
return &UnsafeNumberError{
|
|
Path: path,
|
|
Message: fmt.Sprintf(
|
|
"integers beyond +/-2^53-1 are not permitted in committed payloads (%s); "+
|
|
"encode large integers as strings", path),
|
|
}
|
|
}
|
|
|
|
func assertSafeFloat(v float64, path string) error {
|
|
if math.IsInf(v, 0) || math.IsNaN(v) || math.Trunc(v) != v {
|
|
return nonIntegerError(path)
|
|
}
|
|
if math.Abs(v) > float64(maxSafeInteger) {
|
|
return unsafeIntegerError(path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertSafeInt64(v int64, path string) error {
|
|
if v > maxSafeInteger || v < -maxSafeInteger {
|
|
return unsafeIntegerError(path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertSafeNumberString(s, path string) error {
|
|
if strings.ContainsAny(s, ".eE") {
|
|
f, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
return nonIntegerError(path)
|
|
}
|
|
return assertSafeFloat(f, path)
|
|
}
|
|
n, err := strconv.ParseInt(s, 10, 64)
|
|
if err != nil {
|
|
// Outside int64 range is necessarily outside the safe integer range.
|
|
return unsafeIntegerError(path)
|
|
}
|
|
return assertSafeInt64(n, path)
|
|
}
|
|
|
|
// AssertCommitmentSafeNumbers rejects numbers whose canonical-JSON bytes diverge
|
|
// across Python, Go, and JavaScript, so a commitment computed here matches the
|
|
// backend's byte-for-byte. Mirrors assert_commitment_safe_numbers in the backend:
|
|
// non-integer numbers are rejected outright; integers must be within +/-(2^53 - 1).
|
|
// Booleans are exempt. Returns an *UnsafeNumberError on the first offending value.
|
|
func AssertCommitmentSafeNumbers(value any, path string) error {
|
|
switch v := value.(type) {
|
|
case nil, bool, string:
|
|
return nil
|
|
case json.Number:
|
|
return assertSafeNumberString(v.String(), path)
|
|
case float64:
|
|
return assertSafeFloat(v, path)
|
|
case float32:
|
|
return assertSafeFloat(float64(v), path)
|
|
case int:
|
|
return assertSafeInt64(int64(v), path)
|
|
case int8:
|
|
return assertSafeInt64(int64(v), path)
|
|
case int16:
|
|
return assertSafeInt64(int64(v), path)
|
|
case int32:
|
|
return assertSafeInt64(int64(v), path)
|
|
case int64:
|
|
return assertSafeInt64(v, path)
|
|
case uint:
|
|
return assertSafeUint64(uint64(v), path)
|
|
case uint8:
|
|
return assertSafeInt64(int64(v), path)
|
|
case uint16:
|
|
return assertSafeInt64(int64(v), path)
|
|
case uint32:
|
|
return assertSafeInt64(int64(v), path)
|
|
case uint64:
|
|
return assertSafeUint64(v, path)
|
|
case []any:
|
|
for i, item := range v {
|
|
if err := AssertCommitmentSafeNumbers(item, fmt.Sprintf("%s[%d]", path, i)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
case []M:
|
|
for i, item := range v {
|
|
if err := AssertCommitmentSafeNumbers(item, fmt.Sprintf("%s[%d]", path, i)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
case map[string]any:
|
|
return assertSafeMap(v, path)
|
|
case M:
|
|
return assertSafeMap(map[string]any(v), path)
|
|
default:
|
|
// Structs and other types: normalize through JSON exactly as the
|
|
// canonical encoder does, then re-check the plain representation.
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var decoded any
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&decoded); err != nil {
|
|
return err
|
|
}
|
|
return AssertCommitmentSafeNumbers(decoded, path)
|
|
}
|
|
}
|
|
|
|
func assertSafeUint64(v uint64, path string) error {
|
|
if v > uint64(maxSafeInteger) {
|
|
return unsafeIntegerError(path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertSafeMap(m map[string]any, path string) error {
|
|
for key, item := range m {
|
|
if err := AssertCommitmentSafeNumbers(item, fmt.Sprintf("%s.%s", path, key)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PayloadCommitment returns a deterministic commitment to an event payload,
|
|
// byte-identical to the server's stored payload_commitment. Call
|
|
// AssertCommitmentSafeNumbers first if the payload is not yet known to be safe.
|
|
func PayloadCommitment(payload any) (map[string]string, error) {
|
|
if err := EnsureSelfTest(); err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := CanonicalJSON(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]string{
|
|
"hash_alg": "sha256",
|
|
"canonical_payload_hash": SHA256Hex(raw),
|
|
}, nil
|
|
}
|
|
|
|
// MetadataCommitment returns a deterministic commitment to event metadata,
|
|
// byte-identical to the server's stored metadata_commitment.
|
|
func MetadataCommitment(metadata any) (map[string]string, error) {
|
|
raw, err := CanonicalJSON(metadata)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]string{
|
|
"hash_alg": "sha256",
|
|
"canonical_metadata_hash": SHA256Hex(raw),
|
|
}, nil
|
|
}
|
|
|
|
func storedCommitmentHash(event map[string]any, commitmentKey, hashKey string) string {
|
|
containers := []any{event, event["envelope"], event["envelope_json"]}
|
|
for _, container := range containers {
|
|
obj, ok := container.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
commitment, ok := obj[commitmentKey].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if stored, ok := commitment[hashKey].(string); ok {
|
|
return stored
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// VerifyPayloadCommitment recomputes the payload commitment locally and compares
|
|
// it to the value stored on an event (payload_commitment.canonical_payload_hash,
|
|
// whether the event is given flat or under envelope / envelope_json). Returns
|
|
// false when the stored commitment is absent or differs.
|
|
func VerifyPayloadCommitment(payload any, event map[string]any) (bool, error) {
|
|
stored := storedCommitmentHash(event, "payload_commitment", "canonical_payload_hash")
|
|
if stored == "" {
|
|
return false, nil
|
|
}
|
|
commitment, err := PayloadCommitment(payload)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return commitment["canonical_payload_hash"] == stored, nil
|
|
}
|
|
|
|
// VerifyMetadataCommitment recomputes the metadata commitment locally and
|
|
// compares it to the value stored on an event.
|
|
func VerifyMetadataCommitment(metadata any, event map[string]any) (bool, error) {
|
|
stored := storedCommitmentHash(event, "metadata_commitment", "canonical_metadata_hash")
|
|
if stored == "" {
|
|
return false, nil
|
|
}
|
|
commitment, err := MetadataCommitment(metadata)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return commitment["canonical_metadata_hash"] == stored, nil
|
|
}
|
|
|
|
func windowNodeHash(left, right string) (string, error) {
|
|
return DomainHashHex(ProofstreamDomains["window"], map[string]any{
|
|
"kind": "node", "left_hash": left, "right_hash": right,
|
|
})
|
|
}
|
|
|
|
func checkpointNodeHash(left, right string) (string, error) {
|
|
return DomainHashHex(ProofstreamDomains["checkpoint"], map[string]any{
|
|
"kind": "node", "left_hash": left, "right_hash": right,
|
|
})
|
|
}
|
|
|
|
// InclusionStep is one node of a window inclusion proof.
|
|
type InclusionStep struct {
|
|
Side string `json:"side"`
|
|
Hash string `json:"hash"`
|
|
}
|
|
|
|
// VerifyInclusionProof folds a window inclusion proof from a leaf up to the
|
|
// window root. Mirrors verify_inclusion_proof in the backend windows.py: a left
|
|
// sibling hashes as node(sibling, current), a right sibling as node(current, sibling).
|
|
func VerifyInclusionProof(leafHash string, proof []InclusionStep, rootHash string) (bool, error) {
|
|
current := leafHash
|
|
for _, step := range proof {
|
|
var err error
|
|
switch step.Side {
|
|
case "left":
|
|
if step.Hash == "" {
|
|
return false, nil
|
|
}
|
|
current, err = windowNodeHash(step.Hash, current)
|
|
case "right":
|
|
if step.Hash == "" {
|
|
return false, nil
|
|
}
|
|
current, err = windowNodeHash(current, step.Hash)
|
|
default:
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
return current == rootHash, nil
|
|
}
|
|
|
|
// VerifyCheckpointRoot recomputes a checkpoint root from its window hashes and
|
|
// compares. Mirrors checkpoint_root_hash in the backend checkpoints.py: an odd
|
|
// node at any level is promoted unchanged (never duplicated/hashed with itself).
|
|
func VerifyCheckpointRoot(windowHashes []string, expectedRoot string) (bool, error) {
|
|
if len(windowHashes) == 0 {
|
|
return false, nil
|
|
}
|
|
level := append([]string{}, windowHashes...)
|
|
for len(level) > 1 {
|
|
next := make([]string, 0, (len(level)+1)/2)
|
|
for offset := 0; offset < len(level); offset += 2 {
|
|
if offset+1 >= len(level) {
|
|
next = append(next, level[offset]) // promote, do not duplicate
|
|
} else {
|
|
h, err := checkpointNodeHash(level[offset], level[offset+1])
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
next = append(next, h)
|
|
}
|
|
}
|
|
level = next
|
|
}
|
|
return level[0] == expectedRoot, nil
|
|
}
|
|
|
|
// VerifyCheckpointExtension checks that current continues previous: contiguous
|
|
// sequence (current.from_seq_no == previous.to_seq_no + 1) and back-link
|
|
// (current.previous_checkpoint_hash == previous.checkpoint_hash).
|
|
func VerifyCheckpointExtension(previous, current map[string]any) VerifyReport {
|
|
problems := make([]string, 0)
|
|
if asFloat(current["from_seq_no"]) != asFloat(previous["to_seq_no"])+1 {
|
|
problems = append(problems, "checkpoint does not extend previous (sequence gap)")
|
|
}
|
|
if !sameString(current["previous_checkpoint_hash"], previous["checkpoint_hash"]) {
|
|
problems = append(problems, "checkpoint previous_checkpoint_hash does not match previous")
|
|
}
|
|
return VerifyReport{Kind: "checkpoint-extension", OK: len(problems) == 0, Problems: problems}
|
|
}
|
|
|
|
// VerifyCompleteness proves no events were omitted in [fromSeqNo, toSeqNo]: the
|
|
// sequence numbers must be gap-free and every event's prev_event_hash must equal
|
|
// the previous event's event_hash (the per-stream hash chain).
|
|
func VerifyCompleteness(events []map[string]any, fromSeqNo, toSeqNo int) VerifyReport {
|
|
problems := make([]string, 0)
|
|
ordered := append([]map[string]any{}, events...)
|
|
sort.Slice(ordered, func(i, j int) bool {
|
|
return asFloat(ordered[i]["seq_no"]) < asFloat(ordered[j]["seq_no"])
|
|
})
|
|
gapFree := len(ordered) == toSeqNo-fromSeqNo+1
|
|
if gapFree {
|
|
for i, event := range ordered {
|
|
if int(asFloat(event["seq_no"])) != fromSeqNo+i {
|
|
gapFree = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !gapFree {
|
|
problems = append(problems, "sequence range is not gap-free")
|
|
} else {
|
|
for i := 1; i < len(ordered); i++ {
|
|
if !sameString(ordered[i]["prev_event_hash"], ordered[i-1]["event_hash"]) {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"event chain broken at seq_no %d", int(asFloat(ordered[i]["seq_no"]))))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return VerifyReport{Kind: "completeness", OK: len(problems) == 0, Problems: problems}
|
|
}
|
|
|
|
func asFloat(v any) float64 {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n
|
|
case int:
|
|
return float64(n)
|
|
case int64:
|
|
return float64(n)
|
|
case json.Number:
|
|
f, _ := n.Float64()
|
|
return f
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func sameString(a, b any) bool {
|
|
as, aok := a.(string)
|
|
bs, bok := b.(string)
|
|
if aok && bok {
|
|
return as == bs
|
|
}
|
|
return a == nil && b == nil
|
|
}
|
|
|
|
func webhookHeader(headers map[string]string, name string) (string, bool) {
|
|
for key, value := range headers {
|
|
if strings.EqualFold(key, name) {
|
|
return value, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// VerifyWebhook verifies an inbound webhook delivered by Attesto. It reads the
|
|
// X-Attesto-Timestamp / X-Attesto-Signature headers (case-insensitive), rejects
|
|
// when the timestamp is outside maxSkewS of now, recomputes
|
|
// hmac_sha256(secret, "<ts>." + body), and compares in constant time. Mirrors
|
|
// the backend webhook signing scheme.
|
|
func VerifyWebhook(body []byte, headers map[string]string, secret string, maxSkewS int) bool {
|
|
return verifyWebhookAt(body, headers, secret, maxSkewS, time.Now().Unix())
|
|
}
|
|
|
|
func verifyWebhookAt(body []byte, headers map[string]string, secret string, maxSkewS int, now int64) bool {
|
|
timestamp, okTS := webhookHeader(headers, "x-attesto-timestamp")
|
|
signature, okSig := webhookHeader(headers, "x-attesto-signature")
|
|
if !okTS || !okSig {
|
|
return false
|
|
}
|
|
ts, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
skew := now - ts
|
|
if skew < 0 {
|
|
skew = -skew
|
|
}
|
|
if skew > int64(maxSkewS) {
|
|
return false
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write([]byte(strconv.FormatInt(ts, 10)))
|
|
mac.Write([]byte("."))
|
|
mac.Write(body)
|
|
expected := hex.EncodeToString(mac.Sum(nil))
|
|
return hmac.Equal([]byte(expected), []byte(signature))
|
|
}
|
|
|
|
func SignConnectorWebhookPayload(secret string, body []byte, timestamp int64) (string, string) {
|
|
if timestamp == 0 {
|
|
timestamp = time.Now().Unix()
|
|
}
|
|
ts := fmt.Sprintf("%d", timestamp)
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write([]byte(ts))
|
|
mac.Write([]byte("."))
|
|
mac.Write(body)
|
|
return ts, hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func SignedConnectorWebhookHeaders(secret string, body []byte, timestamp int64) map[string]string {
|
|
ts, sig := SignConnectorWebhookPayload(secret, body, timestamp)
|
|
return map[string]string{
|
|
"X-Attesto-Connector-Timestamp": ts,
|
|
"X-Attesto-Connector-Signature": sig,
|
|
}
|
|
}
|
|
|
|
func VerifyReceiptOffline(receipt SignedReceipt, publicKeyHex string) VerifyReport {
|
|
if err := EnsureSelfTest(); err != nil {
|
|
return VerifyReport{Kind: VerifyReceipt, OK: false, Problems: []string{err.Error()}}
|
|
}
|
|
problems := make([]string, 0)
|
|
hash, err := DomainHashHex(ProofstreamDomains["receipt"], receipt.Payload)
|
|
if err != nil {
|
|
problems = append(problems, "receipt payload is not canonical-json compatible")
|
|
} else if hash != receipt.ReceiptHash {
|
|
problems = append(problems, "receipt hash mismatch")
|
|
}
|
|
if strings.ToLower(receipt.Signature.Alg) != "ed25519" {
|
|
problems = append(problems, "unsupported receipt signature algorithm")
|
|
}
|
|
signatureHex := receipt.Signature.SignatureHex
|
|
if signatureHex == "" {
|
|
problems = append(problems, "receipt signature missing")
|
|
}
|
|
if err == nil && signatureHex != "" {
|
|
publicKey, keyErr := hex.DecodeString(strings.TrimPrefix(strings.ToLower(publicKeyHex), "0x"))
|
|
signature, sigErr := hex.DecodeString(strings.TrimPrefix(strings.ToLower(signatureHex), "0x"))
|
|
payloadBytes, payloadErr := CanonicalJSON(receipt.Payload)
|
|
if keyErr != nil || len(publicKey) != ed25519.PublicKeySize {
|
|
problems = append(problems, "invalid public key")
|
|
} else if sigErr != nil || len(signature) != ed25519.SignatureSize {
|
|
problems = append(problems, "invalid receipt signature")
|
|
} else if payloadErr != nil {
|
|
problems = append(problems, "receipt payload is not canonical-json compatible")
|
|
} else {
|
|
message := make([]byte, 0, len(ProofstreamDomains["receipt"])+1+len(payloadBytes))
|
|
message = append(message, []byte(ProofstreamDomains["receipt"])...)
|
|
message = append(message, 0)
|
|
message = append(message, payloadBytes...)
|
|
if !ed25519.Verify(ed25519.PublicKey(publicKey), message, signature) {
|
|
problems = append(problems, "receipt signature mismatch")
|
|
}
|
|
}
|
|
}
|
|
ok := len(problems) == 0
|
|
result := "failed"
|
|
if ok {
|
|
result = "accepted"
|
|
}
|
|
eventHash, _ := receipt.Payload["event_hash"].(string)
|
|
if eventHash == "" {
|
|
eventHash, _ = receipt.Payload["eventHash"].(string)
|
|
}
|
|
return VerifyReport{
|
|
Kind: VerifyReceipt,
|
|
OK: ok,
|
|
ReceiptHash: hash,
|
|
EventHash: eventHash,
|
|
Problems: problems,
|
|
Protocol: ProofstreamProtocol,
|
|
ProtocolVersion: ProtocolVersionAlpha,
|
|
Subject: subjectFromPayload(receipt.Payload),
|
|
Result: result,
|
|
Checks: []VerificationCheck{{
|
|
Name: "receipt",
|
|
Result: result,
|
|
Details: M{"problems": problems},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func normalizeCanonical(value any) (any, error) {
|
|
switch v := value.(type) {
|
|
case nil, string, bool:
|
|
return v, nil
|
|
case time.Time:
|
|
return v.UTC().Format("2006-01-02T15:04:05.000Z"), nil
|
|
case []byte:
|
|
return hex.EncodeToString(v), nil
|
|
case int:
|
|
return v, nil
|
|
case int8:
|
|
return int64(v), nil
|
|
case int16:
|
|
return int64(v), nil
|
|
case int32:
|
|
return int64(v), nil
|
|
case int64:
|
|
return v, nil
|
|
case uint:
|
|
return v, nil
|
|
case uint8:
|
|
return uint64(v), nil
|
|
case uint16:
|
|
return uint64(v), nil
|
|
case uint32:
|
|
return uint64(v), nil
|
|
case uint64:
|
|
return v, nil
|
|
case float32:
|
|
if math.IsInf(float64(v), 0) || math.IsNaN(float64(v)) {
|
|
return nil, errors.New("canonical JSON cannot encode non-finite numbers")
|
|
}
|
|
return v, nil
|
|
case float64:
|
|
if math.IsInf(v, 0) || math.IsNaN(v) {
|
|
return nil, errors.New("canonical JSON cannot encode non-finite numbers")
|
|
}
|
|
return v, nil
|
|
case []any:
|
|
out := make([]any, len(v))
|
|
for i, item := range v {
|
|
normalized, err := normalizeCanonical(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i] = normalized
|
|
}
|
|
return out, nil
|
|
case []M:
|
|
out := make([]any, len(v))
|
|
for i, item := range v {
|
|
normalized, err := normalizeCanonical(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i] = normalized
|
|
}
|
|
return out, nil
|
|
case map[string]any:
|
|
return normalizeMap(v)
|
|
case M:
|
|
return normalizeMap(map[string]any(v))
|
|
default:
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var decoded any
|
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
return normalizeCanonical(decoded)
|
|
}
|
|
}
|
|
|
|
func normalizeMap(in map[string]any) (map[string]any, error) {
|
|
out := make(map[string]any, len(in))
|
|
keys := make([]string, 0, len(in))
|
|
for key := range in {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
normalized, err := normalizeCanonical(in[key])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[key] = normalized
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func subjectFromPayload(payload M) M {
|
|
subject := M{}
|
|
for _, key := range []string{"tenant_id", "system_id", "stream_id", "stream_event_id", "seq_no", "from_seq_no", "to_seq_no", "checkpoint_id", "from_checkpoint_id", "to_checkpoint_id"} {
|
|
if value, ok := payload[key]; ok {
|
|
subject[key] = value
|
|
}
|
|
}
|
|
return subject
|
|
}
|