Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
315f7f05f2 | ||
|
|
b5cece2822 | ||
|
|
53ae31e196 | ||
|
|
8dd6c4a784 | ||
|
|
6858bbcdd8 | ||
|
|
4a2d8645b0 | ||
|
|
2276f4da09 | ||
|
|
217db5a11e | ||
|
|
ce9b8ccfbb | ||
|
|
227ea57bd5 |
50
README.md
50
README.md
@@ -10,6 +10,16 @@ tooling, CI, evidence exporters, and operator automation. Do not embed Attesto A
|
||||
go get go.attesto.eu/sdk
|
||||
```
|
||||
|
||||
CLI binaries: `curl -fsSL https://get.attesto.eu | sh` (checksum-verified).
|
||||
Verify the release signature before you trust its verifier:
|
||||
|
||||
```shell
|
||||
curl -fsSO https://get.attesto.eu/cosign.pub
|
||||
curl -fsSO https://get.attesto.eu/0.3.0/SHA256SUMS
|
||||
curl -fsSO https://get.attesto.eu/0.3.0/SHA256SUMS.sig
|
||||
cosign verify-blob --key cosign.pub --insecure-ignore-tlog --signature SHA256SUMS.sig SHA256SUMS
|
||||
```
|
||||
|
||||
The first release is VCS-resolved from the Attesto repository. It intentionally
|
||||
uses only the Go standard library.
|
||||
|
||||
@@ -64,6 +74,9 @@ Attesto stores source-system time separately from backend ingest time.
|
||||
`time.Now().UTC()` when omitted, but production integrations should pass the
|
||||
real upstream event timestamp whenever the source system provides one.
|
||||
|
||||
|
||||
Canonicalization is specified normatively in [ATTESTO-CANONICAL-JSON-001](../../docs/protocol/ATTESTO-CANONICAL-JSON-001.md); the parity corpus `golden-vectors/sdk-parity/` is its conformance set.
|
||||
|
||||
## Committed payload number rule
|
||||
|
||||
When events are committed to a Proofstream, payload and metadata numbers must
|
||||
@@ -129,6 +142,43 @@ client, _ := attesto.NewClient(apiKey, attesto.WithHeadStore(attesto.NewFileHead
|
||||
client, _ = attesto.NewClient(apiKey, attesto.WithHeadStore(nil))
|
||||
```
|
||||
|
||||
## Typed compliance events and the evidence report
|
||||
|
||||
```go
|
||||
decision := attesto.ModelDecision{Model: "credit-v1", Decision: "approve", ConfidenceBp: 8700}
|
||||
payload, _ := decision.ToPayload() // regulation_refs attached, number-policy validated
|
||||
client.LogEvent(ctx, streamID, attesto.EventInput{
|
||||
SourceRef: "d-1", EventType: decision.EventType(), Payload: payload,
|
||||
})
|
||||
```
|
||||
|
||||
```bash
|
||||
attesto report article12 --stream str_... --output report.md
|
||||
```
|
||||
|
||||
The report is a deterministic template (never LLM-generated) stating what is
|
||||
recorded and independently verifiable — it never asserts conformity.
|
||||
|
||||
## 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
|
||||
|
||||
428
attestotest/server.go
Normal file
428
attestotest/server.go
Normal file
@@ -0,0 +1,428 @@
|
||||
// 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
|
||||
}
|
||||
132
attestotest/server_test.go
Normal file
132
attestotest/server_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
97
cmd/attesto-verify-wasm/main.go
Normal file
97
cmd/attesto-verify-wasm/main.go
Normal file
@@ -0,0 +1,97 @@
|
||||
//go:build js && wasm
|
||||
|
||||
// attesto-verify-wasm [P3.1] — the verifier-only WebAssembly build.
|
||||
//
|
||||
// Exposes the offline verification functions (and nothing else: no client,
|
||||
// no CLI, no network capability is ever invoked) on a global
|
||||
// `attestoVerify` object for the docs-site /verify drop-zone. Every
|
||||
// function takes JSON strings and returns a JSON string, so the JS side
|
||||
// stays a thin shell.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"syscall/js"
|
||||
|
||||
attesto "go.attesto.eu/sdk"
|
||||
)
|
||||
|
||||
func respond(value any) string {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return `{"ok":false,"problems":["internal: response marshal failed"]}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func fail(problem string) string {
|
||||
return respond(map[string]any{"ok": false, "problems": []string{problem}})
|
||||
}
|
||||
|
||||
// verifyReceipt(receiptJSON, publicKeyHex) -> VerifyReport JSON
|
||||
func verifyReceipt(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 2 {
|
||||
return fail("usage: verifyReceipt(receiptJSON, publicKeyHex)")
|
||||
}
|
||||
var receipt attesto.SignedReceipt
|
||||
if err := json.Unmarshal([]byte(args[0].String()), &receipt); err != nil {
|
||||
return fail("receipt is not valid JSON: " + err.Error())
|
||||
}
|
||||
return respond(attesto.VerifyReceiptOffline(receipt, args[1].String()))
|
||||
}
|
||||
|
||||
// verifyInclusion(leafHash, proofJSON, rootHash) -> {ok, problems}
|
||||
func verifyInclusion(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 3 {
|
||||
return fail("usage: verifyInclusion(leafHash, proofJSON, rootHash)")
|
||||
}
|
||||
var proof []attesto.InclusionStep
|
||||
if err := json.Unmarshal([]byte(args[1].String()), &proof); err != nil {
|
||||
return fail("proof is not valid JSON: " + err.Error())
|
||||
}
|
||||
ok, err := attesto.VerifyInclusionProof(args[0].String(), proof, args[2].String())
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
return respond(map[string]any{"ok": ok, "problems": []string{}})
|
||||
}
|
||||
|
||||
// verifyCheckpointRoot(windowHashesJSON, expectedRoot) -> {ok, problems}
|
||||
func verifyCheckpointRoot(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 2 {
|
||||
return fail("usage: verifyCheckpointRoot(windowHashesJSON, expectedRoot)")
|
||||
}
|
||||
var hashes []string
|
||||
if err := json.Unmarshal([]byte(args[0].String()), &hashes); err != nil {
|
||||
return fail("windowHashes is not valid JSON: " + err.Error())
|
||||
}
|
||||
ok, err := attesto.VerifyCheckpointRoot(hashes, args[1].String())
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
return respond(map[string]any{"ok": ok, "problems": []string{}})
|
||||
}
|
||||
|
||||
// verifyCompleteness(eventsJSON, fromSeqNo, toSeqNo) -> VerifyReport JSON
|
||||
func verifyCompleteness(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 3 {
|
||||
return fail("usage: verifyCompleteness(eventsJSON, fromSeqNo, toSeqNo)")
|
||||
}
|
||||
var events []map[string]any
|
||||
if err := json.Unmarshal([]byte(args[0].String()), &events); err != nil {
|
||||
return fail("events is not valid JSON: " + err.Error())
|
||||
}
|
||||
return respond(attesto.VerifyCompleteness(events, args[1].Int(), args[2].Int()))
|
||||
}
|
||||
|
||||
func main() {
|
||||
exports := js.Global().Get("Object").New()
|
||||
exports.Set("verifyReceipt", js.FuncOf(verifyReceipt))
|
||||
exports.Set("verifyInclusion", js.FuncOf(verifyInclusion))
|
||||
exports.Set("verifyCheckpointRoot", js.FuncOf(verifyCheckpointRoot))
|
||||
exports.Set("verifyCompleteness", js.FuncOf(verifyCompleteness))
|
||||
exports.Set("sdkVersion", attesto.SDKVersion)
|
||||
js.Global().Set("attestoVerify", exports)
|
||||
// Keep the runtime alive for calls from JS.
|
||||
select {}
|
||||
}
|
||||
248
cmd/attesto/connector_init.go
Normal file
248
cmd/attesto/connector_init.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
// [D.5] `attesto connector init <slug>` — scaffold a marketplace-ready
|
||||
// connector: a v2 manifest that passes connectorkit validation locally, a
|
||||
// signed-webhook handler stub built on the P1.4 verification helper, and a
|
||||
// README pointing at the submission flow. The local validation run is the
|
||||
// same code the marketplace runs, so a green scaffold is a green
|
||||
// pre-submission check.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"go.attesto.eu/sdk/connectorkit"
|
||||
)
|
||||
|
||||
var connectorSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,95}$`)
|
||||
|
||||
func connectorManifestTemplate(slug, name, category string) connectorkit.Manifest {
|
||||
return connectorkit.Manifest{
|
||||
SchemaVersion: "attesto.connector.v2",
|
||||
Slug: slug,
|
||||
Name: name,
|
||||
Version: "0.1.0",
|
||||
AssetType: "connector",
|
||||
Category: category,
|
||||
Summary: fmt.Sprintf("Verify %s evidence into Attesto Proofstream.", name),
|
||||
Description: fmt.Sprintf(
|
||||
"Produces verifiable evidence for %s events through Attesto Proofstream.", name),
|
||||
Publisher: map[string]string{"name": "CHANGE ME", "slug": "change-me"},
|
||||
Repository: map[string]string{"url": "https://example.com/CHANGE-ME/" + slug},
|
||||
Documentation: map[string]string{"url": "https://docs.attesto.eu/manuals/connectors.html"},
|
||||
Capabilities: []string{
|
||||
"proofstream", "signed-webhook", "offline-verification",
|
||||
},
|
||||
Evidence: map[string]bool{
|
||||
"offlineVerification": true,
|
||||
"receipts": true,
|
||||
"witnessCompatible": true,
|
||||
},
|
||||
Security: map[string]bool{
|
||||
"dependencyScan": true,
|
||||
"secretScan": true,
|
||||
"secretsServerSide": true,
|
||||
},
|
||||
SupportedLanguages: []string{"en"},
|
||||
Provider: map[string]any{
|
||||
"id": slug,
|
||||
"name": name,
|
||||
"websiteUrl": "https://example.com",
|
||||
},
|
||||
Auth: map[string]any{
|
||||
"mode": "signed-webhook",
|
||||
"scopes": []string{"webhook:read"},
|
||||
},
|
||||
Sync: map[string]any{
|
||||
"modes": []string{"webhook"},
|
||||
"supportsReplay": true,
|
||||
"rateLimitPolicy": "Provider webhook retries and Attesto idempotency keys",
|
||||
},
|
||||
EventTypes: []string{slug + ".event"},
|
||||
SourceTime: map[string]any{
|
||||
"required": true,
|
||||
"timezonePolicy": "source-timestamp-with-offset-required",
|
||||
},
|
||||
ConfigSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"resourceRef"},
|
||||
"properties": map[string]any{
|
||||
"resourceRef": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
SecretSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"webhookSecret"},
|
||||
"properties": map[string]any{
|
||||
"webhookSecret": map[string]any{"type": "string", "secret": true},
|
||||
},
|
||||
},
|
||||
Diagnostics: map[string]any{
|
||||
"providerAuthStatus": true,
|
||||
"replayConflictCheck": true,
|
||||
"revocationCheck": true,
|
||||
"syncLag": true,
|
||||
"testConnection": true,
|
||||
},
|
||||
Runtime: map[string]any{
|
||||
"officialConnectorKit": true,
|
||||
"sdkSurfaces": []string{"python", "typescript", "go", "cli"},
|
||||
"requiredMethods": []string{
|
||||
"metadata", "validateConfig", "testConnection", "sync",
|
||||
"handleWebhook", "emitProofstreamEvent", "diagnostics", "revoke",
|
||||
},
|
||||
// A scaffold cannot honestly claim a green assurance canary; this
|
||||
// stays "pending" (the one expected validation finding) until the
|
||||
// connector has real canary evidence.
|
||||
"canary": map[string]any{
|
||||
"status": "pending",
|
||||
"ref": "CHANGE ME: assurance canary evidence ref",
|
||||
},
|
||||
},
|
||||
InstallRequirements: map[string]any{
|
||||
"tenantLoginRequired": true,
|
||||
"entitlementRequired": true,
|
||||
},
|
||||
Changelog: []map[string]any{
|
||||
{"version": "0.1.0", "changes": []string{"Initial scaffold."}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const webhookHandlerStub = `"""Signed-webhook handler stub for the %s connector.
|
||||
|
||||
Verification uses the Attesto SDK's P1.4 helper — the same scheme the
|
||||
platform signs with: HMAC-SHA256 over "{timestamp}.{body}" with a 300s
|
||||
skew window and constant-time comparison.
|
||||
"""
|
||||
import os
|
||||
|
||||
from attesto.webhooks import verify_webhook
|
||||
|
||||
|
||||
def handle(headers: dict[str, str], body: bytes) -> dict:
|
||||
if not verify_webhook(
|
||||
body=body,
|
||||
headers=headers,
|
||||
secret=os.environ["WEBHOOK_SECRET"],
|
||||
):
|
||||
raise PermissionError("invalid webhook signature or stale timestamp")
|
||||
|
||||
# The payload is now authentic: turn it into a proofstream event here.
|
||||
return {"ok": True}
|
||||
`
|
||||
|
||||
const connectorReadmeStub = `# %s
|
||||
|
||||
Scaffolded by ` + "`attesto connector init`" + `.
|
||||
|
||||
1. Edit ` + "`attesto.connector.json`" + ` (publisher, repository, provider,
|
||||
event types — search for CHANGE ME).
|
||||
2. Implement the runtime methods (see ` + "`webhook_handler.py`" + ` for the
|
||||
signed-webhook entry point; verification is already wired).
|
||||
3. Re-run the pre-submission check at any time:
|
||||
|
||||
attesto connector init --validate-only %s
|
||||
|
||||
4. Submit through the marketplace flow (docs.attesto.eu/manuals/connectors.html).
|
||||
`
|
||||
|
||||
func (a *app) connectorInit(args []string) error {
|
||||
fs := flag.NewFlagSet("connector init", flag.ContinueOnError)
|
||||
fs.SetOutput(a.err)
|
||||
name := fs.String("name", "", "human-readable connector name (default: derived from slug)")
|
||||
category := fs.String("category", "devops", "marketplace category")
|
||||
dir := fs.String("dir", "", "output directory (default: ./<slug>)")
|
||||
validateOnly := fs.String("validate-only", "", "validate an existing <dir>/attesto.connector.json and exit")
|
||||
// Accept the slug positionally before flags: `connector init my-slug --category crm`.
|
||||
slug := ""
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
slug = args[0]
|
||||
args = args[1:]
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *validateOnly != "" {
|
||||
raw, err := os.ReadFile(filepath.Join(*validateOnly, "attesto.connector.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var manifest connectorkit.Manifest
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
result := connectorkit.ValidateManifest(manifest)
|
||||
if err := a.write(result); err != nil {
|
||||
return err
|
||||
}
|
||||
if !result.OK {
|
||||
return errors.New("manifest validation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if slug == "" && fs.NArg() == 1 {
|
||||
slug = fs.Arg(0)
|
||||
}
|
||||
if slug == "" {
|
||||
return errors.New("usage: attesto connector init <slug> [--name ...] [--category ...]")
|
||||
}
|
||||
if !connectorSlugPattern.MatchString(slug) {
|
||||
return fmt.Errorf("slug %q must match %s", slug, connectorSlugPattern)
|
||||
}
|
||||
connectorName := *name
|
||||
if connectorName == "" {
|
||||
connectorName = strings.Title(strings.ReplaceAll(slug, "-", " ")) //nolint:staticcheck
|
||||
}
|
||||
|
||||
manifest := connectorManifestTemplate(slug, connectorName, *category)
|
||||
result := connectorkit.ValidateManifest(manifest)
|
||||
// The only acceptable finding on a fresh scaffold is the pending canary —
|
||||
// everything else must already satisfy the marketplace validator.
|
||||
for _, finding := range result.Findings {
|
||||
if finding.Code != "runtime.canary" {
|
||||
return fmt.Errorf("internal error: scaffold template failed validation: %+v", result.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
outDir := *dir
|
||||
if outDir == "" {
|
||||
outDir = slug
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outDir, "attesto.connector.json")); err == nil {
|
||||
return fmt.Errorf("%s/attesto.connector.json already exists", outDir)
|
||||
}
|
||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outDir, "attesto.connector.json"), append(raw, '\n'), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
handler := fmt.Sprintf(webhookHandlerStub, connectorName)
|
||||
if err := os.WriteFile(filepath.Join(outDir, "webhook_handler.py"), []byte(handler), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
readme := fmt.Sprintf(connectorReadmeStub, connectorName, outDir)
|
||||
if err := os.WriteFile(filepath.Join(outDir, "README.md"), []byte(readme), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.write(map[string]any{
|
||||
"created": outDir,
|
||||
"files": []string{"attesto.connector.json", "webhook_handler.py", "README.md"},
|
||||
"validation": result,
|
||||
"nextSteps": "edit CHANGE ME fields, implement runtime methods, earn a green " +
|
||||
"assurance canary, re-run with --validate-only until OK",
|
||||
})
|
||||
}
|
||||
49
cmd/attesto/connector_init_test.go
Normal file
49
cmd/attesto/connector_init_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
// [D.5] connector init scaffolds a manifest whose ONLY validation finding is
|
||||
// the pending canary, and the generated webhook stub calls the real P1.4
|
||||
// helper with its actual signature.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.attesto.eu/sdk/connectorkit"
|
||||
)
|
||||
|
||||
func TestConnectorInitScaffoldsValidManifest(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "my-crm")
|
||||
a := &app{out: &bytes.Buffer{}, err: &bytes.Buffer{}}
|
||||
if err := a.connectorInit([]string{"my-crm-evidence", "--category", "crm", "--dir", dir}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "attesto.connector.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest connectorkit.Manifest
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := connectorkit.ValidateManifest(manifest)
|
||||
for _, finding := range result.Findings {
|
||||
if finding.Code != "runtime.canary" {
|
||||
t.Fatalf("unexpected finding: %+v", finding)
|
||||
}
|
||||
}
|
||||
stub, err := os.ReadFile(filepath.Join(dir, "webhook_handler.py"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(stub), "from attesto.webhooks import verify_webhook") {
|
||||
t.Fatal("stub does not use the P1.4 helper")
|
||||
}
|
||||
// re-running must refuse to overwrite
|
||||
if err := a.connectorInit([]string{"my-crm-evidence", "--dir", dir}); err == nil {
|
||||
t.Fatal("expected overwrite refusal")
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"go.attesto.eu/sdk/connectorkit"
|
||||
)
|
||||
|
||||
const cliVersion = "0.3.0"
|
||||
const cliVersion = "0.4.0"
|
||||
|
||||
var supportedVerifyKindNames = []string{
|
||||
"receipt",
|
||||
@@ -137,6 +137,12 @@ func (a *app) dispatch(ctx context.Context, args []string) error {
|
||||
return a.quorum(ctx, args[1:])
|
||||
case "ivc":
|
||||
return a.ivc(ctx, args[1:])
|
||||
case "connector":
|
||||
// [D.5] scaffold + local pre-submission validation
|
||||
if len(args) > 1 && args[1] == "init" {
|
||||
return a.connectorInit(args[2:])
|
||||
}
|
||||
return errors.New("connector subcommand required (init)")
|
||||
case "connectors":
|
||||
return a.connectors(ctx, args[1:])
|
||||
case "local-vault":
|
||||
@@ -145,6 +151,8 @@ func (a *app) dispatch(ctx context.Context, args []string) error {
|
||||
return a.marketplace(ctx, args[1:])
|
||||
case "doctor":
|
||||
return a.doctor(ctx, args[1:])
|
||||
case "report":
|
||||
return a.report(ctx, args[1:])
|
||||
case "readiness":
|
||||
return a.readiness(args[1:])
|
||||
default:
|
||||
@@ -168,6 +176,30 @@ func (a *app) verify(ctx context.Context, args []string) error {
|
||||
return errors.New("--file is required")
|
||||
}
|
||||
return a.write(verifyTruthPackageZip(*file))
|
||||
case "file":
|
||||
// [P3.4] Verify a portable *.attesto.json receipt export offline.
|
||||
fs := flag.NewFlagSet("verify file", flag.ContinueOnError)
|
||||
fs.SetOutput(a.err)
|
||||
file := fs.String("file", "", "portable receipt export (*.attesto.json)")
|
||||
publicKeyHex := fs.String("public-key-hex", "", "pinned witness key (omitting it verifies against the file's embedded hint)")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if *file == "" {
|
||||
return errors.New("--file is required")
|
||||
}
|
||||
raw, err := os.ReadFile(*file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
report := attesto.VerifyReceiptExport(raw, *publicKeyHex)
|
||||
if err := a.write(report); err != nil {
|
||||
return err
|
||||
}
|
||||
if !report.OK {
|
||||
return errors.New("verification failed")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
_ = ctx
|
||||
return fmt.Errorf("unknown verify subcommand: %s", args[0])
|
||||
|
||||
198
cmd/attesto/report.go
Normal file
198
cmd/attesto/report.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
// [P2.2] `attesto report article12` — deterministic evidence-report templating
|
||||
// (never LLM-generated). The report states what is recorded and independently
|
||||
// verifiable; it never asserts conformity.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
attesto "go.attesto.eu/sdk"
|
||||
)
|
||||
|
||||
const reportDisclaimer = "This report lists evidence recorded and independently " +
|
||||
"verifiable on this stream. It does not assert conformity with any regulation: " +
|
||||
"Attesto attests records; assessing legal obligations is for your advisors."
|
||||
|
||||
var article12Elements = [][2]string{
|
||||
{"(a) period of each use", "event timestamps (occurred_at / ingested_at), hash-chained per stream"},
|
||||
{"(b) reference database checks", "input commitments on model_decision events"},
|
||||
{"(c) input data for the check", "payload commitments (canonical SHA-256, recomputable client-side)"},
|
||||
{"(d) identification of persons involved", "operator/actor references on decision and override events"},
|
||||
}
|
||||
|
||||
func (a *app) report(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 || args[0] != "article12" {
|
||||
return errors.New("usage: attesto report article12 --stream <id> [--from ts] [--to ts] [--output report.md]")
|
||||
}
|
||||
fs := flag.NewFlagSet("report article12", flag.ContinueOnError)
|
||||
fs.SetOutput(a.err)
|
||||
streamID := fs.String("stream", "", "stream id")
|
||||
fromTs := fs.String("from", "", "RFC3339 window start")
|
||||
toTs := fs.String("to", "", "RFC3339 window end")
|
||||
output := fs.String("output", "", "write the markdown report to this file")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if *streamID == "" {
|
||||
return errors.New("--stream is required")
|
||||
}
|
||||
client, err := a.bearerClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
report, err := buildArticle12Report(ctx, client, *streamID, *fromTs, *toTs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *output != "" {
|
||||
if err := os.WriteFile(*output, []byte(report), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.write(map[string]any{"ok": true, "output": *output})
|
||||
}
|
||||
_, err = fmt.Fprint(a.out, report)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildArticle12Report(ctx context.Context, client *attesto.Client, streamID, fromTs, toTs string) (string, error) {
|
||||
var events []attesto.M
|
||||
it := client.IterTenantStreamEvents(streamID, 200)
|
||||
for {
|
||||
event, err := it.Next(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if event == nil {
|
||||
break
|
||||
}
|
||||
ts, _ := firstString(event, "occurred_at", "occurredAt", "ingested_at", "ingestedAt")
|
||||
if ts != "" {
|
||||
if fromTs != "" && ts < fromTs {
|
||||
continue
|
||||
}
|
||||
if toTs != "" && ts > toTs {
|
||||
continue
|
||||
}
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
|
||||
counts := map[string]int{}
|
||||
var seqs []int
|
||||
completenessInput := make([]map[string]any, 0, len(events))
|
||||
for _, event := range events {
|
||||
eventType, _ := firstString(event, "event_type", "eventType")
|
||||
if eventType == "" {
|
||||
eventType = "(untyped)"
|
||||
}
|
||||
counts[eventType]++
|
||||
seq := int(asFloatValue(event["seq_no"], event["seqNo"]))
|
||||
seqs = append(seqs, seq)
|
||||
prev, _ := firstString(event, "prev_event_hash", "prevEventHash")
|
||||
hash, _ := firstString(event, "event_hash", "eventHash")
|
||||
completenessInput = append(completenessInput, map[string]any{
|
||||
"seq_no": seq, "prev_event_hash": prev, "event_hash": hash,
|
||||
})
|
||||
}
|
||||
sort.Ints(seqs)
|
||||
completenessLine := "no events in range"
|
||||
if len(seqs) > 0 {
|
||||
comp := attesto.VerifyCompleteness(completenessInput, seqs[0], seqs[len(seqs)-1])
|
||||
if comp.OK {
|
||||
completenessLine = fmt.Sprintf("PASS — sequence %d..%d is gap-free and hash-chained", seqs[0], seqs[len(seqs)-1])
|
||||
} else {
|
||||
completenessLine = "FAIL — " + strings.Join(comp.Problems, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
var checkpointRows []string
|
||||
cit := client.IterTenantCheckpoints(streamID, 200)
|
||||
for {
|
||||
checkpoint, err := cit.Next(ctx)
|
||||
if err != nil {
|
||||
break // endpoint optional on older deployments
|
||||
}
|
||||
if checkpoint == nil {
|
||||
break
|
||||
}
|
||||
hash, _ := firstString(checkpoint, "checkpoint_hash", "checkpointHash", "rootHash")
|
||||
tx, _ := firstString(checkpoint, "tx_hash", "txHash")
|
||||
if tx == "" {
|
||||
tx = "not yet anchored"
|
||||
}
|
||||
block := "—"
|
||||
if b := asFloatValue(checkpoint["block_number"], checkpoint["blockNumber"]); b > 0 {
|
||||
block = fmt.Sprintf("%d", int64(b))
|
||||
}
|
||||
checkpointRows = append(checkpointRows, fmt.Sprintf("| `%s` | `%s` | %s |", hash, tx, block))
|
||||
}
|
||||
if len(checkpointRows) == 0 {
|
||||
checkpointRows = []string{"| (no checkpoints in range) | — | — |"}
|
||||
}
|
||||
|
||||
window := "stream start"
|
||||
if fromTs != "" {
|
||||
window = fromTs
|
||||
}
|
||||
windowEnd := "now"
|
||||
if toTs != "" {
|
||||
windowEnd = toTs
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Evidence report — stream `%s`\n\n%s\n\n", streamID, reportDisclaimer)
|
||||
fmt.Fprintf(&b, "Window: %s → %s\n\n", window, windowEnd)
|
||||
b.WriteString("## Logging coverage (EU AI Act Article 12(2))\n\n| Element | Evidence recorded |\n|---|---|\n")
|
||||
for _, row := range article12Elements {
|
||||
fmt.Fprintf(&b, "| %s | %s |\n", row[0], row[1])
|
||||
}
|
||||
b.WriteString("\n## Events in range\n\n| Event type | Count |\n|---|---|\n")
|
||||
types := make([]string, 0, len(counts))
|
||||
for t := range counts {
|
||||
types = append(types, t)
|
||||
}
|
||||
sort.Strings(types)
|
||||
for _, t := range types {
|
||||
fmt.Fprintf(&b, "| `%s` | %d |\n", t, counts[t])
|
||||
}
|
||||
fmt.Fprintf(&b, "| **total** | **%d** |\n\n", len(events))
|
||||
fmt.Fprintf(&b, "**Completeness (no omissions):** %s\n\n", completenessLine)
|
||||
b.WriteString("## Verification path per checkpoint\n\n| Checkpoint | Anchor tx | Block |\n|---|---|---|\n")
|
||||
b.WriteString(strings.Join(checkpointRows, "\n"))
|
||||
b.WriteString("\n\n## Replay these checks yourself\n\n```bash\n")
|
||||
b.WriteString("go get go.attesto.eu/sdk # or: pip install attesto / npm i @attesto/sdk\n")
|
||||
fmt.Fprintf(&b, "# offline, no Attesto call: VerifyReceiptOffline / VerifyInclusionProof /\n# VerifyCompleteness over stream %s\n", streamID)
|
||||
b.WriteString("attesto verify truth-package --file <export.zip>\n```\n\n")
|
||||
fmt.Fprintf(&b, "_Generated by attesto-go/%s (deterministic template; no AI involved)._\n", attesto.SDKVersion)
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func firstString(m map[string]any, keys ...string) (string, bool) {
|
||||
for _, key := range keys {
|
||||
if s, ok := m[key].(string); ok && s != "" {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func asFloatValue(values ...any) float64 {
|
||||
for _, v := range values {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
58
cmd/attesto/report_test.go
Normal file
58
cmd/attesto/report_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
attesto "go.attesto.eu/sdk"
|
||||
"go.attesto.eu/sdk/attestotest"
|
||||
)
|
||||
|
||||
func TestArticle12ReportWordingAndStructure(t *testing.T) {
|
||||
server := attestotest.NewServer()
|
||||
defer server.Close()
|
||||
client, err := attesto.NewBearerClient("dummy-tenant-bearer-token", attesto.WithBaseURL(server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
stream, err := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decision := attesto.ModelDecision{Model: "m", Decision: "approve", ConfidenceBp: 8700}
|
||||
payload, err := decision.ToPayload()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
// Distinct source refs: identical refs are one event (idempotent ingestion).
|
||||
if _, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{
|
||||
SourceRef: fmt.Sprintf("e-%d", i), EventType: decision.EventType(), Payload: payload,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
report, err := buildArticle12Report(ctx, client, stream.StreamID, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Article 12(2)",
|
||||
"| `attesto.model_decision` | 2 |",
|
||||
"PASS — sequence 1..2 is gap-free and hash-chained",
|
||||
"deterministic template; no AI involved",
|
||||
"attesto verify truth-package",
|
||||
} {
|
||||
if !strings.Contains(report, want) {
|
||||
t.Errorf("report missing %q", want)
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(report)
|
||||
if strings.Contains(lower, "compliant") || strings.Contains(lower, "compliance guaranteed") {
|
||||
t.Error("claims discipline violated: report must never say compliant")
|
||||
}
|
||||
}
|
||||
135
events.go
Normal file
135
events.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package attesto
|
||||
|
||||
// [P2.2] Typed compliance events — SDK-side conventions, no backend change.
|
||||
// Each ToPayload() returns a plain payload map with regulation_refs included,
|
||||
// validated against the committed-payload number policy. Recording these never
|
||||
// claims conformity: Attesto attests records.
|
||||
|
||||
// Typed event type identifiers.
|
||||
const (
|
||||
EventTypeModelDecision = "attesto.model_decision"
|
||||
EventTypeHumanOverride = "attesto.human_override"
|
||||
EventTypeIncidentReport = "attesto.incident_report"
|
||||
EventTypeDataAccess = "attesto.data_access"
|
||||
)
|
||||
|
||||
// Regulation references per typed event (conventions for reports/auditors).
|
||||
var (
|
||||
RefsModelDecision = []string{"EU-AI-Act:Art.12", "EU-AI-Act:Art.14"}
|
||||
RefsHumanOverride = []string{"EU-AI-Act:Art.14"}
|
||||
RefsIncidentReport = []string{"NIS2:Art.23", "EU-AI-Act:Art.62"}
|
||||
RefsDataAccess = []string{"GDPR:Art.30", "GDPR:Art.6"}
|
||||
)
|
||||
|
||||
func finishPayload(payload M, refs []string, extra M) (M, error) {
|
||||
for key, value := range extra {
|
||||
payload[key] = value
|
||||
}
|
||||
out := M{}
|
||||
for key, value := range payload {
|
||||
if value != nil && value != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
out["regulation_refs"] = refs
|
||||
if err := AssertCommitmentSafeNumbers(map[string]any(out), "$"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ModelDecision is one model-driven decision (commitments only; raw inputs and
|
||||
// outputs never leave your process).
|
||||
type ModelDecision struct {
|
||||
Model string
|
||||
InputCommitment map[string]string
|
||||
OutputCommitment map[string]string
|
||||
Decision string
|
||||
ConfidenceBp int // basis points keep integers commitment-safe
|
||||
HumanInLoop bool
|
||||
OperatorRef string
|
||||
Extra M
|
||||
}
|
||||
|
||||
func (e ModelDecision) EventType() string { return EventTypeModelDecision }
|
||||
|
||||
func (e ModelDecision) ToPayload() (M, error) {
|
||||
return finishPayload(M{
|
||||
"model": e.Model,
|
||||
"input_commitment": orNil(e.InputCommitment),
|
||||
"output_commitment": orNil(e.OutputCommitment),
|
||||
"decision": e.Decision,
|
||||
"confidence_bp": e.ConfidenceBp,
|
||||
"human_in_loop": e.HumanInLoop,
|
||||
"operator_ref": e.OperatorRef,
|
||||
}, RefsModelDecision, e.Extra)
|
||||
}
|
||||
|
||||
// HumanOverride records a human overriding a model decision (Art. 14).
|
||||
type HumanOverride struct {
|
||||
OriginalEventRef string
|
||||
OperatorRef string
|
||||
JustificationCommitment map[string]string
|
||||
NewDecision string
|
||||
Extra M
|
||||
}
|
||||
|
||||
func (e HumanOverride) EventType() string { return EventTypeHumanOverride }
|
||||
|
||||
func (e HumanOverride) ToPayload() (M, error) {
|
||||
return finishPayload(M{
|
||||
"original_event_ref": e.OriginalEventRef,
|
||||
"operator_ref": e.OperatorRef,
|
||||
"justification_commitment": orNil(e.JustificationCommitment),
|
||||
"new_decision": e.NewDecision,
|
||||
}, RefsHumanOverride, e.Extra)
|
||||
}
|
||||
|
||||
// IncidentReport is a reportable incident with NIS2-style field names.
|
||||
type IncidentReport struct {
|
||||
Severity string
|
||||
Category string
|
||||
DetectedAt string
|
||||
SummaryCommitment map[string]string
|
||||
AffectedService string
|
||||
Extra M
|
||||
}
|
||||
|
||||
func (e IncidentReport) EventType() string { return EventTypeIncidentReport }
|
||||
|
||||
func (e IncidentReport) ToPayload() (M, error) {
|
||||
return finishPayload(M{
|
||||
"severity": e.Severity,
|
||||
"category": e.Category,
|
||||
"detected_at": e.DetectedAt,
|
||||
"summary_commitment": orNil(e.SummaryCommitment),
|
||||
"affected_service": e.AffectedService,
|
||||
}, RefsIncidentReport, e.Extra)
|
||||
}
|
||||
|
||||
// DataAccess records an access to personal or regulated data.
|
||||
type DataAccess struct {
|
||||
SubjectRefCommitment map[string]string
|
||||
Purpose string
|
||||
LegalBasis string
|
||||
AccessorRef string
|
||||
Extra M
|
||||
}
|
||||
|
||||
func (e DataAccess) EventType() string { return EventTypeDataAccess }
|
||||
|
||||
func (e DataAccess) ToPayload() (M, error) {
|
||||
return finishPayload(M{
|
||||
"subject_ref_commitment": orNil(e.SubjectRefCommitment),
|
||||
"purpose": e.Purpose,
|
||||
"legal_basis": e.LegalBasis,
|
||||
"accessor_ref": e.AccessorRef,
|
||||
}, RefsDataAccess, e.Extra)
|
||||
}
|
||||
|
||||
func orNil(m map[string]string) any {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
161
export.go
Normal file
161
export.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package attesto
|
||||
|
||||
// [P3.4] Portable receipt export — a self-contained `*.attesto.json`.
|
||||
// Mirrors attesto.export (Python) and export.ts (TypeScript); the
|
||||
// receipt-export.json parity corpus is normative for all three.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ExportFormat = "attesto-receipt-export"
|
||||
ExportFormatVersion = 1
|
||||
)
|
||||
|
||||
// ReceiptExport is the portable envelope. Receipt is kept as raw JSON so the
|
||||
// export carries the receipt verbatim, exactly as the API returned it.
|
||||
type ReceiptExport struct {
|
||||
Format string `json:"format"`
|
||||
FormatVersion int `json:"format_version"`
|
||||
ExportedAt string `json:"exported_at"`
|
||||
StreamID any `json:"stream_id,omitempty"`
|
||||
SeqNo any `json:"seq_no,omitempty"`
|
||||
EventHash any `json:"event_hash,omitempty"`
|
||||
WitnessPublicKeyHex string `json:"witness_public_key_hex,omitempty"`
|
||||
Receipt json.RawMessage `json:"receipt"`
|
||||
Payload M `json:"payload,omitempty"`
|
||||
PayloadCommitment M `json:"payload_commitment,omitempty"`
|
||||
}
|
||||
|
||||
func exportPick(source M, keys ...string) any {
|
||||
for _, key := range keys {
|
||||
if value, ok := source[key]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportReceiptFile builds a portable export from a receipt's raw JSON (as
|
||||
// returned by the API) and optionally writes it to path (empty = no write).
|
||||
func ExportReceiptFile(receiptJSON []byte, path string, witnessPublicKeyHex string) (ReceiptExport, error) {
|
||||
var parsed struct {
|
||||
Payload M `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(receiptJSON, &parsed); err != nil {
|
||||
return ReceiptExport{}, fmt.Errorf("receipt is not valid JSON: %w", err)
|
||||
}
|
||||
export := ReceiptExport{
|
||||
Format: ExportFormat,
|
||||
FormatVersion: ExportFormatVersion,
|
||||
ExportedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
|
||||
StreamID: exportPick(parsed.Payload, "stream_id", "streamId"),
|
||||
SeqNo: exportPick(parsed.Payload, "seq_no", "seqNo"),
|
||||
EventHash: exportPick(parsed.Payload, "event_hash", "eventHash"),
|
||||
WitnessPublicKeyHex: witnessPublicKeyHex,
|
||||
Receipt: json.RawMessage(receiptJSON),
|
||||
}
|
||||
if path != "" {
|
||||
raw, err := json.MarshalIndent(export, "", " ")
|
||||
if err != nil {
|
||||
return ReceiptExport{}, err
|
||||
}
|
||||
if err := os.WriteFile(path, append(raw, '\n'), 0o644); err != nil {
|
||||
return ReceiptExport{}, err
|
||||
}
|
||||
}
|
||||
return export, nil
|
||||
}
|
||||
|
||||
// VerifyReceiptExport verifies a portable export offline. publicKeyHex == ""
|
||||
// falls back to the embedded hint (self-contained mode — proves internal
|
||||
// consistency against the key the file itself names).
|
||||
func VerifyReceiptExport(exportJSON []byte, publicKeyHex string) VerifyReport {
|
||||
var export struct {
|
||||
Format string `json:"format"`
|
||||
FormatVersion int `json:"format_version"`
|
||||
StreamID any `json:"stream_id"`
|
||||
SeqNo any `json:"seq_no"`
|
||||
EventHash any `json:"event_hash"`
|
||||
WitnessPublicKeyHex string `json:"witness_public_key_hex"`
|
||||
Receipt json.RawMessage `json:"receipt"`
|
||||
Payload M `json:"payload"`
|
||||
PayloadCommitment M `json:"payload_commitment"`
|
||||
}
|
||||
kind := VerifyKind("receipt-export")
|
||||
if publicKeyHex == "" {
|
||||
kind = "receipt-export-selfcontained"
|
||||
}
|
||||
fail := func(problems ...string) VerifyReport {
|
||||
return VerifyReport{Kind: kind, OK: false, Problems: problems}
|
||||
}
|
||||
if err := json.Unmarshal(exportJSON, &export); err != nil {
|
||||
return fail("export is not valid JSON: " + err.Error())
|
||||
}
|
||||
var problems []string
|
||||
if export.Format != ExportFormat {
|
||||
problems = append(problems, "not an attesto receipt export (format field)")
|
||||
}
|
||||
if export.FormatVersion != ExportFormatVersion {
|
||||
problems = append(problems, fmt.Sprintf("unsupported export format_version: %d", export.FormatVersion))
|
||||
}
|
||||
if len(export.Receipt) == 0 {
|
||||
problems = append(problems, "export carries no receipt object")
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return fail(problems...)
|
||||
}
|
||||
|
||||
key := publicKeyHex
|
||||
if key == "" {
|
||||
key = export.WitnessPublicKeyHex
|
||||
}
|
||||
if key == "" {
|
||||
return fail("no public key supplied and no embedded hint")
|
||||
}
|
||||
var receipt SignedReceipt
|
||||
if err := json.Unmarshal(export.Receipt, &receipt); err != nil {
|
||||
return fail("receipt is not valid JSON: " + err.Error())
|
||||
}
|
||||
report := VerifyReceiptOffline(receipt, key)
|
||||
problems = append(problems, report.Problems...)
|
||||
|
||||
linkage := []struct {
|
||||
name string
|
||||
outer any
|
||||
keys []string
|
||||
}{
|
||||
{"stream_id", export.StreamID, []string{"stream_id", "streamId"}},
|
||||
{"seq_no", export.SeqNo, []string{"seq_no", "seqNo"}},
|
||||
{"event_hash", export.EventHash, []string{"event_hash", "eventHash"}},
|
||||
}
|
||||
for _, link := range linkage {
|
||||
inner := exportPick(receipt.Payload, link.keys...)
|
||||
if link.outer == nil || inner == nil {
|
||||
continue
|
||||
}
|
||||
// JSON numbers decode as float64 on both sides, so == is sound here.
|
||||
if fmt.Sprint(link.outer) != fmt.Sprint(inner) {
|
||||
problems = append(problems, "export linkage mismatch: "+link.name)
|
||||
}
|
||||
}
|
||||
|
||||
if export.Payload != nil && export.PayloadCommitment != nil {
|
||||
ok, err := VerifyPayloadCommitment(export.Payload, M{"payload_commitment": export.PayloadCommitment})
|
||||
if err != nil || !ok {
|
||||
problems = append(problems, "embedded payload does not match payload_commitment")
|
||||
}
|
||||
}
|
||||
|
||||
return VerifyReport{
|
||||
Kind: kind,
|
||||
OK: len(problems) == 0,
|
||||
ReceiptHash: report.ReceiptHash,
|
||||
EventHash: report.EventHash,
|
||||
Problems: problems,
|
||||
}
|
||||
}
|
||||
77
export_parity_test.go
Normal file
77
export_parity_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package attesto
|
||||
|
||||
// [P3.4] Receipt-export parity corpus — Go verifier.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReceiptExportParity(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "golden-vectors", "sdk-parity", "receipt-export.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read corpus: %v", err)
|
||||
}
|
||||
var corpus struct {
|
||||
Cases []struct {
|
||||
ID string `json:"id"`
|
||||
ExpectOK bool `json:"expect_ok"`
|
||||
PublicKeyHex *string `json:"public_key_hex"`
|
||||
Export json.RawMessage `json:"export"`
|
||||
} `json:"cases"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &corpus); err != nil {
|
||||
t.Fatalf("parse corpus: %v", err)
|
||||
}
|
||||
if len(corpus.Cases) < 5 {
|
||||
t.Fatalf("expected >=5 cases, got %d", len(corpus.Cases))
|
||||
}
|
||||
for _, testCase := range corpus.Cases {
|
||||
t.Run(testCase.ID, func(t *testing.T) {
|
||||
key := ""
|
||||
if testCase.PublicKeyHex != nil {
|
||||
key = *testCase.PublicKeyHex
|
||||
}
|
||||
report := VerifyReceiptExport(testCase.Export, key)
|
||||
if report.OK != testCase.ExpectOK {
|
||||
t.Fatalf("ok=%v want %v (problems: %v)", report.OK, testCase.ExpectOK, report.Problems)
|
||||
}
|
||||
if testCase.PublicKeyHex == nil && testCase.ExpectOK && report.Kind != "receipt-export-selfcontained" {
|
||||
t.Fatalf("kind=%q, want receipt-export-selfcontained", report.Kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportReceiptFileRoundTrip(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "golden-vectors", "sdk-parity", "receipt-export.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read corpus: %v", err)
|
||||
}
|
||||
var corpus struct {
|
||||
Cases []struct {
|
||||
PublicKeyHex *string `json:"public_key_hex"`
|
||||
Export struct {
|
||||
Receipt json.RawMessage `json:"receipt"`
|
||||
} `json:"export"`
|
||||
} `json:"cases"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &corpus); err != nil {
|
||||
t.Fatalf("parse corpus: %v", err)
|
||||
}
|
||||
valid := corpus.Cases[0]
|
||||
path := filepath.Join(t.TempDir(), "receipt.attesto.json")
|
||||
if _, err := ExportReceiptFile(valid.Export.Receipt, path, *valid.PublicKeyHex); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
exported, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read export: %v", err)
|
||||
}
|
||||
report := VerifyReceiptExport(exported, *valid.PublicKeyHex)
|
||||
if !report.OK {
|
||||
t.Fatalf("round-trip verify failed: %v", report.Problems)
|
||||
}
|
||||
}
|
||||
5
heads.go
5
heads.go
@@ -144,6 +144,11 @@ func (s *FileHeadStore) Set(streamID string, seqNo int64, eventHash string) {
|
||||
// does not chain. A forward gap is accepted and advances.
|
||||
func checkAndAdvanceHead(store HeadStore, receipt EventReceipt) error {
|
||||
if storedSeq, storedHash, ok := store.Get(receipt.StreamID); ok {
|
||||
if receipt.SeqNo == storedSeq && receipt.EventHash == storedHash {
|
||||
// Benign idempotent replay: the server deduplicated a resend and
|
||||
// returned the same receipt for the same event.
|
||||
return nil
|
||||
}
|
||||
if receipt.SeqNo <= storedSeq ||
|
||||
(receipt.SeqNo == storedSeq+1 && receipt.PrevEventHash != storedHash) {
|
||||
return &ForkDetectedError{
|
||||
|
||||
@@ -80,3 +80,20 @@ func TestFileHeadStorePersistsAndIs0600(t *testing.T) {
|
||||
t.Error("expected fork on reopened store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExactReplayOfStoredHeadIsBenign(t *testing.T) {
|
||||
// [P3.3 regression] A deduplicated resend returns the same receipt; the
|
||||
// head tracker must treat (same seqNo, same eventHash) as a no-op.
|
||||
store := NewMemoryHeadStore()
|
||||
first := EventReceipt{StreamID: "str_x", SeqNo: 1, EventHash: "h1"}
|
||||
if err := checkAndAdvanceHead(store, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkAndAdvanceHead(store, first); err != nil {
|
||||
t.Fatalf("exact replay must be benign, got %v", err)
|
||||
}
|
||||
fork := EventReceipt{StreamID: "str_x", SeqNo: 1, EventHash: "h2"}
|
||||
if err := checkAndAdvanceHead(store, fork); err == nil {
|
||||
t.Fatal("same seq with different hash must be a fork")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package attesto
|
||||
|
||||
const (
|
||||
SDKVersion = "0.3.0"
|
||||
SDKVersion = "0.4.0"
|
||||
DefaultBaseURL = "https://verify.attesto.eu"
|
||||
ProofstreamProtocol = "ATTESTO-PROOFSTREAM-001"
|
||||
ProtocolVersionAlpha = "0.1-alpha"
|
||||
|
||||
Reference in New Issue
Block a user