nova_e2e_contract's cargo fmt --check now passes (formatting from the Plan B circuit work), and the two scanner-flagged test fixtures use allowlisted fake-markers: the gateway test provider key carries "dummy" and the Go emulator API key is atto_test_abc123... (valid 32-hex, "abc123" marker). Gateway + Go suites green; secret scan 0 findings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
429 lines
14 KiB
Go
429 lines
14 KiB
Go
// Package attestotest provides a local, in-memory Attesto v2 emulator for
|
|
// tests ([P2.3]). NewServer starts an httptest.Server implementing the v2
|
|
// subset the SDK uses, with REAL seq/hash-chain semantics via the same frozen
|
|
// canonical functions and receipts signed by a per-instance throwaway Ed25519
|
|
// key under kid "attesto-mock-ed25519". Every emitted object carries
|
|
// mock: true, so mock evidence is structurally incapable of passing as real.
|
|
package attestotest
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"sync"
|
|
"time"
|
|
|
|
attesto "go.attesto.eu/sdk"
|
|
)
|
|
|
|
// MockKid is the signer kid on every mock receipt.
|
|
const MockKid = "attesto-mock-ed25519"
|
|
|
|
type mockEvent struct {
|
|
Envelope attesto.M
|
|
EventHash string
|
|
StreamHeadHash string
|
|
StreamEventID string
|
|
TenantView attesto.M
|
|
}
|
|
|
|
// Server is the running emulator. Point a real client at URL; verify its
|
|
// receipts offline with PublicKeyHex.
|
|
type Server struct {
|
|
URL string
|
|
APIKey string
|
|
PublicKeyHex string
|
|
|
|
httpServer *httptest.Server
|
|
priv ed25519.PrivateKey
|
|
mu sync.Mutex
|
|
streams map[string]attesto.M
|
|
events map[string][]*mockEvent
|
|
receipts map[string]attesto.M
|
|
counter int
|
|
}
|
|
|
|
// Close shuts the emulator down.
|
|
func (s *Server) Close() { s.httpServer.Close() }
|
|
|
|
func (s *Server) id(prefix string) string {
|
|
s.counter++
|
|
return fmt.Sprintf("%s_mock%08d", prefix, s.counter)
|
|
}
|
|
|
|
func nowISO() string {
|
|
return time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
|
}
|
|
|
|
func safeNumbers(value any) bool {
|
|
switch v := value.(type) {
|
|
case json.Number:
|
|
if _, err := v.Int64(); err != nil {
|
|
return false
|
|
}
|
|
n, _ := v.Int64()
|
|
return n <= 1<<53-1 && n >= -(1<<53-1)
|
|
case float64:
|
|
return v == float64(int64(v)) && v <= float64(int64(1)<<53-1) && v >= -float64(int64(1)<<53-1)
|
|
case map[string]any:
|
|
for _, item := range v {
|
|
if !safeNumbers(item) {
|
|
return false
|
|
}
|
|
}
|
|
case []any:
|
|
for _, item := range v {
|
|
if !safeNumbers(item) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func mustHash(domain string, value any) string {
|
|
h, err := attesto.DomainHashHex(domain, value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// NewServer starts the emulator.
|
|
func NewServer() *Server {
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
s := &Server{
|
|
APIKey: "atto_test_abc12300000000000000000000000000",
|
|
PublicKeyHex: hex.EncodeToString(pub),
|
|
priv: priv,
|
|
streams: map[string]attesto.M{},
|
|
events: map[string][]*mockEvent{},
|
|
receipts: map[string]attesto.M{},
|
|
}
|
|
s.httpServer = httptest.NewServer(http.HandlerFunc(s.handle))
|
|
s.URL = s.httpServer.URL
|
|
return s
|
|
}
|
|
|
|
var (
|
|
reEvents = regexp.MustCompile(`^/v2/streams/([^/]+)/events$`)
|
|
reBatch = regexp.MustCompile(`^/v2/streams/([^/]+)/events/batch$`)
|
|
reHead = regexp.MustCompile(`^/v2/streams/([^/]+)/head$`)
|
|
reReceipt = regexp.MustCompile(`^/v2/receipts/([^/]+)$`)
|
|
reTenantEvents = regexp.MustCompile(`^/v2/tenant/streams/([^/]+)/events$`)
|
|
)
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
path := r.URL.Path
|
|
switch {
|
|
case r.Method == http.MethodPost && path == "/v2/streams":
|
|
var body attesto.M
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
streamID := s.id("str")
|
|
stream := attesto.M{
|
|
"streamId": streamID, "systemId": "sys_mock",
|
|
"useCase": str(body["useCase"], "mock"), "policyId": str(body["policyId"], "mock-policy"),
|
|
"status": "active", "lastSeqNo": float64(0),
|
|
"lastEventHash": nil, "lastStreamHeadHash": nil,
|
|
"created": true, "mock": true,
|
|
}
|
|
s.streams[streamID] = stream
|
|
s.events[streamID] = nil
|
|
writeJSON(w, 201, stream)
|
|
case r.Method == http.MethodPost && reBatch.MatchString(path):
|
|
streamID := reBatch.FindStringSubmatch(path)[1]
|
|
var body struct {
|
|
Events []attesto.M `json:"events"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
receipts, code, errBody := s.appendMany(streamID, body.Events)
|
|
if errBody != nil {
|
|
writeJSON(w, code, errBody)
|
|
return
|
|
}
|
|
writeJSON(w, 201, attesto.M{"accepted": len(receipts), "receipts": receipts})
|
|
case r.Method == http.MethodPost && reEvents.MatchString(path):
|
|
streamID := reEvents.FindStringSubmatch(path)[1]
|
|
var body attesto.M
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
receipts, code, errBody := s.appendMany(streamID, []attesto.M{body})
|
|
if errBody != nil {
|
|
writeJSON(w, code, errBody)
|
|
return
|
|
}
|
|
writeJSON(w, 201, receipts[0])
|
|
case r.Method == http.MethodGet && reHead.MatchString(path):
|
|
stream, ok := s.streams[reHead.FindStringSubmatch(path)[1]]
|
|
if !ok {
|
|
writeJSON(w, 404, attesto.M{"detail": "stream not found"})
|
|
return
|
|
}
|
|
writeJSON(w, 200, attesto.M{
|
|
"streamId": stream["streamId"], "systemId": stream["systemId"],
|
|
"status": stream["status"], "lastSeqNo": stream["lastSeqNo"],
|
|
"lastEventHash": stream["lastEventHash"], "lastStreamHeadHash": stream["lastStreamHeadHash"],
|
|
"mock": true,
|
|
})
|
|
case r.Method == http.MethodGet && reReceipt.MatchString(path):
|
|
receipt, ok := s.receipts[reReceipt.FindStringSubmatch(path)[1]]
|
|
if !ok {
|
|
writeJSON(w, 404, attesto.M{"detail": "receipt not found"})
|
|
return
|
|
}
|
|
writeJSON(w, 200, receipt)
|
|
case r.Method == http.MethodGet && reTenantEvents.MatchString(path):
|
|
list := s.events[reTenantEvents.FindStringSubmatch(path)[1]]
|
|
out := make([]attesto.M, 0, len(list))
|
|
for _, e := range list {
|
|
out = append(out, e.TenantView)
|
|
}
|
|
writeJSON(w, 200, out)
|
|
case r.Method == http.MethodGet && path == "/health":
|
|
writeJSON(w, 200, attesto.M{"ok": true, "mock": true})
|
|
default:
|
|
writeJSON(w, 404, attesto.M{"detail": "not found"})
|
|
}
|
|
}
|
|
|
|
func str(v any, fallback string) string {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
return s
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func (s *Server) appendMany(streamID string, bodies []attesto.M) ([]attesto.M, int, attesto.M) {
|
|
stream, ok := s.streams[streamID]
|
|
if !ok {
|
|
return nil, 404, attesto.M{"detail": "stream not found"}
|
|
}
|
|
for _, body := range bodies {
|
|
if !safeNumbers(orEmpty(body["payload"])) || !safeNumbers(orEmpty(body["metadata"])) {
|
|
return nil, 422, attesto.M{"detail": "unsafe numbers are not permitted in committed payloads"}
|
|
}
|
|
}
|
|
out := make([]attesto.M, 0, len(bodies))
|
|
for _, body := range bodies {
|
|
out = append(out, s.append(stream, body))
|
|
}
|
|
return out, 0, nil
|
|
}
|
|
|
|
func orEmpty(v any) any {
|
|
if v == nil {
|
|
return map[string]any{}
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (s *Server) append(stream attesto.M, body attesto.M) attesto.M {
|
|
payload := orEmpty(body["payload"])
|
|
metadata := orEmpty(body["metadata"])
|
|
// Idempotent on (source_kind, source_ref), like real ingestion: a resend
|
|
// returns the existing event's receipt instead of appending.
|
|
sourceKind := str(body["sourceKind"], "sdk")
|
|
sourceRef := str(body["sourceRef"], "")
|
|
for _, existing := range s.events[stream["streamId"].(string)] {
|
|
if sourceRef == "" {
|
|
break // anonymous events never dedupe
|
|
}
|
|
source := existing.Envelope["source"].(attesto.M)
|
|
if source["kind"] == sourceKind && source["event_id"] == sourceRef {
|
|
return s.receipts[existing.StreamEventID]
|
|
}
|
|
}
|
|
seqNo := int64(stream["lastSeqNo"].(float64)) + 1
|
|
ingestedAt := nowISO()
|
|
|
|
payloadCanonical, _ := attesto.CanonicalJSON(payload)
|
|
metadataCanonical, _ := attesto.CanonicalJSON(metadata)
|
|
payloadSum := sha256.Sum256(payloadCanonical)
|
|
metadataSum := sha256.Sum256(metadataCanonical)
|
|
|
|
envelope := attesto.M{
|
|
"protocol": attesto.ProofstreamProtocol,
|
|
"protocol_version": attesto.ProtocolVersionAlpha,
|
|
"tenant_id": "ten_mock",
|
|
"system_id": stream["systemId"],
|
|
"stream_id": stream["streamId"],
|
|
"use_case": stream["useCase"],
|
|
"policy_id": stream["policyId"],
|
|
"seq_no": seqNo,
|
|
"prev_event_hash": stream["lastEventHash"],
|
|
"source": attesto.M{"kind": str(body["sourceKind"], "sdk"), "event_id": str(body["sourceRef"], "")},
|
|
"event_type": str(body["eventType"], "inference"),
|
|
"occurred_at": str(body["occurredAt"], ingestedAt),
|
|
"source_timezone": "Europe/Amsterdam",
|
|
"ingested_at": ingestedAt,
|
|
"payload_commitment": attesto.M{
|
|
"hash_alg": "sha256",
|
|
"canonical_payload_hash": hex.EncodeToString(payloadSum[:]),
|
|
},
|
|
"metadata_commitment": attesto.M{
|
|
"hash_alg": "sha256",
|
|
"canonical_metadata_hash": hex.EncodeToString(metadataSum[:]),
|
|
},
|
|
}
|
|
eventHash := mustHash(attesto.ProofstreamDomains["event"], envelope)
|
|
streamHead := attesto.M{
|
|
"protocol": attesto.ProofstreamProtocol,
|
|
"protocol_version": attesto.ProtocolVersionAlpha,
|
|
"tenant_id": "ten_mock",
|
|
"stream_id": stream["streamId"],
|
|
"seq_no": seqNo,
|
|
"event_hash": eventHash,
|
|
"prev_stream_head_hash": stream["lastStreamHeadHash"],
|
|
"accepted_at": ingestedAt,
|
|
}
|
|
streamHeadHash := mustHash(attesto.ProofstreamDomains["stream"], streamHead)
|
|
streamEventID := s.id("sev")
|
|
|
|
receiptPayload := attesto.M{
|
|
"mock": true,
|
|
"protocol": attesto.ProofstreamProtocol,
|
|
"protocol_version": attesto.ProtocolVersionAlpha,
|
|
"tenant_id": "ten_mock",
|
|
"system_id": stream["systemId"],
|
|
"stream_id": stream["streamId"],
|
|
"stream_event_id": streamEventID,
|
|
"event_id": str(body["sourceRef"], ""),
|
|
"seq_no": seqNo,
|
|
"event_hash": eventHash,
|
|
"prev_event_hash": stream["lastEventHash"],
|
|
"stream_head_hash": streamHeadHash,
|
|
"issued_at": ingestedAt,
|
|
"signer": attesto.M{"alg": "ed25519", "kid": MockKid, "key_epoch": MockKid},
|
|
}
|
|
receiptHash := mustHash(attesto.ProofstreamDomains["receipt"], receiptPayload)
|
|
canonical, _ := attesto.CanonicalJSON(receiptPayload)
|
|
message := append(append([]byte(attesto.ProofstreamDomains["receipt"]), 0), canonical...)
|
|
signature := ed25519.Sign(s.priv, message)
|
|
|
|
wire := attesto.M{
|
|
"streamId": stream["streamId"], "streamEventId": streamEventID,
|
|
"seqNo": seqNo, "eventHash": eventHash,
|
|
"prevEventHash": stream["lastEventHash"], "streamHeadHash": streamHeadHash,
|
|
"mock": true,
|
|
"receipt": attesto.M{
|
|
"payload": receiptPayload,
|
|
"receiptHash": receiptHash,
|
|
"signature": attesto.M{
|
|
"alg": "ed25519", "kid": MockKid, "keyEpoch": MockKid,
|
|
"signatureHex": hex.EncodeToString(signature),
|
|
},
|
|
},
|
|
}
|
|
s.events[stream["streamId"].(string)] = append(s.events[stream["streamId"].(string)], &mockEvent{
|
|
Envelope: envelope, EventHash: eventHash, StreamHeadHash: streamHeadHash,
|
|
StreamEventID: streamEventID,
|
|
TenantView: attesto.M{
|
|
"streamEventId": streamEventID, "seq_no": seqNo,
|
|
"event_type": envelope["event_type"],
|
|
"source_ref": envelope["source"].(attesto.M)["event_id"],
|
|
"event_hash": eventHash, "prev_event_hash": stream["lastEventHash"],
|
|
"stream_head_hash": streamHeadHash,
|
|
"payload_commitment": envelope["payload_commitment"], "mock": true,
|
|
},
|
|
})
|
|
s.receipts[streamEventID] = wire
|
|
stream["lastSeqNo"] = float64(seqNo)
|
|
stream["lastEventHash"] = eventHash
|
|
stream["lastStreamHeadHash"] = streamHeadHash
|
|
return wire
|
|
}
|
|
|
|
// WindowLeaf is one leaf of a built window, with its inclusion proof.
|
|
type WindowLeaf struct {
|
|
StreamEventID string
|
|
SeqNo int64
|
|
LeafIndex int
|
|
LeafHash string
|
|
Proof []attesto.InclusionStep
|
|
}
|
|
|
|
// Window is a built window over all events of a stream so far.
|
|
type Window struct {
|
|
WindowID string
|
|
StreamID string
|
|
RootHash string
|
|
Leaves []WindowLeaf
|
|
}
|
|
|
|
// BuildWindow folds all events so far into a window with per-leaf inclusion
|
|
// proofs (the P1.3 verify functions accept them unchanged).
|
|
func (s *Server) BuildWindow(streamID string) (*Window, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
list := s.events[streamID]
|
|
if len(list) == 0 {
|
|
return nil, fmt.Errorf("no events to fold")
|
|
}
|
|
leafHashes := make([]string, len(list))
|
|
for i, e := range list {
|
|
leafHashes[i] = mustHash(attesto.ProofstreamDomains["window"], attesto.M{
|
|
"kind": "leaf", "protocol": attesto.ProofstreamProtocol,
|
|
"protocol_version": attesto.ProtocolVersionAlpha,
|
|
"tenant_id": "ten_mock", "system_id": "sys_mock",
|
|
"stream_id": streamID, "stream_event_id": e.StreamEventID,
|
|
"seq_no": e.Envelope["seq_no"], "leaf_index": i,
|
|
"event_hash": e.EventHash, "stream_head_hash": e.StreamHeadHash,
|
|
})
|
|
}
|
|
proofs := make([][]attesto.InclusionStep, len(leafHashes))
|
|
type node struct {
|
|
hash string
|
|
idx []int
|
|
}
|
|
level := make([]node, len(leafHashes))
|
|
for i, h := range leafHashes {
|
|
level[i] = node{h, []int{i}}
|
|
}
|
|
for len(level) > 1 {
|
|
var next []node
|
|
for offset := 0; offset < len(level); offset += 2 {
|
|
left := level[offset]
|
|
if offset+1 >= len(level) {
|
|
next = append(next, left) // promote
|
|
continue
|
|
}
|
|
right := level[offset+1]
|
|
for _, i := range left.idx {
|
|
proofs[i] = append(proofs[i], attesto.InclusionStep{Side: "right", Hash: right.hash})
|
|
}
|
|
for _, i := range right.idx {
|
|
proofs[i] = append(proofs[i], attesto.InclusionStep{Side: "left", Hash: left.hash})
|
|
}
|
|
parent := mustHash(attesto.ProofstreamDomains["window"], attesto.M{
|
|
"kind": "node", "left_hash": left.hash, "right_hash": right.hash,
|
|
})
|
|
next = append(next, node{parent, append(append([]int{}, left.idx...), right.idx...)})
|
|
}
|
|
level = next
|
|
}
|
|
window := &Window{WindowID: s.id("win"), StreamID: streamID, RootHash: level[0].hash}
|
|
for i, e := range list {
|
|
window.Leaves = append(window.Leaves, WindowLeaf{
|
|
StreamEventID: e.StreamEventID, SeqNo: e.Envelope["seq_no"].(int64),
|
|
LeafIndex: i, LeafHash: leafHashes[i], Proof: proofs[i],
|
|
})
|
|
}
|
|
return window, nil
|
|
}
|