Compare commits
20 Commits
edec105858
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
315f7f05f2 | ||
|
|
b5cece2822 | ||
|
|
53ae31e196 | ||
|
|
8dd6c4a784 | ||
|
|
6858bbcdd8 | ||
|
|
4a2d8645b0 | ||
|
|
2276f4da09 | ||
|
|
217db5a11e | ||
|
|
ce9b8ccfbb | ||
|
|
227ea57bd5 | ||
|
|
781a149140 | ||
|
|
b06e59adb4 | ||
|
|
180bec4643 | ||
|
|
a7e455efae | ||
|
|
8781fa57d8 | ||
|
|
7d3e8c5b4f | ||
|
|
1e4a11e486 | ||
|
|
a6a14e5fbb | ||
|
|
a46be8085f | ||
|
|
27a1bfcd00 |
167
README.md
167
README.md
@@ -7,7 +7,17 @@ tooling, CI, evidence exporters, and operator automation. Do not embed Attesto A
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
go get git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go
|
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
|
The first release is VCS-resolved from the Attesto repository. It intentionally
|
||||||
@@ -25,7 +35,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go"
|
attesto "go.attesto.eu/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -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
|
`time.Now().UTC()` when omitted, but production integrations should pass the
|
||||||
real upstream event timestamp whenever the source system provides one.
|
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
|
## Committed payload number rule
|
||||||
|
|
||||||
When events are committed to a Proofstream, payload and metadata numbers must
|
When events are committed to a Proofstream, payload and metadata numbers must
|
||||||
@@ -72,6 +85,20 @@ and integers beyond ±(2^53−1) are rejected at ingestion (HTTP 422); encode
|
|||||||
decimals and large integers as strings (e.g. `{"score": "0.87"}`). This keeps
|
decimals and large integers as strings (e.g. `{"score": "0.87"}`). This keeps
|
||||||
cross-language commitment recomputation byte-exact (`CanonicalJSON`).
|
cross-language commitment recomputation byte-exact (`CanonicalJSON`).
|
||||||
|
|
||||||
|
The SDK enforces the same rule **locally** before sending, so you see it at dev
|
||||||
|
time rather than as a production 422. `LogEvent` / `LogEvents` return an
|
||||||
|
`*UnsafeNumberError` (with `.Path`, the JSON path to the offending value). Set
|
||||||
|
`RequestOptions{SkipPreflight: true}` to defer to the server.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Commitment a Proofstream stores for a payload, byte-identical to the server
|
||||||
|
// (and to the Python / TypeScript SDKs):
|
||||||
|
commitment, _ := attesto.PayloadCommitment(map[string]any{"decision": "approve", "score_bp": 8700})
|
||||||
|
// commitment["canonical_payload_hash"] == server's stored hash
|
||||||
|
|
||||||
|
ok, _ := attesto.VerifyPayloadCommitment(myPayload, event) // recompute and compare
|
||||||
|
```
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Remote verification uses Attesto's public `/v2/verify` API. Offline receipt
|
Remote verification uses Attesto's public `/v2/verify` API. Offline receipt
|
||||||
@@ -85,6 +112,142 @@ if !report.OK {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The offline trust model extends across the whole proof chain — all client-side:
|
||||||
|
|
||||||
|
```go
|
||||||
|
ok, _ := attesto.VerifyInclusionProof(leafHash, proof, windowRoot) // event in a window root
|
||||||
|
ok, _ = attesto.VerifyCheckpointRoot(windowHashes, checkpointRoot) // windows fold to checkpoint root
|
||||||
|
ext := attesto.VerifyCheckpointExtension(previous, current) // one checkpoint continues the previous
|
||||||
|
comp := attesto.VerifyCompleteness(events, 5, 8) // no events omitted in [5, 8]
|
||||||
|
```
|
||||||
|
|
||||||
|
`VerifyCompleteness` proves **no events were omitted** in a range: the sequence
|
||||||
|
numbers must be gap-free and each event's `prev_event_hash` must chain to the
|
||||||
|
previous event's `event_hash`.
|
||||||
|
|
||||||
|
## Your SDK is a witness
|
||||||
|
|
||||||
|
The client remembers the last accepted `(seqNo, eventHash)` per stream and checks
|
||||||
|
every new receipt links forward. If the server ever rewinds a sequence number or
|
||||||
|
presents a divergent lineage, `LogEvent` / `LogEvents` return a
|
||||||
|
`*ForkDetectedError` and the stored head is not advanced. The default store is
|
||||||
|
in-memory; use a file store for fork detection across process invocations, or
|
||||||
|
disable it.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Persist across CLI invocations (atomic, 0600 at ~/.attesto/heads.json):
|
||||||
|
client, _ := attesto.NewClient(apiKey, attesto.WithHeadStore(attesto.NewFileHeadStore("")))
|
||||||
|
|
||||||
|
// Disable fork detection:
|
||||||
|
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
|
||||||
|
embedded copy of the cross-language parity vectors and fails closed with
|
||||||
|
`ErrSelfTest` on any divergence. `attesto doctor` (CLI) prints a deterministic
|
||||||
|
JSON report: self-test, head-store writability, number-policy dry-run
|
||||||
|
(`--sample-payload file.json`), and — with credentials configured —
|
||||||
|
reachability and protocol acceptance.
|
||||||
|
|
||||||
|
## Iterating long listings
|
||||||
|
|
||||||
|
Paginated `List*` methods have `Iter*` twins that walk limit/offset pages
|
||||||
|
transparently; `Next` returns `(nil, nil)` when the listing is exhausted:
|
||||||
|
|
||||||
|
```go
|
||||||
|
it := client.IterTenantStreamEvents("str_...", 200)
|
||||||
|
for {
|
||||||
|
event, err := it.Next(ctx)
|
||||||
|
if err != nil || event == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
process(event)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify anchors on-chain
|
||||||
|
|
||||||
|
`VerifyAnchorOnchain` checks an anchor epoch against the chain itself — one raw
|
||||||
|
JSON-RPC `eth_call` to the anchoring contract's `getCommitment(batchId)`
|
||||||
|
(comparing the on-chain merkle root) plus a transaction-receipt check (status,
|
||||||
|
block). No web3 dependency; the RPC endpoint is yours, so this never asks
|
||||||
|
Attesto to confirm Attesto.
|
||||||
|
|
||||||
|
```go
|
||||||
|
anchor, _ := client.GetAnchorEpoch(ctx, "aep_...")
|
||||||
|
report := attesto.VerifyAnchorOnchain(ctx, anchor, "https://polygon-rpc.example", 15*time.Second)
|
||||||
|
if !report.OK {
|
||||||
|
log.Fatalf("anchor failed on-chain verification: %v", report.Problems)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI equivalent (fetch + on-chain check in one step):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
attesto anchors verify aep_... --rpc-url https://polygon-rpc.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Receiving Attesto webhooks
|
||||||
|
|
||||||
|
```go
|
||||||
|
func handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
headers := map[string]string{
|
||||||
|
"X-Attesto-Timestamp": r.Header.Get("X-Attesto-Timestamp"),
|
||||||
|
"X-Attesto-Signature": r.Header.Get("X-Attesto-Signature"),
|
||||||
|
}
|
||||||
|
if !attesto.VerifyWebhook(body, headers, webhookSecret, 300) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
process(body)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification recomputes `hmac_sha256(secret, "<timestamp>." + body)` from the
|
||||||
|
`X-Attesto-Timestamp` / `X-Attesto-Signature` headers, rejects timestamps more
|
||||||
|
than the allowed skew from now (replay protection), and compares with
|
||||||
|
`hmac.Equal` (constant time).
|
||||||
|
|
||||||
## Operator and Admin Endpoints
|
## Operator and Admin Endpoints
|
||||||
|
|
||||||
System-key clients are created with `attesto.NewClient`. Tenant/operator
|
System-key clients are created with `attesto.NewClient`. Tenant/operator
|
||||||
|
|||||||
172
anchors.go
Normal file
172
anchors.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetCommitmentSelector is the first 4 bytes of keccak256("getCommitment(string)"),
|
||||||
|
// fixed by the contract ABI (APSProvenance.abi.json); pinned so no keccak
|
||||||
|
// implementation is needed.
|
||||||
|
const GetCommitmentSelector = "a7b09e2a"
|
||||||
|
|
||||||
|
// EncodeGetCommitmentCall ABI-encodes getCommitment(string) calldata: selector,
|
||||||
|
// then the single dynamic string argument (offset word, length word, padded bytes).
|
||||||
|
func EncodeGetCommitmentCall(batchID string) string {
|
||||||
|
raw := []byte(batchID)
|
||||||
|
pad := (32 - len(raw)%32) % 32
|
||||||
|
body := make([]byte, 0, 64+len(raw)+pad)
|
||||||
|
offset := make([]byte, 32)
|
||||||
|
offset[31] = 0x20
|
||||||
|
length := make([]byte, 32)
|
||||||
|
for i, v := 31, len(raw); v > 0; i, v = i-1, v>>8 {
|
||||||
|
length[i] = byte(v & 0xff)
|
||||||
|
}
|
||||||
|
body = append(body, offset...)
|
||||||
|
body = append(body, length...)
|
||||||
|
body = append(body, raw...)
|
||||||
|
body = append(body, make([]byte, pad)...)
|
||||||
|
return "0x" + GetCommitmentSelector + hex.EncodeToString(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripHex(value string) string {
|
||||||
|
return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), "0x")
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcCall(ctx context.Context, httpClient *http.Client, rpcURL, method string, params []any) (json.RawMessage, string) {
|
||||||
|
payload, err := json.Marshal(map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": method, "params": params,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "rpc " + method + " failed"
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "rpc " + method + " failed"
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "rpc " + method + " failed"
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var envelope struct {
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error json.RawMessage `json:"error"`
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&envelope) != nil {
|
||||||
|
return nil, "rpc " + method + " failed"
|
||||||
|
}
|
||||||
|
if len(envelope.Error) > 0 && string(envelope.Error) != "null" {
|
||||||
|
return nil, "rpc " + method + " failed"
|
||||||
|
}
|
||||||
|
if len(envelope.Result) == 0 {
|
||||||
|
return nil, "rpc " + method + " failed"
|
||||||
|
}
|
||||||
|
return envelope.Result, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func anchorField(epoch map[string]any, keys ...string) (string, bool) {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := epoch[key].(string); ok && value != "" {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAnchorOnchain verifies an anchor epoch against the chain itself via raw
|
||||||
|
// JSON-RPC: one eth_call to getCommitment(batchId) comparing the on-chain merkle
|
||||||
|
// root with the anchor's merkle_root, plus one eth_getTransactionReceipt
|
||||||
|
// confirming the anchoring transaction succeeded in the expected block. The
|
||||||
|
// anchor object is accepted in snake_case or camelCase; the contract address is
|
||||||
|
// read from the epoch's hashed payload. rpcURL is customer-chosen — this
|
||||||
|
// function never talks to Attesto.
|
||||||
|
func VerifyAnchorOnchain(ctx context.Context, anchorEpoch map[string]any, rpcURL string, timeout time.Duration) VerifyReport {
|
||||||
|
problems := make([]string, 0)
|
||||||
|
|
||||||
|
payload, _ := anchorEpoch["payload"].(map[string]any)
|
||||||
|
merkleRoot, okRoot := anchorField(anchorEpoch, "merkle_root", "merkleRoot")
|
||||||
|
txHash, okTx := anchorField(anchorEpoch, "tx_hash", "txHash")
|
||||||
|
batchID, okBatch := anchorField(anchorEpoch, "anchor_batch_id", "anchorBatchId")
|
||||||
|
contractAddress, okAddr := anchorField(anchorEpoch, "contract_address", "contractAddress")
|
||||||
|
if !okAddr && payload != nil {
|
||||||
|
contractAddress, okAddr = anchorField(payload, "contract_address")
|
||||||
|
}
|
||||||
|
blockNumber := int64(-1)
|
||||||
|
switch v := anchorEpoch["block_number"].(type) {
|
||||||
|
case float64:
|
||||||
|
blockNumber = int64(v)
|
||||||
|
case int64:
|
||||||
|
blockNumber = v
|
||||||
|
default:
|
||||||
|
switch v := anchorEpoch["blockNumber"].(type) {
|
||||||
|
case float64:
|
||||||
|
blockNumber = int64(v)
|
||||||
|
case int64:
|
||||||
|
blockNumber = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !okRoot || !okTx || !okBatch || !okAddr || blockNumber < 0 {
|
||||||
|
return VerifyReport{
|
||||||
|
Kind: "anchor-onchain", OK: false,
|
||||||
|
Problems: []string{"anchor epoch is missing required fields"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
httpClient := &http.Client{Timeout: timeout}
|
||||||
|
|
||||||
|
callResult, problem := rpcCall(ctx, httpClient, rpcURL, "eth_call", []any{
|
||||||
|
map[string]any{"to": contractAddress, "data": EncodeGetCommitmentCall(batchID)},
|
||||||
|
"latest",
|
||||||
|
})
|
||||||
|
if problem != "" {
|
||||||
|
problems = append(problems, problem)
|
||||||
|
} else {
|
||||||
|
var returned string
|
||||||
|
if json.Unmarshal(callResult, &returned) != nil {
|
||||||
|
problems = append(problems, "rpc eth_call failed")
|
||||||
|
} else {
|
||||||
|
// Return tuple is (bytes32 merkleRoot, uint32, string, uint64);
|
||||||
|
// the root is the first 32-byte word.
|
||||||
|
onchain := stripHex(returned)
|
||||||
|
if len(onchain) < 64 || onchain[:64] != stripHex(merkleRoot) {
|
||||||
|
problems = append(problems, "anchor merkle root mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
receiptResult, problem := rpcCall(ctx, httpClient, rpcURL, "eth_getTransactionReceipt", []any{txHash})
|
||||||
|
if problem != "" {
|
||||||
|
problems = append(problems, problem)
|
||||||
|
} else if string(receiptResult) == "null" {
|
||||||
|
problems = append(problems, "anchor transaction not found")
|
||||||
|
} else {
|
||||||
|
var receipt struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
BlockNumber string `json:"blockNumber"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(receiptResult, &receipt) != nil {
|
||||||
|
problems = append(problems, "anchor transaction not found")
|
||||||
|
} else {
|
||||||
|
if receipt.Status != "0x1" {
|
||||||
|
problems = append(problems, "anchor transaction failed")
|
||||||
|
}
|
||||||
|
mined, err := strconv.ParseInt(stripHex(receipt.BlockNumber), 16, 64)
|
||||||
|
if err != nil || mined != blockNumber {
|
||||||
|
problems = append(problems, "anchor transaction block mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return VerifyReport{Kind: "anchor-onchain", OK: len(problems) == 0, Problems: problems}
|
||||||
|
}
|
||||||
131
anchors_test.go
Normal file
131
anchors_test.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
anchorRoot = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
anchorTx = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
anchorBlock = int64(12345)
|
||||||
|
)
|
||||||
|
|
||||||
|
func anchorEpochFixture() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"anchorEpochId": "aep_demo",
|
||||||
|
"merkleRoot": "0x" + anchorRoot,
|
||||||
|
"txHash": anchorTx,
|
||||||
|
"blockNumber": float64(anchorBlock),
|
||||||
|
"anchorBatchId": "batch_demo",
|
||||||
|
"chainId": float64(137),
|
||||||
|
"payload": map[string]any{"contract_address": "0x" + strings.Repeat("d", 40)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockRPC(t *testing.T, callRoot string, receipt map[string]any) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params []any `json:"params"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch body.Method {
|
||||||
|
case "eth_call":
|
||||||
|
// (bytes32, uint32, string, uint64) — only the first word matters.
|
||||||
|
result := "0x" + callRoot + strings.Repeat("0", 192)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": result})
|
||||||
|
case "eth_getTransactionReceipt":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": receipt})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeGetCommitmentCallMatchesPinnedExample(t *testing.T) {
|
||||||
|
want := "0xa7b09e2a" +
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000020" +
|
||||||
|
"000000000000000000000000000000000000000000000000000000000000000a" +
|
||||||
|
"62617463685f64656d6f00000000000000000000000000000000000000000000"
|
||||||
|
if got := EncodeGetCommitmentCall("batch_demo"); got != want {
|
||||||
|
t.Errorf("calldata = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABIStillDeclaresGetCommitmentString(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join("testdata", "APSProvenance.abi.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var abi []map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &abi); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range abi {
|
||||||
|
if entry["name"] == "getCommitment" {
|
||||||
|
inputs := entry["inputs"].([]any)
|
||||||
|
if len(inputs) != 1 || inputs[0].(map[string]any)["type"] != "string" {
|
||||||
|
t.Fatalf("getCommitment inputs changed: %v", inputs)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("getCommitment not found in ABI")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnchorVerifiesWhenChainMatches(t *testing.T) {
|
||||||
|
srv := mockRPC(t, anchorRoot, map[string]any{"status": "0x1", "blockNumber": "0x3039"})
|
||||||
|
defer srv.Close()
|
||||||
|
report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second)
|
||||||
|
if !report.OK {
|
||||||
|
t.Errorf("expected ok, problems: %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnchorRootMismatch(t *testing.T) {
|
||||||
|
srv := mockRPC(t, strings.Repeat("e", 64), map[string]any{"status": "0x1", "blockNumber": "0x3039"})
|
||||||
|
defer srv.Close()
|
||||||
|
report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second)
|
||||||
|
if report.OK || !containsProblem(report.Problems, "anchor merkle root mismatch") {
|
||||||
|
t.Errorf("problems: %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnchorFailedTransaction(t *testing.T) {
|
||||||
|
srv := mockRPC(t, anchorRoot, map[string]any{"status": "0x0", "blockNumber": "0x3039"})
|
||||||
|
defer srv.Close()
|
||||||
|
report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second)
|
||||||
|
if !containsProblem(report.Problems, "anchor transaction failed") {
|
||||||
|
t.Errorf("problems: %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnchorWrongBlock(t *testing.T) {
|
||||||
|
srv := mockRPC(t, anchorRoot, map[string]any{"status": "0x1", "blockNumber": "0x3040"})
|
||||||
|
defer srv.Close()
|
||||||
|
report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second)
|
||||||
|
if !containsProblem(report.Problems, "anchor transaction block mismatch") {
|
||||||
|
t.Errorf("problems: %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsProblem(problems []string, want string) bool {
|
||||||
|
for _, p := range problems {
|
||||||
|
if p == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
76
client.go
76
client.go
@@ -23,6 +23,7 @@ type Client struct {
|
|||||||
maxRetries int
|
maxRetries int
|
||||||
userAgent string
|
userAgent string
|
||||||
validateKey bool
|
validateKey bool
|
||||||
|
headStore HeadStore
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Client) error
|
type Option func(*Client) error
|
||||||
@@ -51,6 +52,7 @@ func newClient(bearer string, validateKey bool, opts ...Option) (*Client, error)
|
|||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
userAgent: "attesto-go/" + SDKVersion,
|
userAgent: "attesto-go/" + SDKVersion,
|
||||||
validateKey: validateKey,
|
validateKey: validateKey,
|
||||||
|
headStore: NewMemoryHeadStore(),
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
if err := opt(client); err != nil {
|
if err := opt(client); err != nil {
|
||||||
@@ -112,6 +114,16 @@ func WithUserAgent(userAgent string) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithHeadStore sets the store used for client-side fork detection. The default
|
||||||
|
// is an in-memory store; pass NewFileHeadStore(path) to persist across process
|
||||||
|
// invocations, or nil to disable fork detection.
|
||||||
|
func WithHeadStore(store HeadStore) Option {
|
||||||
|
return func(c *Client) error {
|
||||||
|
c.headStore = store
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) CreateStream(ctx context.Context, input StreamCreateInput, options ...RequestOptions) (*Stream, error) {
|
func (c *Client) CreateStream(ctx context.Context, input StreamCreateInput, options ...RequestOptions) (*Stream, error) {
|
||||||
var out Stream
|
var out Stream
|
||||||
if input.Metadata == nil {
|
if input.Metadata == nil {
|
||||||
@@ -161,24 +173,65 @@ func (c *Client) LogEvent(ctx context.Context, streamID string, input EventInput
|
|||||||
if err := normalizeEventInput(&input); err != nil {
|
if err := normalizeEventInput(&input); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Reject commitment-unsafe numbers locally so the developer sees the rule at
|
||||||
|
// dev time rather than as a production 422; SkipPreflight defers to the server.
|
||||||
|
if !skipPreflight(options) {
|
||||||
|
if err := AssertCommitmentSafeNumbers(input.Payload, "$.payload"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := AssertCommitmentSafeNumbers(input.Metadata, "$.metadata"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
var out EventReceipt
|
var out EventReceipt
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out)
|
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out); err != nil {
|
||||||
return &out, err
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.trackHead(out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackHead checks the receipt extends the stored head (returning a
|
||||||
|
// *ForkDetectedError on a rewind/divergence) and advances the store. No-op when
|
||||||
|
// fork detection is disabled (headStore is nil).
|
||||||
|
func (c *Client) trackHead(receipt EventReceipt) error {
|
||||||
|
if c.headStore == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return checkAndAdvanceHead(c.headStore, receipt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventInput, options ...RequestOptions) (*EventBatchResponse, error) {
|
func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventInput, options ...RequestOptions) (*EventBatchResponse, error) {
|
||||||
if len(events) > 1000 {
|
if len(events) > 1000 {
|
||||||
return nil, errors.New("max 1000 events per batch")
|
return nil, errors.New("max 1000 events per batch")
|
||||||
}
|
}
|
||||||
|
preflight := !skipPreflight(options)
|
||||||
for i := range events {
|
for i := range events {
|
||||||
if err := normalizeEventInput(&events[i]); err != nil {
|
if err := normalizeEventInput(&events[i]); err != nil {
|
||||||
return nil, fmt.Errorf("event %d: %w", i, err)
|
return nil, fmt.Errorf("event %d: %w", i, err)
|
||||||
}
|
}
|
||||||
|
if preflight {
|
||||||
|
if err := AssertCommitmentSafeNumbers(events[i].Payload, fmt.Sprintf("$.events[%d].payload", i)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := AssertCommitmentSafeNumbers(events[i].Metadata, fmt.Sprintf("$.events[%d].metadata", i)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
body := M{"events": events}
|
body := M{"events": events}
|
||||||
var out EventBatchResponse
|
var out EventBatchResponse
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out)
|
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out); err != nil {
|
||||||
return &out, err
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, receipt := range out.Receipts {
|
||||||
|
if err := c.trackHead(receipt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetReceipt(ctx context.Context, streamEventID string) (*EventReceipt, error) {
|
func (c *Client) GetReceipt(ctx context.Context, streamEventID string) (*EventReceipt, error) {
|
||||||
@@ -481,6 +534,9 @@ func (c *Client) requestRaw(ctx context.Context, method, path string, values url
|
|||||||
req.Header.Set("Authorization", "Bearer "+c.bearer)
|
req.Header.Set("Authorization", "Bearer "+c.bearer)
|
||||||
req.Header.Set("User-Agent", c.userAgent)
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
req.Header.Set("X-Attesto-SDK", c.userAgent)
|
req.Header.Set("X-Attesto-SDK", c.userAgent)
|
||||||
|
// [P1.9] protocol handshake: the backend answers 426 when it speaks a
|
||||||
|
// different protocol generation (ErrProtocolMismatch via APIError).
|
||||||
|
req.Header.Set("X-Attesto-Protocol", ProtocolHandshake)
|
||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
@@ -524,6 +580,14 @@ func (e *APIError) Error() string {
|
|||||||
return fmt.Sprintf("attesto api error: status=%d message=%s", e.StatusCode, e.Message)
|
return fmt.Sprintf("attesto api error: status=%d message=%s", e.StatusCode, e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsProtocolMismatch reports whether err is a 426 protocol-handshake rejection:
|
||||||
|
// the backend speaks a different protocol generation than this SDK announced in
|
||||||
|
// X-Attesto-Protocol. Upgrade the SDK to a release that speaks it.
|
||||||
|
func IsProtocolMismatch(err error) bool {
|
||||||
|
var apiErr *APIError
|
||||||
|
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUpgradeRequired
|
||||||
|
}
|
||||||
|
|
||||||
func decodeResponse(resp *http.Response, out any) error {
|
func decodeResponse(resp *http.Response, out any) error {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode == http.StatusNoContent {
|
if resp.StatusCode == http.StatusNoContent {
|
||||||
@@ -558,6 +622,10 @@ func retryable(status int) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func skipPreflight(options []RequestOptions) bool {
|
||||||
|
return len(options) > 0 && options[0].SkipPreflight
|
||||||
|
}
|
||||||
|
|
||||||
func idempotency(options []RequestOptions) string {
|
func idempotency(options []RequestOptions) string {
|
||||||
if len(options) > 0 && options[0].IdempotencyKey != "" {
|
if len(options) > 0 && options[0].IdempotencyKey != "" {
|
||||||
return options[0].IdempotencyKey
|
return options[0].IdempotencyKey
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -15,12 +16,13 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go"
|
attesto "go.attesto.eu/sdk"
|
||||||
"git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go/connectorkit"
|
"go.attesto.eu/sdk/connectorkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
const cliVersion = "0.2.0"
|
const cliVersion = "0.4.0"
|
||||||
|
|
||||||
var supportedVerifyKindNames = []string{
|
var supportedVerifyKindNames = []string{
|
||||||
"receipt",
|
"receipt",
|
||||||
@@ -124,7 +126,7 @@ func (a *app) dispatch(ctx context.Context, args []string) error {
|
|||||||
case "witnesses":
|
case "witnesses":
|
||||||
return a.witnesses(ctx, args[1:])
|
return a.witnesses(ctx, args[1:])
|
||||||
case "anchors":
|
case "anchors":
|
||||||
return a.verifyableObject(ctx, "anchors", args[1:], attesto.VerifyAnchor, "/v2/anchors/")
|
return a.anchors(ctx, args[1:])
|
||||||
case "bundles":
|
case "bundles":
|
||||||
return a.bundles(ctx, args[1:])
|
return a.bundles(ctx, args[1:])
|
||||||
case "verify":
|
case "verify":
|
||||||
@@ -135,12 +137,22 @@ func (a *app) dispatch(ctx context.Context, args []string) error {
|
|||||||
return a.quorum(ctx, args[1:])
|
return a.quorum(ctx, args[1:])
|
||||||
case "ivc":
|
case "ivc":
|
||||||
return a.ivc(ctx, args[1:])
|
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":
|
case "connectors":
|
||||||
return a.connectors(ctx, args[1:])
|
return a.connectors(ctx, args[1:])
|
||||||
case "local-vault":
|
case "local-vault":
|
||||||
return a.localVault(ctx, args[1:])
|
return a.localVault(ctx, args[1:])
|
||||||
case "marketplace":
|
case "marketplace":
|
||||||
return a.marketplace(ctx, args[1:])
|
return a.marketplace(ctx, args[1:])
|
||||||
|
case "doctor":
|
||||||
|
return a.doctor(ctx, args[1:])
|
||||||
|
case "report":
|
||||||
|
return a.report(ctx, args[1:])
|
||||||
case "readiness":
|
case "readiness":
|
||||||
return a.readiness(args[1:])
|
return a.readiness(args[1:])
|
||||||
default:
|
default:
|
||||||
@@ -164,6 +176,30 @@ func (a *app) verify(ctx context.Context, args []string) error {
|
|||||||
return errors.New("--file is required")
|
return errors.New("--file is required")
|
||||||
}
|
}
|
||||||
return a.write(verifyTruthPackageZip(*file))
|
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:
|
default:
|
||||||
_ = ctx
|
_ = ctx
|
||||||
return fmt.Errorf("unknown verify subcommand: %s", args[0])
|
return fmt.Errorf("unknown verify subcommand: %s", args[0])
|
||||||
@@ -410,6 +446,136 @@ func (a *app) verifyableObject(ctx context.Context, group string, args []string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doctor diagnoses an SDK/CLI install: vendored parity self-test, head-store
|
||||||
|
// writability, number-policy dry-run on a sample payload, and (when an API key
|
||||||
|
// is configured) reachability, protocol-header acceptance, and clock skew vs
|
||||||
|
// the server Date header. Deterministic JSON report: {"ok": bool, "checks": {...}}.
|
||||||
|
func (a *app) doctor(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(a.err)
|
||||||
|
samplePayload := fs.String("sample-payload", "", "JSON file with a sample payload for the number-policy dry-run")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
checks := map[string]map[string]any{}
|
||||||
|
pass := func(name string, extra map[string]any) {
|
||||||
|
if extra == nil {
|
||||||
|
extra = map[string]any{}
|
||||||
|
}
|
||||||
|
extra["ok"] = true
|
||||||
|
checks[name] = extra
|
||||||
|
}
|
||||||
|
fail := func(name string, err error) {
|
||||||
|
checks[name] = map[string]any{"ok": false, "error": err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := attesto.EnsureSelfTest(); err != nil {
|
||||||
|
fail("self_test", err)
|
||||||
|
} else {
|
||||||
|
pass("self_test", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
headStore := attesto.NewFileHeadStore("")
|
||||||
|
headStore.Set("__doctor__", 1, strings.Repeat("0", 64))
|
||||||
|
if seq, hash, ok := headStore.Get("__doctor__"); ok && seq == 1 && hash == strings.Repeat("0", 64) {
|
||||||
|
pass("head_store", nil)
|
||||||
|
} else {
|
||||||
|
fail("head_store", errors.New("head store readback failed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if *samplePayload != "" {
|
||||||
|
raw, err := os.ReadFile(*samplePayload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var payload any
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := attesto.AssertCommitmentSafeNumbers(payload, "$"); err != nil {
|
||||||
|
fail("number_policy", err)
|
||||||
|
} else {
|
||||||
|
pass("number_policy", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if client, err := a.systemClient(); err == nil {
|
||||||
|
start := time.Now()
|
||||||
|
head, headErr := client.GetStreamHead(ctx, "__doctor-probe__")
|
||||||
|
_ = head
|
||||||
|
status := 0
|
||||||
|
var apiErr *attesto.APIError
|
||||||
|
if errors.As(headErr, &apiErr) {
|
||||||
|
status = apiErr.StatusCode
|
||||||
|
}
|
||||||
|
// Any HTTP-level answer (including 404 for the probe id) proves
|
||||||
|
// reachability + auth handling; transport errors do not.
|
||||||
|
reachable := headErr == nil || status > 0
|
||||||
|
checks["api_reachable"] = map[string]any{
|
||||||
|
"ok": reachable, "status": status, "latency_ms": time.Since(start).Milliseconds(),
|
||||||
|
}
|
||||||
|
checks["protocol_accepted"] = map[string]any{"ok": status != http.StatusUpgradeRequired, "status": status}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok := true
|
||||||
|
for _, check := range checks {
|
||||||
|
if v, has := check["ok"].(bool); has && !v {
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.write(map[string]any{"ok": ok, "checks": checks})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) anchors(ctx context.Context, args []string) error {
|
||||||
|
// `anchors verify <anchor_epoch_id> --rpc-url <url>` chains an API fetch
|
||||||
|
// with the on-chain check (eth_call getCommitment + tx receipt) against a
|
||||||
|
// customer-chosen RPC endpoint. Without --rpc-url, all subcommands keep the
|
||||||
|
// existing get / remote-verify behavior.
|
||||||
|
if len(args) > 0 && args[0] == "verify" {
|
||||||
|
rest := args[1:]
|
||||||
|
positional := ""
|
||||||
|
if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
|
||||||
|
positional, rest = rest[0], rest[1:]
|
||||||
|
}
|
||||||
|
fs := flag.NewFlagSet("anchors verify", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(a.err)
|
||||||
|
id := fs.String("id", positional, "anchor epoch id")
|
||||||
|
rpcURL := fs.String("rpc-url", "", "JSON-RPC endpoint for the anchor's chain")
|
||||||
|
timeoutS := fs.Int("timeout-s", 15, "RPC timeout in seconds")
|
||||||
|
file := fs.String("file", "", "proof object JSON file (remote verify mode)")
|
||||||
|
publicKeyHex := fs.String("public-key-hex", "", "Ed25519 public key hex (remote verify mode)")
|
||||||
|
if err := fs.Parse(rest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *rpcURL == "" {
|
||||||
|
fallback := []string{}
|
||||||
|
if *file != "" {
|
||||||
|
fallback = append(fallback, "--file", *file)
|
||||||
|
}
|
||||||
|
if *publicKeyHex != "" {
|
||||||
|
fallback = append(fallback, "--public-key-hex", *publicKeyHex)
|
||||||
|
}
|
||||||
|
return a.remoteVerify(ctx, fallback, attesto.VerifyAnchor)
|
||||||
|
}
|
||||||
|
if *id == "" {
|
||||||
|
return errors.New("anchor epoch id is required (positional or --id)")
|
||||||
|
}
|
||||||
|
client, err := a.systemClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
anchor, err := client.GetAnchorEpoch(ctx, *id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
report := attesto.VerifyAnchorOnchain(ctx, anchor, *rpcURL, time.Duration(*timeoutS)*time.Second)
|
||||||
|
return a.write(report)
|
||||||
|
}
|
||||||
|
return a.verifyableObject(ctx, "anchors", args, attesto.VerifyAnchor, "/v2/anchors/")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) checkpoints(ctx context.Context, args []string) error {
|
func (a *app) checkpoints(ctx context.Context, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("checkpoints subcommand required")
|
return errors.New("checkpoints subcommand required")
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go"
|
attesto "go.attesto.eu/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
go.mod
2
go.mod
@@ -1,3 +1,3 @@
|
|||||||
module git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go
|
module go.attesto.eu/sdk
|
||||||
|
|
||||||
go 1.24
|
go 1.24
|
||||||
|
|||||||
165
heads.go
Normal file
165
heads.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HeadStore persists the last accepted (seqNo, eventHash) head per stream so the
|
||||||
|
// client can detect a rewound or divergent server history.
|
||||||
|
type HeadStore interface {
|
||||||
|
Get(streamID string) (seqNo int64, eventHash string, ok bool)
|
||||||
|
Set(streamID string, seqNo int64, eventHash string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForkDetectedError reports that a receipt did not extend the last accepted head
|
||||||
|
// for its stream. The store is NOT advanced when this is returned.
|
||||||
|
type ForkDetectedError struct {
|
||||||
|
StreamID string
|
||||||
|
ExpectedSeq int64
|
||||||
|
ExpectedHash string
|
||||||
|
GotSeq int64
|
||||||
|
GotPrevHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ForkDetectedError) Error() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"fork detected on stream %s: receipt (seq=%d prev=%s) does not extend "+
|
||||||
|
"last accepted head (seq=%d hash=%s)",
|
||||||
|
e.StreamID, e.GotSeq, e.GotPrevHash, e.ExpectedSeq, e.ExpectedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryHeadStore keeps heads in process memory; safe for concurrent use.
|
||||||
|
type MemoryHeadStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
heads map[string][2]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemoryHeadStore() *MemoryHeadStore {
|
||||||
|
return &MemoryHeadStore{heads: map[string][2]any{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryHeadStore) Get(streamID string) (int64, string, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
entry, ok := s.heads[streamID]
|
||||||
|
if !ok {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
return entry[0].(int64), entry[1].(string), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryHeadStore) Set(streamID string, seqNo int64, eventHash string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.heads[streamID] = [2]any{seqNo, eventHash}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileHeadStore persists heads to a JSON file (default ~/.attesto/heads.json),
|
||||||
|
// giving fork detection across separate process invocations. Writes are atomic.
|
||||||
|
type FileHeadStore struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileHeadStore uses the given path, or ~/.attesto/heads.json when empty.
|
||||||
|
func NewFileHeadStore(path string) *FileHeadStore {
|
||||||
|
if path == "" {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
path = filepath.Join(home, ".attesto", "heads.json")
|
||||||
|
} else {
|
||||||
|
path = ".attesto-heads.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &FileHeadStore{path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileHeadStore) load() map[string][2]json.RawMessage {
|
||||||
|
raw, err := os.ReadFile(s.path)
|
||||||
|
if err != nil {
|
||||||
|
return map[string][2]json.RawMessage{}
|
||||||
|
}
|
||||||
|
out := map[string][2]json.RawMessage{}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return map[string][2]json.RawMessage{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileHeadStore) Get(streamID string) (int64, string, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
entry, ok := s.load()[streamID]
|
||||||
|
if !ok {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
var seqNo int64
|
||||||
|
var eventHash string
|
||||||
|
if json.Unmarshal(entry[0], &seqNo) != nil || json.Unmarshal(entry[1], &eventHash) != nil {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
return seqNo, eventHash, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileHeadStore) Set(streamID string, seqNo int64, eventHash string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
heads := map[string][2]any{}
|
||||||
|
for key, entry := range s.load() {
|
||||||
|
var n int64
|
||||||
|
var h string
|
||||||
|
_ = json.Unmarshal(entry[0], &n)
|
||||||
|
_ = json.Unmarshal(entry[1], &h)
|
||||||
|
heads[key] = [2]any{n, h}
|
||||||
|
}
|
||||||
|
heads[streamID] = [2]any{seqNo, eventHash}
|
||||||
|
body, err := json.Marshal(heads)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".heads-")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
_, writeErr := tmp.Write(body)
|
||||||
|
closeErr := tmp.Close()
|
||||||
|
if writeErr != nil || closeErr != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Chmod(tmpName, 0o600)
|
||||||
|
_ = os.Rename(tmpName, s.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAndAdvanceHead verifies a receipt extends the stored head, then advances
|
||||||
|
// the store. Returns *ForkDetectedError (without advancing) when the receipt
|
||||||
|
// rewinds or collides on seqNo, or claims to be the immediate next event but
|
||||||
|
// 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{
|
||||||
|
StreamID: receipt.StreamID,
|
||||||
|
ExpectedSeq: storedSeq,
|
||||||
|
ExpectedHash: storedHash,
|
||||||
|
GotSeq: receipt.SeqNo,
|
||||||
|
GotPrevHash: receipt.PrevEventHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.Set(receipt.StreamID, receipt.SeqNo, receipt.EventHash)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
99
heads_test.go
Normal file
99
heads_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func receipt(seqNo int64, eventHash, prevEventHash string) EventReceipt {
|
||||||
|
return EventReceipt{
|
||||||
|
StreamID: "str_demo",
|
||||||
|
SeqNo: seqNo,
|
||||||
|
EventHash: eventHash,
|
||||||
|
PrevEventHash: prevEventHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryHeadStoreInOrderAdvances(t *testing.T) {
|
||||||
|
store := NewMemoryHeadStore()
|
||||||
|
for _, r := range []EventReceipt{receipt(1, "h1", ""), receipt(2, "h2", "h1"), receipt(3, "h3", "h2")} {
|
||||||
|
if err := checkAndAdvanceHead(store, r); err != nil {
|
||||||
|
t.Fatalf("unexpected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seq, hash, ok := store.Get("str_demo"); !ok || seq != 3 || hash != "h3" {
|
||||||
|
t.Errorf("head = (%d,%s,%v)", seq, hash, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForgedRewoundReceiptIsFork(t *testing.T) {
|
||||||
|
store := NewMemoryHeadStore()
|
||||||
|
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
|
||||||
|
_ = checkAndAdvanceHead(store, receipt(2, "h2", "h1"))
|
||||||
|
err := checkAndAdvanceHead(store, receipt(2, "h2-fork", "h1"))
|
||||||
|
var fork *ForkDetectedError
|
||||||
|
if !errors.As(err, &fork) {
|
||||||
|
t.Fatalf("expected *ForkDetectedError, got %v", err)
|
||||||
|
}
|
||||||
|
if seq, hash, _ := store.Get("str_demo"); seq != 2 || hash != "h2" {
|
||||||
|
t.Errorf("store advanced past fork: (%d,%s)", seq, hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDivergentNextEventIsFork(t *testing.T) {
|
||||||
|
store := NewMemoryHeadStore()
|
||||||
|
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
|
||||||
|
err := checkAndAdvanceHead(store, receipt(2, "h2", "WRONG"))
|
||||||
|
var fork *ForkDetectedError
|
||||||
|
if !errors.As(err, &fork) {
|
||||||
|
t.Fatalf("expected *ForkDetectedError, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardGapAccepted(t *testing.T) {
|
||||||
|
store := NewMemoryHeadStore()
|
||||||
|
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
|
||||||
|
if err := checkAndAdvanceHead(store, receipt(5, "h5", "h4")); err != nil {
|
||||||
|
t.Fatalf("forward gap should be accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileHeadStorePersistsAndIs0600(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "heads.json")
|
||||||
|
store := NewFileHeadStore(path)
|
||||||
|
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
|
||||||
|
_ = checkAndAdvanceHead(store, receipt(2, "h2", "h1"))
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("mode = %o, want 600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
reopened := NewFileHeadStore(path)
|
||||||
|
if seq, hash, ok := reopened.Get("str_demo"); !ok || seq != 2 || hash != "h2" {
|
||||||
|
t.Errorf("reopened head = (%d,%s,%v)", seq, hash, ok)
|
||||||
|
}
|
||||||
|
if err := checkAndAdvanceHead(reopened, receipt(2, "h2-fork", "h1")); err == nil {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
80
iterator.go
Normal file
80
iterator.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// PageFunc fetches one limit/offset page of list results.
|
||||||
|
type PageFunc func(ctx context.Context, limit, offset int) ([]M, error)
|
||||||
|
|
||||||
|
// Iterator walks limit/offset pages of a list endpoint transparently, stopping
|
||||||
|
// on the first short page. Same endpoints, no new API surface.
|
||||||
|
type Iterator struct {
|
||||||
|
fetch PageFunc
|
||||||
|
pageSize int
|
||||||
|
buffer []M
|
||||||
|
offset int
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIterator wraps any limit/offset PageFunc. pageSize <= 0 defaults to 100.
|
||||||
|
func NewIterator(fetch PageFunc, pageSize int) *Iterator {
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
return &Iterator{fetch: fetch, pageSize: pageSize}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next returns the next item, or (nil, nil) when the listing is exhausted.
|
||||||
|
func (it *Iterator) Next(ctx context.Context) (M, error) {
|
||||||
|
if len(it.buffer) == 0 && !it.done {
|
||||||
|
page, err := it.fetch(ctx, it.pageSize, it.offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
it.offset += it.pageSize
|
||||||
|
if len(page) < it.pageSize {
|
||||||
|
it.done = true
|
||||||
|
}
|
||||||
|
it.buffer = page
|
||||||
|
}
|
||||||
|
if len(it.buffer) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
item := it.buffer[0]
|
||||||
|
it.buffer = it.buffer[1:]
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IterTenantStreamEvents returns an iterator over a stream's events.
|
||||||
|
func (c *Client) IterTenantStreamEvents(streamID string, pageSize int) *Iterator {
|
||||||
|
return NewIterator(func(ctx context.Context, limit, offset int) ([]M, error) {
|
||||||
|
return c.ListTenantStreamEvents(ctx, streamID, limit, offset)
|
||||||
|
}, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IterTenantWindows returns an iterator over a stream's windows.
|
||||||
|
func (c *Client) IterTenantWindows(streamID string, pageSize int) *Iterator {
|
||||||
|
return NewIterator(func(ctx context.Context, limit, offset int) ([]M, error) {
|
||||||
|
return c.ListTenantWindows(ctx, streamID, limit, offset)
|
||||||
|
}, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IterTenantCheckpoints returns an iterator over a stream's checkpoints.
|
||||||
|
func (c *Client) IterTenantCheckpoints(streamID string, pageSize int) *Iterator {
|
||||||
|
return NewIterator(func(ctx context.Context, limit, offset int) ([]M, error) {
|
||||||
|
return c.ListTenantCheckpoints(ctx, streamID, limit, offset)
|
||||||
|
}, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IterForkEvidence returns an iterator over a stream's fork evidence.
|
||||||
|
func (c *Client) IterForkEvidence(streamID string, pageSize int) *Iterator {
|
||||||
|
return NewIterator(func(ctx context.Context, limit, offset int) ([]M, error) {
|
||||||
|
return c.ListForkEvidence(ctx, streamID, limit, offset)
|
||||||
|
}, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IterTenantIVCEpochs returns an iterator over a stream's IVC epochs.
|
||||||
|
func (c *Client) IterTenantIVCEpochs(streamID string, pageSize int) *Iterator {
|
||||||
|
return NewIterator(func(ctx context.Context, limit, offset int) ([]M, error) {
|
||||||
|
return c.ListTenantIVCEpochs(ctx, streamID, limit, offset)
|
||||||
|
}, pageSize)
|
||||||
|
}
|
||||||
65
iterator_test.go
Normal file
65
iterator_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIteratorDrainsThreePagesInOrder(t *testing.T) {
|
||||||
|
pages := map[int][]M{
|
||||||
|
0: {{"seqNo": 1}, {"seqNo": 2}, {"seqNo": 3}},
|
||||||
|
3: {{"seqNo": 4}, {"seqNo": 5}, {"seqNo": 6}},
|
||||||
|
6: {{"seqNo": 7}},
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
it := NewIterator(func(_ context.Context, limit, offset int) ([]M, error) {
|
||||||
|
calls++
|
||||||
|
if limit != 3 {
|
||||||
|
t.Fatalf("limit = %d, want 3", limit)
|
||||||
|
}
|
||||||
|
return pages[offset], nil
|
||||||
|
}, 3)
|
||||||
|
|
||||||
|
var seqs []int
|
||||||
|
for {
|
||||||
|
item, err := it.Next(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seqs = append(seqs, item["seqNo"].(int))
|
||||||
|
}
|
||||||
|
want := []int{1, 2, 3, 4, 5, 6, 7}
|
||||||
|
if len(seqs) != len(want) {
|
||||||
|
t.Fatalf("seqs = %v", seqs)
|
||||||
|
}
|
||||||
|
for i, v := range want {
|
||||||
|
if seqs[i] != v {
|
||||||
|
t.Fatalf("seqs = %v, want %v", seqs, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 3 {
|
||||||
|
t.Errorf("calls = %d, want 3", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIteratorStopsCleanlyOnShortFirstPage(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
it := NewIterator(func(_ context.Context, _, _ int) ([]M, error) {
|
||||||
|
calls++
|
||||||
|
return []M{{"streamId": "str_only"}}, nil
|
||||||
|
}, 100)
|
||||||
|
first, err := it.Next(context.Background())
|
||||||
|
if err != nil || first == nil {
|
||||||
|
t.Fatalf("first = %v err = %v", first, err)
|
||||||
|
}
|
||||||
|
second, err := it.Next(context.Background())
|
||||||
|
if err != nil || second != nil {
|
||||||
|
t.Fatalf("second = %v err = %v", second, err)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Errorf("calls = %d, want 1 (short page must end iteration)", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
419
proofstream.go
419
proofstream.go
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -69,6 +70,421 @@ func SHA256Hex(value []byte) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxSafeInteger mirrors JavaScript's Number.MAX_SAFE_INTEGER (2^53 - 1).
|
||||||
|
// Integers beyond this lose precision when a JS verifier parses them, so they
|
||||||
|
// would re-serialize to different canonical bytes across languages.
|
||||||
|
const maxSafeInteger = int64(1)<<53 - 1
|
||||||
|
|
||||||
|
// UnsafeNumberError reports a committed payload/metadata number whose
|
||||||
|
// canonical-JSON bytes diverge across Python, Go, and JavaScript (a non-integer,
|
||||||
|
// or an integer outside +/-(2^53 - 1)). Path is the JSON path to the offender.
|
||||||
|
type UnsafeNumberError struct {
|
||||||
|
Path string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *UnsafeNumberError) Error() string { return e.Message }
|
||||||
|
|
||||||
|
func nonIntegerError(path string) *UnsafeNumberError {
|
||||||
|
return &UnsafeNumberError{
|
||||||
|
Path: path,
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"non-integer numbers are not permitted in committed payloads (%s); "+
|
||||||
|
"encode decimals as strings", path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsafeIntegerError(path string) *UnsafeNumberError {
|
||||||
|
return &UnsafeNumberError{
|
||||||
|
Path: path,
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"integers beyond +/-2^53-1 are not permitted in committed payloads (%s); "+
|
||||||
|
"encode large integers as strings", path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSafeFloat(v float64, path string) error {
|
||||||
|
if math.IsInf(v, 0) || math.IsNaN(v) || math.Trunc(v) != v {
|
||||||
|
return nonIntegerError(path)
|
||||||
|
}
|
||||||
|
if math.Abs(v) > float64(maxSafeInteger) {
|
||||||
|
return unsafeIntegerError(path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSafeInt64(v int64, path string) error {
|
||||||
|
if v > maxSafeInteger || v < -maxSafeInteger {
|
||||||
|
return unsafeIntegerError(path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSafeNumberString(s, path string) error {
|
||||||
|
if strings.ContainsAny(s, ".eE") {
|
||||||
|
f, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nonIntegerError(path)
|
||||||
|
}
|
||||||
|
return assertSafeFloat(f, path)
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
// Outside int64 range is necessarily outside the safe integer range.
|
||||||
|
return unsafeIntegerError(path)
|
||||||
|
}
|
||||||
|
return assertSafeInt64(n, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssertCommitmentSafeNumbers rejects numbers whose canonical-JSON bytes diverge
|
||||||
|
// across Python, Go, and JavaScript, so a commitment computed here matches the
|
||||||
|
// backend's byte-for-byte. Mirrors assert_commitment_safe_numbers in the backend:
|
||||||
|
// non-integer numbers are rejected outright; integers must be within +/-(2^53 - 1).
|
||||||
|
// Booleans are exempt. Returns an *UnsafeNumberError on the first offending value.
|
||||||
|
func AssertCommitmentSafeNumbers(value any, path string) error {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case nil, bool, string:
|
||||||
|
return nil
|
||||||
|
case json.Number:
|
||||||
|
return assertSafeNumberString(v.String(), path)
|
||||||
|
case float64:
|
||||||
|
return assertSafeFloat(v, path)
|
||||||
|
case float32:
|
||||||
|
return assertSafeFloat(float64(v), path)
|
||||||
|
case int:
|
||||||
|
return assertSafeInt64(int64(v), path)
|
||||||
|
case int8:
|
||||||
|
return assertSafeInt64(int64(v), path)
|
||||||
|
case int16:
|
||||||
|
return assertSafeInt64(int64(v), path)
|
||||||
|
case int32:
|
||||||
|
return assertSafeInt64(int64(v), path)
|
||||||
|
case int64:
|
||||||
|
return assertSafeInt64(v, path)
|
||||||
|
case uint:
|
||||||
|
return assertSafeUint64(uint64(v), path)
|
||||||
|
case uint8:
|
||||||
|
return assertSafeInt64(int64(v), path)
|
||||||
|
case uint16:
|
||||||
|
return assertSafeInt64(int64(v), path)
|
||||||
|
case uint32:
|
||||||
|
return assertSafeInt64(int64(v), path)
|
||||||
|
case uint64:
|
||||||
|
return assertSafeUint64(v, path)
|
||||||
|
case []any:
|
||||||
|
for i, item := range v {
|
||||||
|
if err := AssertCommitmentSafeNumbers(item, fmt.Sprintf("%s[%d]", path, i)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case []M:
|
||||||
|
for i, item := range v {
|
||||||
|
if err := AssertCommitmentSafeNumbers(item, fmt.Sprintf("%s[%d]", path, i)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case map[string]any:
|
||||||
|
return assertSafeMap(v, path)
|
||||||
|
case M:
|
||||||
|
return assertSafeMap(map[string]any(v), path)
|
||||||
|
default:
|
||||||
|
// Structs and other types: normalize through JSON exactly as the
|
||||||
|
// canonical encoder does, then re-check the plain representation.
|
||||||
|
raw, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var decoded any
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
dec.UseNumber()
|
||||||
|
if err := dec.Decode(&decoded); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return AssertCommitmentSafeNumbers(decoded, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSafeUint64(v uint64, path string) error {
|
||||||
|
if v > uint64(maxSafeInteger) {
|
||||||
|
return unsafeIntegerError(path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSafeMap(m map[string]any, path string) error {
|
||||||
|
for key, item := range m {
|
||||||
|
if err := AssertCommitmentSafeNumbers(item, fmt.Sprintf("%s.%s", path, key)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayloadCommitment returns a deterministic commitment to an event payload,
|
||||||
|
// byte-identical to the server's stored payload_commitment. Call
|
||||||
|
// AssertCommitmentSafeNumbers first if the payload is not yet known to be safe.
|
||||||
|
func PayloadCommitment(payload any) (map[string]string, error) {
|
||||||
|
if err := EnsureSelfTest(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw, err := CanonicalJSON(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]string{
|
||||||
|
"hash_alg": "sha256",
|
||||||
|
"canonical_payload_hash": SHA256Hex(raw),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataCommitment returns a deterministic commitment to event metadata,
|
||||||
|
// byte-identical to the server's stored metadata_commitment.
|
||||||
|
func MetadataCommitment(metadata any) (map[string]string, error) {
|
||||||
|
raw, err := CanonicalJSON(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]string{
|
||||||
|
"hash_alg": "sha256",
|
||||||
|
"canonical_metadata_hash": SHA256Hex(raw),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func storedCommitmentHash(event map[string]any, commitmentKey, hashKey string) string {
|
||||||
|
containers := []any{event, event["envelope"], event["envelope_json"]}
|
||||||
|
for _, container := range containers {
|
||||||
|
obj, ok := container.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
commitment, ok := obj[commitmentKey].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stored, ok := commitment[hashKey].(string); ok {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPayloadCommitment recomputes the payload commitment locally and compares
|
||||||
|
// it to the value stored on an event (payload_commitment.canonical_payload_hash,
|
||||||
|
// whether the event is given flat or under envelope / envelope_json). Returns
|
||||||
|
// false when the stored commitment is absent or differs.
|
||||||
|
func VerifyPayloadCommitment(payload any, event map[string]any) (bool, error) {
|
||||||
|
stored := storedCommitmentHash(event, "payload_commitment", "canonical_payload_hash")
|
||||||
|
if stored == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
commitment, err := PayloadCommitment(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return commitment["canonical_payload_hash"] == stored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyMetadataCommitment recomputes the metadata commitment locally and
|
||||||
|
// compares it to the value stored on an event.
|
||||||
|
func VerifyMetadataCommitment(metadata any, event map[string]any) (bool, error) {
|
||||||
|
stored := storedCommitmentHash(event, "metadata_commitment", "canonical_metadata_hash")
|
||||||
|
if stored == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
commitment, err := MetadataCommitment(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return commitment["canonical_metadata_hash"] == stored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowNodeHash(left, right string) (string, error) {
|
||||||
|
return DomainHashHex(ProofstreamDomains["window"], map[string]any{
|
||||||
|
"kind": "node", "left_hash": left, "right_hash": right,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointNodeHash(left, right string) (string, error) {
|
||||||
|
return DomainHashHex(ProofstreamDomains["checkpoint"], map[string]any{
|
||||||
|
"kind": "node", "left_hash": left, "right_hash": right,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InclusionStep is one node of a window inclusion proof.
|
||||||
|
type InclusionStep struct {
|
||||||
|
Side string `json:"side"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyInclusionProof folds a window inclusion proof from a leaf up to the
|
||||||
|
// window root. Mirrors verify_inclusion_proof in the backend windows.py: a left
|
||||||
|
// sibling hashes as node(sibling, current), a right sibling as node(current, sibling).
|
||||||
|
func VerifyInclusionProof(leafHash string, proof []InclusionStep, rootHash string) (bool, error) {
|
||||||
|
current := leafHash
|
||||||
|
for _, step := range proof {
|
||||||
|
var err error
|
||||||
|
switch step.Side {
|
||||||
|
case "left":
|
||||||
|
if step.Hash == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
current, err = windowNodeHash(step.Hash, current)
|
||||||
|
case "right":
|
||||||
|
if step.Hash == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
current, err = windowNodeHash(current, step.Hash)
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current == rootHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyCheckpointRoot recomputes a checkpoint root from its window hashes and
|
||||||
|
// compares. Mirrors checkpoint_root_hash in the backend checkpoints.py: an odd
|
||||||
|
// node at any level is promoted unchanged (never duplicated/hashed with itself).
|
||||||
|
func VerifyCheckpointRoot(windowHashes []string, expectedRoot string) (bool, error) {
|
||||||
|
if len(windowHashes) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
level := append([]string{}, windowHashes...)
|
||||||
|
for len(level) > 1 {
|
||||||
|
next := make([]string, 0, (len(level)+1)/2)
|
||||||
|
for offset := 0; offset < len(level); offset += 2 {
|
||||||
|
if offset+1 >= len(level) {
|
||||||
|
next = append(next, level[offset]) // promote, do not duplicate
|
||||||
|
} else {
|
||||||
|
h, err := checkpointNodeHash(level[offset], level[offset+1])
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
next = append(next, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
level = next
|
||||||
|
}
|
||||||
|
return level[0] == expectedRoot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyCheckpointExtension checks that current continues previous: contiguous
|
||||||
|
// sequence (current.from_seq_no == previous.to_seq_no + 1) and back-link
|
||||||
|
// (current.previous_checkpoint_hash == previous.checkpoint_hash).
|
||||||
|
func VerifyCheckpointExtension(previous, current map[string]any) VerifyReport {
|
||||||
|
problems := make([]string, 0)
|
||||||
|
if asFloat(current["from_seq_no"]) != asFloat(previous["to_seq_no"])+1 {
|
||||||
|
problems = append(problems, "checkpoint does not extend previous (sequence gap)")
|
||||||
|
}
|
||||||
|
if !sameString(current["previous_checkpoint_hash"], previous["checkpoint_hash"]) {
|
||||||
|
problems = append(problems, "checkpoint previous_checkpoint_hash does not match previous")
|
||||||
|
}
|
||||||
|
return VerifyReport{Kind: "checkpoint-extension", OK: len(problems) == 0, Problems: problems}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyCompleteness proves no events were omitted in [fromSeqNo, toSeqNo]: the
|
||||||
|
// sequence numbers must be gap-free and every event's prev_event_hash must equal
|
||||||
|
// the previous event's event_hash (the per-stream hash chain).
|
||||||
|
func VerifyCompleteness(events []map[string]any, fromSeqNo, toSeqNo int) VerifyReport {
|
||||||
|
problems := make([]string, 0)
|
||||||
|
ordered := append([]map[string]any{}, events...)
|
||||||
|
sort.Slice(ordered, func(i, j int) bool {
|
||||||
|
return asFloat(ordered[i]["seq_no"]) < asFloat(ordered[j]["seq_no"])
|
||||||
|
})
|
||||||
|
gapFree := len(ordered) == toSeqNo-fromSeqNo+1
|
||||||
|
if gapFree {
|
||||||
|
for i, event := range ordered {
|
||||||
|
if int(asFloat(event["seq_no"])) != fromSeqNo+i {
|
||||||
|
gapFree = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gapFree {
|
||||||
|
problems = append(problems, "sequence range is not gap-free")
|
||||||
|
} else {
|
||||||
|
for i := 1; i < len(ordered); i++ {
|
||||||
|
if !sameString(ordered[i]["prev_event_hash"], ordered[i-1]["event_hash"]) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"event chain broken at seq_no %d", int(asFloat(ordered[i]["seq_no"]))))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return VerifyReport{Kind: "completeness", OK: len(problems) == 0, Problems: problems}
|
||||||
|
}
|
||||||
|
|
||||||
|
func asFloat(v any) float64 {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return n
|
||||||
|
case int:
|
||||||
|
return float64(n)
|
||||||
|
case int64:
|
||||||
|
return float64(n)
|
||||||
|
case json.Number:
|
||||||
|
f, _ := n.Float64()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameString(a, b any) bool {
|
||||||
|
as, aok := a.(string)
|
||||||
|
bs, bok := b.(string)
|
||||||
|
if aok && bok {
|
||||||
|
return as == bs
|
||||||
|
}
|
||||||
|
return a == nil && b == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func webhookHeader(headers map[string]string, name string) (string, bool) {
|
||||||
|
for key, value := range headers {
|
||||||
|
if strings.EqualFold(key, name) {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyWebhook verifies an inbound webhook delivered by Attesto. It reads the
|
||||||
|
// X-Attesto-Timestamp / X-Attesto-Signature headers (case-insensitive), rejects
|
||||||
|
// when the timestamp is outside maxSkewS of now, recomputes
|
||||||
|
// hmac_sha256(secret, "<ts>." + body), and compares in constant time. Mirrors
|
||||||
|
// the backend webhook signing scheme.
|
||||||
|
func VerifyWebhook(body []byte, headers map[string]string, secret string, maxSkewS int) bool {
|
||||||
|
return verifyWebhookAt(body, headers, secret, maxSkewS, time.Now().Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyWebhookAt(body []byte, headers map[string]string, secret string, maxSkewS int, now int64) bool {
|
||||||
|
timestamp, okTS := webhookHeader(headers, "x-attesto-timestamp")
|
||||||
|
signature, okSig := webhookHeader(headers, "x-attesto-signature")
|
||||||
|
if !okTS || !okSig {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ts, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
skew := now - ts
|
||||||
|
if skew < 0 {
|
||||||
|
skew = -skew
|
||||||
|
}
|
||||||
|
if skew > int64(maxSkewS) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(strconv.FormatInt(ts, 10)))
|
||||||
|
mac.Write([]byte("."))
|
||||||
|
mac.Write(body)
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
return hmac.Equal([]byte(expected), []byte(signature))
|
||||||
|
}
|
||||||
|
|
||||||
func SignConnectorWebhookPayload(secret string, body []byte, timestamp int64) (string, string) {
|
func SignConnectorWebhookPayload(secret string, body []byte, timestamp int64) (string, string) {
|
||||||
if timestamp == 0 {
|
if timestamp == 0 {
|
||||||
timestamp = time.Now().Unix()
|
timestamp = time.Now().Unix()
|
||||||
@@ -90,6 +506,9 @@ func SignedConnectorWebhookHeaders(secret string, body []byte, timestamp int64)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func VerifyReceiptOffline(receipt SignedReceipt, publicKeyHex string) VerifyReport {
|
func VerifyReceiptOffline(receipt SignedReceipt, publicKeyHex string) VerifyReport {
|
||||||
|
if err := EnsureSelfTest(); err != nil {
|
||||||
|
return VerifyReport{Kind: VerifyReceipt, OK: false, Problems: []string{err.Error()}}
|
||||||
|
}
|
||||||
problems := make([]string, 0)
|
problems := make([]string, 0)
|
||||||
hash, err := DomainHashHex(ProofstreamDomains["receipt"], receipt.Payload)
|
hash, err := DomainHashHex(ProofstreamDomains["receipt"], receipt.Payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
203
proofstream_parity_test.go
Normal file
203
proofstream_parity_test.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type parityVectors struct {
|
||||||
|
Accept []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
CanonicalPayloadHash string `json:"canonical_payload_hash"`
|
||||||
|
} `json:"accept"`
|
||||||
|
Reject []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"reject"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadParityVectors(t *testing.T) parityVectors {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "canonical-numbers.json")
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read parity vectors: %v", err)
|
||||||
|
}
|
||||||
|
var v parityVectors
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
t.Fatalf("decode parity vectors: %v", err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParityAcceptCommitmentHashes(t *testing.T) {
|
||||||
|
for _, c := range loadParityVectors(t).Accept {
|
||||||
|
commitment, err := PayloadCommitment(c.Payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: PayloadCommitment: %v", c.ID, err)
|
||||||
|
}
|
||||||
|
if commitment["hash_alg"] != "sha256" {
|
||||||
|
t.Errorf("%s: hash_alg %q", c.ID, commitment["hash_alg"])
|
||||||
|
}
|
||||||
|
if commitment["canonical_payload_hash"] != c.CanonicalPayloadHash {
|
||||||
|
t.Errorf("%s: hash %s != %s", c.ID, commitment["canonical_payload_hash"], c.CanonicalPayloadHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParityAcceptPassPreflight(t *testing.T) {
|
||||||
|
for _, c := range loadParityVectors(t).Accept {
|
||||||
|
if err := AssertCommitmentSafeNumbers(c.Payload, "$"); err != nil {
|
||||||
|
t.Errorf("%s: unexpected preflight error: %v", c.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParityRejectPaths(t *testing.T) {
|
||||||
|
for _, c := range loadParityVectors(t).Reject {
|
||||||
|
err := AssertCommitmentSafeNumbers(c.Payload, "$")
|
||||||
|
var ue *UnsafeNumberError
|
||||||
|
if !errors.As(err, &ue) {
|
||||||
|
t.Errorf("%s: expected *UnsafeNumberError, got %v", c.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ue.Path != c.Path {
|
||||||
|
t.Errorf("%s: path %s != %s", c.ID, ue.Path, c.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type receiptParityVectors struct {
|
||||||
|
Cases []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PublicKeyHex string `json:"public_key_hex"`
|
||||||
|
Receipt SignedReceipt `json:"receipt"`
|
||||||
|
ExpectOK bool `json:"expect_ok"`
|
||||||
|
ExpectProblem []string `json:"expect_problems"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParityReceiptVerification(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "receipts.json")
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read receipt vectors: %v", err)
|
||||||
|
}
|
||||||
|
var v receiptParityVectors
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
t.Fatalf("decode receipt vectors: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range v.Cases {
|
||||||
|
report := VerifyReceiptOffline(c.Receipt, c.PublicKeyHex)
|
||||||
|
if report.OK != c.ExpectOK {
|
||||||
|
t.Errorf("%s: ok=%v want %v (problems=%v)", c.ID, report.OK, c.ExpectOK, report.Problems)
|
||||||
|
}
|
||||||
|
if len(report.Problems) != len(c.ExpectProblem) {
|
||||||
|
t.Errorf("%s: problems=%v want %v", c.ID, report.Problems, c.ExpectProblem)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, p := range c.ExpectProblem {
|
||||||
|
if report.Problems[i] != p {
|
||||||
|
t.Errorf("%s: problem[%d]=%q want %q", c.ID, i, report.Problems[i], p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type inclusionParityVectors struct {
|
||||||
|
Inclusion []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
LeafHash string `json:"leaf_hash"`
|
||||||
|
Proof []InclusionStep `json:"proof"`
|
||||||
|
RootHash string `json:"root_hash"`
|
||||||
|
ExpectOK bool `json:"expect_ok"`
|
||||||
|
} `json:"inclusion"`
|
||||||
|
CheckpointRoot []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
WindowHashes []string `json:"window_hashes"`
|
||||||
|
ExpectedRoot string `json:"expected_root"`
|
||||||
|
ExpectOK bool `json:"expect_ok"`
|
||||||
|
} `json:"checkpoint_root"`
|
||||||
|
CheckpointExtension []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Previous map[string]any `json:"previous"`
|
||||||
|
Current map[string]any `json:"current"`
|
||||||
|
ExpectOK bool `json:"expect_ok"`
|
||||||
|
} `json:"checkpoint_extension"`
|
||||||
|
Completeness []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Events []map[string]any `json:"events"`
|
||||||
|
FromSeqNo int `json:"from_seq_no"`
|
||||||
|
ToSeqNo int `json:"to_seq_no"`
|
||||||
|
ExpectOK bool `json:"expect_ok"`
|
||||||
|
} `json:"completeness"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParityInclusionAndCheckpoint(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "inclusion.json")
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read inclusion vectors: %v", err)
|
||||||
|
}
|
||||||
|
var v inclusionParityVectors
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
t.Fatalf("decode inclusion vectors: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range v.Inclusion {
|
||||||
|
ok, err := VerifyInclusionProof(c.LeafHash, c.Proof, c.RootHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", c.ID, err)
|
||||||
|
}
|
||||||
|
if ok != c.ExpectOK {
|
||||||
|
t.Errorf("inclusion %s: ok=%v want %v", c.ID, ok, c.ExpectOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range v.CheckpointRoot {
|
||||||
|
ok, err := VerifyCheckpointRoot(c.WindowHashes, c.ExpectedRoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", c.ID, err)
|
||||||
|
}
|
||||||
|
if ok != c.ExpectOK {
|
||||||
|
t.Errorf("checkpoint_root %s: ok=%v want %v", c.ID, ok, c.ExpectOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range v.CheckpointExtension {
|
||||||
|
report := VerifyCheckpointExtension(c.Previous, c.Current)
|
||||||
|
if report.OK != c.ExpectOK {
|
||||||
|
t.Errorf("extension %s: ok=%v want %v (%v)", c.ID, report.OK, c.ExpectOK, report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range v.Completeness {
|
||||||
|
report := VerifyCompleteness(c.Events, c.FromSeqNo, c.ToSeqNo)
|
||||||
|
if report.OK != c.ExpectOK {
|
||||||
|
t.Errorf("completeness %s: ok=%v want %v (%v)", c.ID, report.OK, c.ExpectOK, report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParityVerifyPayloadCommitment(t *testing.T) {
|
||||||
|
for _, c := range loadParityVectors(t).Accept {
|
||||||
|
commitment, err := PayloadCommitment(c.Payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: PayloadCommitment: %v", c.ID, err)
|
||||||
|
}
|
||||||
|
stored := map[string]any{}
|
||||||
|
for k, v := range commitment {
|
||||||
|
stored[k] = v
|
||||||
|
}
|
||||||
|
event := map[string]any{"envelope": map[string]any{"payload_commitment": stored}}
|
||||||
|
ok, err := VerifyPayloadCommitment(c.Payload, event)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Errorf("%s: verify match ok=%v err=%v", c.ID, ok, err)
|
||||||
|
}
|
||||||
|
bad, err := VerifyPayloadCommitment(map[string]any{"tampered": float64(1)}, event)
|
||||||
|
if err != nil || bad {
|
||||||
|
t.Errorf("%s: tampered verify bad=%v err=%v", c.ID, bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
selftest.go
Normal file
72
selftest.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// [P1.10] Trimmed parity vectors vendored into the package; regenerated from
|
||||||
|
// golden-vectors/sdk-parity (do not hand-edit).
|
||||||
|
//
|
||||||
|
//go:embed selftest_vectors.json
|
||||||
|
var selftestVectors []byte
|
||||||
|
|
||||||
|
// ErrSelfTest wraps a failed vendored parity self-test: this install's hashing
|
||||||
|
// diverges from the pinned cross-language vectors (corrupted package or broken
|
||||||
|
// runtime). The SDK fails closed rather than produce wrong evidence.
|
||||||
|
var ErrSelfTest = errors.New("attesto self-test failed")
|
||||||
|
|
||||||
|
var (
|
||||||
|
selftestOnce sync.Once
|
||||||
|
selftestErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func runSelfTest(raw []byte) error {
|
||||||
|
var vectors struct {
|
||||||
|
Commitment struct {
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
CanonicalPayloadHash string `json:"canonical_payload_hash"`
|
||||||
|
} `json:"commitment"`
|
||||||
|
Receipt struct {
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
ReceiptHash string `json:"receipt_hash"`
|
||||||
|
} `json:"receipt"`
|
||||||
|
Inclusion struct {
|
||||||
|
LeafHash string `json:"leaf_hash"`
|
||||||
|
Proof []InclusionStep `json:"proof"`
|
||||||
|
RootHash string `json:"root_hash"`
|
||||||
|
} `json:"inclusion"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &vectors); err != nil {
|
||||||
|
return fmt.Errorf("%w: vendored vectors unreadable: %v", ErrSelfTest, err)
|
||||||
|
}
|
||||||
|
canonical, err := CanonicalJSON(vectors.Commitment.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrSelfTest, err)
|
||||||
|
}
|
||||||
|
if SHA256Hex(canonical) != vectors.Commitment.CanonicalPayloadHash {
|
||||||
|
return fmt.Errorf("%w: commitment hash diverged from vendored vector", ErrSelfTest)
|
||||||
|
}
|
||||||
|
receiptHash, err := DomainHashHex(ProofstreamDomains["receipt"], vectors.Receipt.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrSelfTest, err)
|
||||||
|
}
|
||||||
|
if receiptHash != vectors.Receipt.ReceiptHash {
|
||||||
|
return fmt.Errorf("%w: receipt domain-hash diverged from vendored vector", ErrSelfTest)
|
||||||
|
}
|
||||||
|
ok, err := VerifyInclusionProof(
|
||||||
|
vectors.Inclusion.LeafHash, vectors.Inclusion.Proof, vectors.Inclusion.RootHash)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return fmt.Errorf("%w: inclusion fold diverged from vendored vector", ErrSelfTest)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureSelfTest runs the vendored parity self-test once per process (cached).
|
||||||
|
func EnsureSelfTest() error {
|
||||||
|
selftestOnce.Do(func() { selftestErr = runSelfTest(selftestVectors) })
|
||||||
|
return selftestErr
|
||||||
|
}
|
||||||
26
selftest_test.go
Normal file
26
selftest_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelfTestPassesOnVendoredVectors(t *testing.T) {
|
||||||
|
if err := EnsureSelfTest(); err != nil {
|
||||||
|
t.Fatalf("self-test failed on shipped vectors: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorruptedVendoredVectorFailsClosed(t *testing.T) {
|
||||||
|
corrupted := bytes.Replace(
|
||||||
|
selftestVectors,
|
||||||
|
[]byte(`"canonical_payload_hash": "`),
|
||||||
|
[]byte(`"canonical_payload_hash": "0`),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
err := runSelfTest(corrupted)
|
||||||
|
if !errors.Is(err, ErrSelfTest) {
|
||||||
|
t.Fatalf("expected ErrSelfTest, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
selftest_vectors.json
Normal file
58
selftest_vectors.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"note": "Trimmed parity self-test set vendored into each SDK package; regenerated from golden-vectors/sdk-parity (do not hand-edit).",
|
||||||
|
"commitment": {
|
||||||
|
"payload": {
|
||||||
|
"b": 2,
|
||||||
|
"a": 1,
|
||||||
|
"nested": {
|
||||||
|
"z": [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"flag": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"canonical_payload_hash": "8b2252e6632801faa71c8eb4a597bfeaba385face3ddef30b7eb636787902304"
|
||||||
|
},
|
||||||
|
"receipt": {
|
||||||
|
"payload": {
|
||||||
|
"event_hash": "cd7753e1bc862729d4de6d2e9d9c0942830e557cf539ebcbf00bf9b4cd6a62a9",
|
||||||
|
"event_id": "evt_golden_2",
|
||||||
|
"issued_at": "2026-06-05T20:59:04.000Z",
|
||||||
|
"prev_event_hash": "731f8a0a3cc6ea2ca00bb26dca8f721da34e1328750f2e16a32f7bf7b36d7264",
|
||||||
|
"protocol": "ATTESTO-PROOFSTREAM-001",
|
||||||
|
"protocol_version": "0.1-alpha",
|
||||||
|
"seq_no": 2,
|
||||||
|
"signer": {
|
||||||
|
"alg": "ed25519",
|
||||||
|
"key_epoch": "golden-key-2026-06",
|
||||||
|
"kid": "golden-key-2026-06"
|
||||||
|
},
|
||||||
|
"stream_event_id": "sev_golden_2",
|
||||||
|
"stream_head_hash": "140142e46eb7cbe17db99c717666cb5a038a858feaff7cc26b78d10c8868da51",
|
||||||
|
"stream_id": "str_golden",
|
||||||
|
"system_id": "sys_golden",
|
||||||
|
"tenant_id": "tnt_golden"
|
||||||
|
},
|
||||||
|
"receipt_hash": "4f72f15fa4d07c9a83baa908f6168e27beeb1d4c9984f70e52f67ff6c380ccb2"
|
||||||
|
},
|
||||||
|
"inclusion": {
|
||||||
|
"leaf_hash": "7973a148f766058042177a3507c5baa4757c6159c91049cbc42f60f138715072",
|
||||||
|
"proof": [
|
||||||
|
{
|
||||||
|
"side": "right",
|
||||||
|
"hash": "2faac607f33011ec3bdf305e01e36d1257bb226868cec5df4bfecbb8a552b48b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"side": "right",
|
||||||
|
"hash": "9ff585ea4fd08983d242fd3b73144e800b07a4ae86324d4e0f0fb2f70933b26f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"side": "right",
|
||||||
|
"hash": "6977f0ace2809f289002e6600967de240acc3dcdcf62aa4e078c56c10f57d381"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"root_hash": "37a6a0d69e0951df3827205cfb8440c84d5d60c729b4c00a0d9460361923a18b"
|
||||||
|
}
|
||||||
|
}
|
||||||
172
testdata/APSProvenance.abi.json
vendored
Normal file
172
testdata/APSProvenance.abi.json
vendored
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"inputs": [],
|
||||||
|
"stateMutability": "nonpayable",
|
||||||
|
"type": "constructor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"internalType": "string",
|
||||||
|
"name": "batchId",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "AlreadyCommitted",
|
||||||
|
"type": "error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inputs": [],
|
||||||
|
"name": "EmptyBatchId",
|
||||||
|
"type": "error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inputs": [],
|
||||||
|
"name": "NotOwner",
|
||||||
|
"type": "error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anonymous": false,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"indexed": true,
|
||||||
|
"internalType": "string",
|
||||||
|
"name": "batchId",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"indexed": true,
|
||||||
|
"internalType": "bytes32",
|
||||||
|
"name": "merkleRoot",
|
||||||
|
"type": "bytes32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"indexed": false,
|
||||||
|
"internalType": "uint32",
|
||||||
|
"name": "runCount",
|
||||||
|
"type": "uint32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"indexed": false,
|
||||||
|
"internalType": "string",
|
||||||
|
"name": "model",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"indexed": false,
|
||||||
|
"internalType": "uint64",
|
||||||
|
"name": "blockTimestamp",
|
||||||
|
"type": "uint64"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "RootCommitted",
|
||||||
|
"type": "event"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"internalType": "uint256",
|
||||||
|
"name": "",
|
||||||
|
"type": "uint256"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "batchIds",
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"internalType": "string",
|
||||||
|
"name": "",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stateMutability": "view",
|
||||||
|
"type": "function"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"internalType": "string",
|
||||||
|
"name": "batchId",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalType": "bytes32",
|
||||||
|
"name": "merkleRoot",
|
||||||
|
"type": "bytes32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalType": "uint32",
|
||||||
|
"name": "runCount",
|
||||||
|
"type": "uint32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalType": "string",
|
||||||
|
"name": "model",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "commitRoot",
|
||||||
|
"outputs": [],
|
||||||
|
"stateMutability": "nonpayable",
|
||||||
|
"type": "function"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inputs": [],
|
||||||
|
"name": "getBatchCount",
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"internalType": "uint256",
|
||||||
|
"name": "",
|
||||||
|
"type": "uint256"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stateMutability": "view",
|
||||||
|
"type": "function"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"internalType": "string",
|
||||||
|
"name": "batchId",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "getCommitment",
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"internalType": "bytes32",
|
||||||
|
"name": "merkleRoot",
|
||||||
|
"type": "bytes32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalType": "uint32",
|
||||||
|
"name": "runCount",
|
||||||
|
"type": "uint32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalType": "string",
|
||||||
|
"name": "model",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalType": "uint64",
|
||||||
|
"name": "blockTimestamp",
|
||||||
|
"type": "uint64"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stateMutability": "view",
|
||||||
|
"type": "function"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inputs": [],
|
||||||
|
"name": "owner",
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"internalType": "address",
|
||||||
|
"name": "",
|
||||||
|
"type": "address"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stateMutability": "view",
|
||||||
|
"type": "function"
|
||||||
|
}
|
||||||
|
]
|
||||||
4
types.go
4
types.go
@@ -6,6 +6,10 @@ type M map[string]any
|
|||||||
|
|
||||||
type RequestOptions struct {
|
type RequestOptions struct {
|
||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
|
// SkipPreflight disables the client-side commitment number preflight, which
|
||||||
|
// rejects non-integer / out-of-range numbers locally before LogEvent /
|
||||||
|
// LogEvents sends them. Default false: the preflight runs.
|
||||||
|
SkipPreflight bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamCreateInput struct {
|
type StreamCreateInput struct {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package attesto
|
package attesto
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SDKVersion = "0.2.0"
|
SDKVersion = "0.4.0"
|
||||||
DefaultBaseURL = "https://verify.attesto.eu"
|
DefaultBaseURL = "https://verify.attesto.eu"
|
||||||
ProofstreamProtocol = "ATTESTO-PROOFSTREAM-001"
|
ProofstreamProtocol = "ATTESTO-PROOFSTREAM-001"
|
||||||
ProtocolVersionAlpha = "0.1-alpha"
|
ProtocolVersionAlpha = "0.1-alpha"
|
||||||
|
// ProtocolHandshake is sent as X-Attesto-Protocol on every request; the
|
||||||
|
// backend answers 426 when it speaks a different protocol generation.
|
||||||
|
ProtocolHandshake = ProofstreamProtocol + "/" + ProtocolVersionAlpha
|
||||||
)
|
)
|
||||||
|
|||||||
65
webhook_parity_test.go
Normal file
65
webhook_parity_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type webhookParityVectors struct {
|
||||||
|
Cases []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
Now int64 `json:"now"`
|
||||||
|
MaxSkewS int `json:"max_skew_s"`
|
||||||
|
ExpectOK bool `json:"expect_ok"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParityWebhookVerification(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "webhook.json")
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read webhook vectors: %v", err)
|
||||||
|
}
|
||||||
|
var v webhookParityVectors
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
t.Fatalf("decode webhook vectors: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range v.Cases {
|
||||||
|
ok := verifyWebhookAt([]byte(c.Body), c.Headers, c.Secret, c.MaxSkewS, c.Now)
|
||||||
|
if ok != c.ExpectOK {
|
||||||
|
t.Errorf("webhook %s: ok=%v want %v", c.ID, ok, c.ExpectOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookHeaderLookupIsCaseInsensitive(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "webhook.json")
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read webhook vectors: %v", err)
|
||||||
|
}
|
||||||
|
var v webhookParityVectors
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
t.Fatalf("decode webhook vectors: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range v.Cases {
|
||||||
|
if c.ID != "valid" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lowered := map[string]string{}
|
||||||
|
for key, value := range c.Headers {
|
||||||
|
lowered[key] = value
|
||||||
|
}
|
||||||
|
// Re-key with different casing.
|
||||||
|
lowered["X-ATTESTO-TIMESTAMP"] = lowered["X-Attesto-Timestamp"]
|
||||||
|
delete(lowered, "X-Attesto-Timestamp")
|
||||||
|
if !verifyWebhookAt([]byte(c.Body), lowered, c.Secret, c.MaxSkewS, c.Now) {
|
||||||
|
t.Error("case-insensitive header lookup failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user