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>
This commit is contained in:
228
proofstream.go
228
proofstream.go
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -69,6 +70,233 @@ func SHA256Hex(value []byte) string {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user