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:
14
README.md
14
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
|
decimals and large integers as strings (e.g. `{"score": "0.87"}`). This keeps
|
||||||
cross-language commitment recomputation byte-exact (`CanonicalJSON`).
|
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
|
## Verification
|
||||||
|
|
||||||
Remote verification uses Attesto's public `/v2/verify` API. Offline receipt
|
Remote verification uses Attesto's public `/v2/verify` API. Offline receipt
|
||||||
|
|||||||
23
client.go
23
client.go
@@ -161,6 +161,16 @@ func (c *Client) LogEvent(ctx context.Context, streamID string, input EventInput
|
|||||||
if err := normalizeEventInput(&input); err != nil {
|
if err := normalizeEventInput(&input); err != nil {
|
||||||
return nil, err
|
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
|
var out EventReceipt
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out)
|
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out)
|
||||||
return &out, err
|
return &out, err
|
||||||
@@ -170,10 +180,19 @@ func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventI
|
|||||||
if len(events) > 1000 {
|
if len(events) > 1000 {
|
||||||
return nil, errors.New("max 1000 events per batch")
|
return nil, errors.New("max 1000 events per batch")
|
||||||
}
|
}
|
||||||
|
preflight := !skipPreflight(options)
|
||||||
for i := range events {
|
for i := range events {
|
||||||
if err := normalizeEventInput(&events[i]); err != nil {
|
if err := normalizeEventInput(&events[i]); err != nil {
|
||||||
return nil, fmt.Errorf("event %d: %w", i, err)
|
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}
|
body := M{"events": events}
|
||||||
var out EventBatchResponse
|
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 {
|
func idempotency(options []RequestOptions) string {
|
||||||
if len(options) > 0 && options[0].IdempotencyKey != "" {
|
if len(options) > 0 && options[0].IdempotencyKey != "" {
|
||||||
return options[0].IdempotencyKey
|
return options[0].IdempotencyKey
|
||||||
|
|||||||
228
proofstream.go
228
proofstream.go
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -69,6 +70,233 @@ func SHA256Hex(value []byte) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
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) {
|
func SignConnectorWebhookPayload(secret string, body []byte, timestamp int64) (string, string) {
|
||||||
if timestamp == 0 {
|
if timestamp == 0 {
|
||||||
timestamp = time.Now().Unix()
|
timestamp = time.Now().Unix()
|
||||||
|
|||||||
95
proofstream_parity_test.go
Normal file
95
proofstream_parity_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
types.go
4
types.go
@@ -6,6 +6,10 @@ type M map[string]any
|
|||||||
|
|
||||||
type RequestOptions struct {
|
type RequestOptions struct {
|
||||||
IdempotencyKey string
|
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 {
|
type StreamCreateInput struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user