From 27a1bfcd00e0a0a9ac327dd8e8030c62167eab83 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 11 Jun 2026 14:05:49 +0200 Subject: [PATCH] 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 --- README.md | 14 +++ client.go | 23 ++++ proofstream.go | 228 +++++++++++++++++++++++++++++++++++++ proofstream_parity_test.go | 95 ++++++++++++++++ types.go | 4 + 5 files changed, 364 insertions(+) create mode 100644 proofstream_parity_test.go diff --git a/README.md b/README.md index 412df46..5bb1cde 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,20 @@ and integers beyond ±(2^53−1) are rejected at ingestion (HTTP 422); encode decimals and large integers as strings (e.g. `{"score": "0.87"}`). This keeps cross-language commitment recomputation byte-exact (`CanonicalJSON`). +The SDK enforces the same rule **locally** before sending, so you see it at dev +time rather than as a production 422. `LogEvent` / `LogEvents` return an +`*UnsafeNumberError` (with `.Path`, the JSON path to the offending value). Set +`RequestOptions{SkipPreflight: true}` to defer to the server. + +```go +// Commitment a Proofstream stores for a payload, byte-identical to the server +// (and to the Python / TypeScript SDKs): +commitment, _ := attesto.PayloadCommitment(map[string]any{"decision": "approve", "score_bp": 8700}) +// commitment["canonical_payload_hash"] == server's stored hash + +ok, _ := attesto.VerifyPayloadCommitment(myPayload, event) // recompute and compare +``` + ## Verification Remote verification uses Attesto's public `/v2/verify` API. Offline receipt diff --git a/client.go b/client.go index 5d01bed..47baed2 100644 --- a/client.go +++ b/client.go @@ -161,6 +161,16 @@ func (c *Client) LogEvent(ctx context.Context, streamID string, input EventInput if err := normalizeEventInput(&input); err != nil { return nil, err } + // Reject commitment-unsafe numbers locally so the developer sees the rule at + // dev time rather than as a production 422; SkipPreflight defers to the server. + if !skipPreflight(options) { + if err := AssertCommitmentSafeNumbers(input.Payload, "$.payload"); err != nil { + return nil, err + } + if err := AssertCommitmentSafeNumbers(input.Metadata, "$.metadata"); err != nil { + return nil, err + } + } var out EventReceipt err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out) return &out, err @@ -170,10 +180,19 @@ func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventI if len(events) > 1000 { return nil, errors.New("max 1000 events per batch") } + preflight := !skipPreflight(options) for i := range events { if err := normalizeEventInput(&events[i]); err != nil { return nil, fmt.Errorf("event %d: %w", i, err) } + if preflight { + if err := AssertCommitmentSafeNumbers(events[i].Payload, fmt.Sprintf("$.events[%d].payload", i)); err != nil { + return nil, err + } + if err := AssertCommitmentSafeNumbers(events[i].Metadata, fmt.Sprintf("$.events[%d].metadata", i)); err != nil { + return nil, err + } + } } body := M{"events": events} var out EventBatchResponse @@ -558,6 +577,10 @@ func retryable(status int) bool { } } +func skipPreflight(options []RequestOptions) bool { + return len(options) > 0 && options[0].SkipPreflight +} + func idempotency(options []RequestOptions) string { if len(options) > 0 && options[0].IdempotencyKey != "" { return options[0].IdempotencyKey diff --git a/proofstream.go b/proofstream.go index fb8a334..1f78def 100644 --- a/proofstream.go +++ b/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() diff --git a/proofstream_parity_test.go b/proofstream_parity_test.go new file mode 100644 index 0000000..9b4f81a --- /dev/null +++ b/proofstream_parity_test.go @@ -0,0 +1,95 @@ +package attesto + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +type parityVectors struct { + Accept []struct { + ID string `json:"id"` + Payload map[string]any `json:"payload"` + CanonicalPayloadHash string `json:"canonical_payload_hash"` + } `json:"accept"` + Reject []struct { + ID string `json:"id"` + Payload map[string]any `json:"payload"` + Path string `json:"path"` + } `json:"reject"` +} + +func loadParityVectors(t *testing.T) parityVectors { + t.Helper() + path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "canonical-numbers.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read parity vectors: %v", err) + } + var v parityVectors + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode parity vectors: %v", err) + } + return v +} + +func TestParityAcceptCommitmentHashes(t *testing.T) { + for _, c := range loadParityVectors(t).Accept { + commitment, err := PayloadCommitment(c.Payload) + if err != nil { + t.Fatalf("%s: PayloadCommitment: %v", c.ID, err) + } + if commitment["hash_alg"] != "sha256" { + t.Errorf("%s: hash_alg %q", c.ID, commitment["hash_alg"]) + } + if commitment["canonical_payload_hash"] != c.CanonicalPayloadHash { + t.Errorf("%s: hash %s != %s", c.ID, commitment["canonical_payload_hash"], c.CanonicalPayloadHash) + } + } +} + +func TestParityAcceptPassPreflight(t *testing.T) { + for _, c := range loadParityVectors(t).Accept { + if err := AssertCommitmentSafeNumbers(c.Payload, "$"); err != nil { + t.Errorf("%s: unexpected preflight error: %v", c.ID, err) + } + } +} + +func TestParityRejectPaths(t *testing.T) { + for _, c := range loadParityVectors(t).Reject { + err := AssertCommitmentSafeNumbers(c.Payload, "$") + var ue *UnsafeNumberError + if !errors.As(err, &ue) { + t.Errorf("%s: expected *UnsafeNumberError, got %v", c.ID, err) + continue + } + if ue.Path != c.Path { + t.Errorf("%s: path %s != %s", c.ID, ue.Path, c.Path) + } + } +} + +func TestParityVerifyPayloadCommitment(t *testing.T) { + for _, c := range loadParityVectors(t).Accept { + commitment, err := PayloadCommitment(c.Payload) + if err != nil { + t.Fatalf("%s: PayloadCommitment: %v", c.ID, err) + } + stored := map[string]any{} + for k, v := range commitment { + stored[k] = v + } + event := map[string]any{"envelope": map[string]any{"payload_commitment": stored}} + ok, err := VerifyPayloadCommitment(c.Payload, event) + if err != nil || !ok { + t.Errorf("%s: verify match ok=%v err=%v", c.ID, ok, err) + } + bad, err := VerifyPayloadCommitment(map[string]any{"tampered": float64(1)}, event) + if err != nil || bad { + t.Errorf("%s: tampered verify bad=%v err=%v", c.ID, bad, err) + } + } +} diff --git a/types.go b/types.go index eeaa806..31b5a04 100644 --- a/types.go +++ b/types.go @@ -6,6 +6,10 @@ type M map[string]any type RequestOptions struct { IdempotencyKey string + // SkipPreflight disables the client-side commitment number preflight, which + // rejects non-integer / out-of-range numbers locally before LogEvent / + // LogEvents sends them. Default false: the preflight runs. + SkipPreflight bool } type StreamCreateInput struct {