package attesto import ( "bytes" "crypto/ed25519" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "math" "sort" "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[:]) } 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 }