diff --git a/README.md b/README.md index b631d7a..d3a3c7e 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,26 @@ client, _ := attesto.NewClient(apiKey, attesto.WithHeadStore(attesto.NewFileHead client, _ = attesto.NewClient(apiKey, attesto.WithHeadStore(nil)) ``` +## Testing without Attesto: attestotest + +`go.attesto.eu/sdk/attestotest` starts a local httptest emulator with **real** +hash-chain semantics; point the real client at it and run your full pipeline +in CI with zero network: + +```go +server := attestotest.NewServer() +defer server.Close() +client, _ := attesto.NewClient(server.APIKey, attesto.WithBaseURL(server.URL)) +stream, _ := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"}) +receipt, _ := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{SourceRef: "e1"}) +stored, _ := client.GetReceipt(ctx, receipt.StreamEventID) +report := attesto.VerifyReceiptOffline(stored.Receipt, server.PublicKeyHex) +``` + +Mock evidence can never pass as real: every object carries `mock: true`, the +signer kid is `attesto-mock-ed25519`, and verification against any real +witness key fails. + ## Built-in self-test and doctor On the first hashing operation per process the SDK verifies itself against an diff --git a/attestotest/server.go b/attestotest/server.go new file mode 100644 index 0000000..846c310 --- /dev/null +++ b/attestotest/server.go @@ -0,0 +1,413 @@ +// 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_00000000000000000000000000000000", + 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"]) + 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_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 +} diff --git a/attestotest/server_test.go b/attestotest/server_test.go new file mode 100644 index 0000000..70b94f1 --- /dev/null +++ b/attestotest/server_test.go @@ -0,0 +1,132 @@ +package attestotest + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "encoding/json" + "testing" + + attesto "go.attesto.eu/sdk" +) + +func newClient(t *testing.T, s *Server) *attesto.Client { + t.Helper() + client, err := attesto.NewClient(s.APIKey, attesto.WithBaseURL(s.URL)) + if err != nil { + t.Fatal(err) + } + return client +} + +func toSignedReceipt(t *testing.T, wire attesto.M) attesto.SignedReceipt { + t.Helper() + raw, _ := json.Marshal(wire["receipt"]) + var receipt attesto.SignedReceipt + if err := json.Unmarshal(raw, &receipt); err != nil { + t.Fatal(err) + } + return receipt +} + +func TestFullPipelineAgainstTheEmulator(t *testing.T) { + server := NewServer() + defer server.Close() + client := newClient(t, server) + ctx := context.Background() + + stream, err := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"}) + if err != nil { + t.Fatal(err) + } + receipt, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{ + SourceRef: "e1", Payload: attesto.M{"decision": "approve", "score_bp": 8700}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := client.LogEvents(ctx, stream.StreamID, []attesto.EventInput{ + {SourceRef: "e2", Payload: attesto.M{"n": 2}}, + {SourceRef: "e3", Payload: attesto.M{"n": 3}}, + }); err != nil { + t.Fatal(err) + } + + stored, err := client.GetReceipt(ctx, receipt.StreamEventID) + if err != nil { + t.Fatal(err) + } + report := attesto.VerifyReceiptOffline(stored.Receipt, server.PublicKeyHex) + if !report.OK { + t.Fatalf("offline verification failed: %v", report.Problems) + } + + events, err := client.ListTenantStreamEvents(ctx, stream.StreamID, 100, 0) + if err != nil { + t.Fatal(err) + } + plain := make([]map[string]any, len(events)) + for i, e := range events { + plain[i] = e + } + comp := attesto.VerifyCompleteness(plain, 1, 3) + if !comp.OK { + t.Fatalf("completeness failed: %v", comp.Problems) + } +} + +func TestInclusionProofsFromBuiltWindowVerify(t *testing.T) { + server := NewServer() + defer server.Close() + client := newClient(t, server) + ctx := context.Background() + stream, _ := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"}) + for i := 0; i < 5; i++ { // odd leaf count exercises the promote rule + if _, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{ + SourceRef: "e", Payload: attesto.M{"i": i}, + }); err != nil { + t.Fatal(err) + } + } + window, err := server.BuildWindow(stream.StreamID) + if err != nil { + t.Fatal(err) + } + for _, leaf := range window.Leaves { + ok, err := attesto.VerifyInclusionProof(leaf.LeafHash, leaf.Proof, window.RootHash) + if err != nil || !ok { + t.Fatalf("leaf %d failed inclusion: ok=%v err=%v", leaf.LeafIndex, ok, err) + } + } +} + +func TestMockReceiptsCannotPassAsReal(t *testing.T) { + server := NewServer() + defer server.Close() + client := newClient(t, server) + ctx := context.Background() + stream, _ := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"}) + receipt, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{SourceRef: "e1"}) + if err != nil { + t.Fatal(err) + } + stored, err := client.GetReceipt(ctx, receipt.StreamEventID) + if err != nil { + t.Fatal(err) + } + // Structurally marked. + if stored.Receipt.Payload["mock"] != true { + t.Error("mock receipt payload must declare mock: true") + } + signer, _ := stored.Receipt.Payload["signer"].(map[string]any) + if signer["kid"] != MockKid { + t.Errorf("kid = %v, want %s", signer["kid"], MockKid) + } + // Rejected against a different ("real") witness key. + realPub, _, _ := ed25519.GenerateKey(rand.Reader) + report := attesto.VerifyReceiptOffline(stored.Receipt, hex.EncodeToString(realPub)) + if report.OK { + t.Fatal("mock receipt verified against a real key — must never happen") + } +}