Files
attesto-go/proofstream.go
Codex 27a1bfcd00 sdk(P1.2): payload commitment + safe-number preflight in all three SDKs
Adds payload_commitment / metadata_commitment / verify_payload_commitment
and assert_commitment_safe_numbers to the Python, TypeScript, and Go SDKs,
each building on the frozen canonical_json/domain_hash primitives (no change
to their byte output). The number preflight is a byte-for-byte port of the
backend assert_commitment_safe_numbers (floats rejected, |int| > 2^53-1
rejected, bool exempt) and is wired into the v2 log_event / log_events send
path, raising a typed AttestoUnsafeNumberError with the JSON path so the rule
fails at dev time rather than as a production 422; preflight=False /
SkipPreflight defers to the server.

New shared corpus golden-vectors/sdk-parity/canonical-numbers.json (15 accept
+ 8 reject), accept-hashes generated from the backend _commitment. Proven:
Python = TypeScript = Go = backend produce byte-identical commitment hashes
for every accept vector and identical reject paths (the Go float64-vs-Python-
int serialization parity holds). READMEs updated per SDK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:05:49 +02:00

483 lines
14 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) {
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 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 {
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
}