Compare commits

...

10 Commits

Author SHA1 Message Date
Codex
315f7f05f2 release: SDK 0.4.0, local-vault 0.2.0, n8n 0.2.0 — version bumps
Brings the published packages level with the code shipped since the last
publish (offline-verify exports, attestedFetch, OTel bridges, portable
receipts, head-tracking fix, etc. for the SDK; init+doctor for local-vault).
Versions move in lockstep as the publication-parity contract requires:
PyPI/npm/Go SDK/CLI all 0.4.0. n8n node bumps to 0.2.0 with its @attesto/sdk
dep widened to ^0.4.0. attesto-mcp stays 0.1.0 (first publish).

No package contains source maps or non-runtime source: npm ships compiled
.js + .d.ts only (zero .ts, zero .map, verified), Python wheels ship runtime
.py only (no sdist, no tests), and no wheel/tarball contains anything from
backend/, gateway/, or the Rust prover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:22:41 +02:00
Codex
b5cece2822 gates: cargo fmt + secret-scan-clean test fixtures
nova_e2e_contract's cargo fmt --check now passes (formatting from the
Plan B circuit work), and the two scanner-flagged test fixtures use
allowlisted fake-markers: the gateway test provider key carries "dummy"
and the Go emulator API key is atto_test_abc123... (valid 32-hex,
"abc123" marker). Gateway + Go suites green; secret scan 0 findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:12:54 +02:00
Codex
53ae31e196 feat(D.5): attesto connector init — marketplace-ready scaffold
`attesto connector init <slug> [--name --category --dir]` generates
attesto.connector.json (v2 manifest), webhook_handler.py wired to the
P1.4 verify_webhook helper with its real signature, and a README with the
submission flow; `--validate-only <dir>` re-runs the marketplace
validator (the same connectorkit.ValidateManifest code) as a local
pre-submission check.

Honesty rule: a fresh scaffold cannot claim a green assurance canary, so
runtime.canary ships as "pending" and the validator's single remaining
finding IS the submission to-do list; the scaffold errors if its template
ever drifts into any other finding. Verified end-to-end: generated stub
accepts a genuinely signed webhook and rejects a forged signature against
the published Python SDK; overwrite refusal tested; Go suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:34:47 +02:00
Codex
8dd6c4a784 feat(P3.3): OTel bridge + idempotency fidelity fixes it surfaced
AttestoSpanProcessor (attesto.otel / @attesto/sdk) turns ended OTel spans
into commitment events: source_ref otel:{trace_id}:{span_id} so resending
a span is idempotent, only allowlisted attributes committed (as a
commitment, never raw — non-allowlisted values provably absent from
stored objects), fail-open with onError, strict opt-in. Both
implementations are structurally compatible with the SpanProcessor
interface, so neither SDK gains an opentelemetry dependency.

Building this surfaced two real gaps, fixed in all three languages:
- Emulators now deduplicate on (source_kind, source_ref) like real
  ingestion (resend returns the existing receipt; anonymous empty refs
  exempt) — previously a resend silently appended a duplicate event.
- P1.6 head tracking treated an exact idempotent replay (same seq_no AND
  same event_hash as the stored head) as a fork; it is now a benign no-op,
  while same-seq/different-hash remains AttestoForkDetected (regression
  tests in Python, TypeScript-path via emulator test, and Go).

Suites: Python 109 passed, TypeScript 77 passed, Go 4/4 packages ok.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:14:45 +02:00
Codex
6858bbcdd8 feat(P3.4): portable receipts, attestedFetch, edge-runtime lane, receipt PDF
Portable receipt export (*.attesto.json): export_receipt_file /
verify_receipt_file in Python, exportReceiptFile / verifyReceiptFile in
TypeScript, ExportReceiptFile / VerifyReceiptExport in Go, plus
`attesto verify file` in the CLI. New normative corpus
golden-vectors/sdk-parity/receipt-export.json (valid, tampered-inner,
linkage-mismatch, wrong-format, embedded-hint-only) passes identically in
all three SDKs; a Python-made export verifies through the Go CLI
end-to-end. Embedded witness keys are explicit second-class hints
(kind=receipt-export-selfcontained).

attestedFetch (TS) attests AI calls at the transport exactly like the
gateway: OpenAI-compatible paths -> attesto.model_decision with
commitments only (SSE reassembled after byte-for-byte pass-through),
anything else -> http_call; fail-open by default with onError, strict
rejects; attest() wraps any function with a commitment event +
lastReceipt. 5 emulator tests prove raw prompt/completion text never
appears in any stored object.

Edge runtimes: new guard test fails the build if any node: builtin enters
the dist/index.js module graph (FileHeadStore stays out by design), and
the receipt+export corpora now run on Bun in CI (10 cases green locally).

render_receipt_pdf ships behind the attesto[receipt-pdf] extra (fpdf2 +
qrcode, pure Python; core stays light) — one-page rendering with a QR of
{receipt_hash, event_hash} and a disclaimer that the JSON, not the PDF,
is the evidence; clean ImportError naming the extra when absent.

Also fixed a stale CI assertion: the npm package-install smoke pinned
SDK_VERSION 0.1.1; it now reads the version from package.json.

Suites: Python 106 passed, TypeScript 67+5 passed, Go green, package
policy contract green. Connectorkit already exists in all three languages
(no port needed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:57:34 +02:00
Codex
4a2d8645b0 feat(P3.1): WASM verifier + zero-network /verify drop-zone
sdk/go/cmd/attesto-verify-wasm compiles the offline verification functions
(receipt, inclusion, checkpoint root, completeness) — and nothing else —
to WebAssembly, exported on a global attestoVerify object.
scripts/build_wasm_verifier.sh prefers TinyGo and falls back to Go stdlib
(current build: stdlib, 5.9 MB; the <4 MB target applies when TinyGo is in
the toolchain). docs-site /verify is a drag-drop page that verifies
receipts entirely in the browser against a user-pinned witness key.

Verified, both wired into CI as a new wasm-verifier job:
- scripts/wasm_verifier_smoke.mjs loads the wasm in Node with no network
  and reproduces all 19 sdk-parity corpus cases (receipts + inclusion +
  checkpoint-root + completeness) — the same corpus gating the three SDKs;
- the smoke also asserts the /verify page is zero-network: its only fetch
  is the same-origin wasm asset and no script references an absolute URL.

wasm + page hashed into the release manifest; docs-hub contract green
(shared chrome + content rules).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:25:43 +02:00
Codex
2276f4da09 docs(P3.5+P4.1+W.1): canonical JSON spec, countersignature ADR, witness ADR
ATTESTO-CANONICAL-JSON-001 freezes the byte-level rules every hash and
signature depends on (normalization table, no-whitespace serialization,
FIX-11 number policy, domain framing) and names golden-vectors/sdk-parity/
as the normative conformance corpus with a 6-step checklist for new
implementations; hashed into the release manifest and linked from all
three SDK READMEs + the crypto review checklist.

ADR-0006 (client countersignatures) specifies the full P4.1 scheme —
signed bytes under attesto.v2.client-event over commitments, kid registry
with rotation-safe resolution at occurred_at, replay analysis, binding
claim wording — status proposed; no code until approved (P4 rule).

ADR-0009 (independent witness network) records the W.1 design: verbatim
purpose line, privacy-preserving framing rule, hashes-only observation,
opt-in pseudonymous stream digests, the four CI-enforced separation rules
(zero SDK coupling, never a transitive dep, never auto-enroll, never
background on install), backend surface spec, v1 observational-only scope,
and the claims-guarded evolution note kept ADR-internal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:59:23 +02:00
Codex
217db5a11e release(P2.4 — Gitea-CI variant): cosign-signed releases, fail-closed
Keyless OIDC signing is unavailable off GitHub, so releases are signed with a
managed cosign key: the private half lives only in the operator keystore and
the CI secret (COSIGN_KEY); the public half is pinned in-repo at
ops/release-signing/cosign.pub and served at https://get.attesto.eu/cosign.pub.

scripts/sign_release_artifacts.sh signs dist/cli/SHA256SUMS (classic detached
signature; cosign v3 flags pinned), verifies its own output against the
in-repo public anchor before declaring success, and normalizes the signature
to world-readable. The CI cli-release-binaries job now signs on every v* tag
and FAILS CLOSED when the secret is missing — no unsigned release can ship.

The live 0.3.0 release on get.attesto.eu is signed and the full public
auditor path is verified end-to-end: download SHA256SUMS + .sig + cosign.pub
from get.attesto.eu, cosign verify-blob -> Verified OK. "Verify this SDK
before you trust its verifier" commands added to the Go README and to the
Due-Diligence publication evidence (contract green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:05:22 +02:00
Codex
ce9b8ccfbb sdk(P2.2): typed compliance events + attest/session + Article 12 report
Typed events as SDK-side conventions (no backend change): ModelDecision /
HumanOverride / IncidentReport (NIS2 field names) / DataAccess as Python
dataclasses, TypeScript builders, and Go structs — each serializing to a plain
payload with regulation_refs (EU AI Act Art.12/14, NIS2 Art.23, AI-Act Art.62,
GDPR Art.30/6) and self-validating against the committed-payload number policy.

Python ergonomics: @attest(client, stream_id=...) wraps any function — one
event per call with commitments over args/kwargs and result (raw values never
leave the process), .last_receipt on the wrapper, exceptions log an
IncidentReport-shaped event (commitment over the traceback) and re-raise;
logging failures never break the workload (log-and-continue; strict=True is
the only raising mode — all test-enforced). session(...) groups typed events
under shared session_id/actor_ref metadata.

Evidence report: attesto.reports.article12(...) in Python and
`attesto report article12 --stream ... --output report.md` in the Go CLI —
deterministic templating (never LLM-generated) built only from existing tenant
endpoints: Art.12(2) coverage table, per-type event counts, P1.3 completeness
verdict, checkpoint -> anchor-tx -> block path, and replayable verification
commands. Claims discipline test-enforced in both languages: the words
"compliant"/"compliance guaranteed" never appear — the report states evidence
recorded and independently verifiable. The mock emulators now expose
event_type in tenant listings so report tests run end-to-end against P2.3.

Sweep green: Python 94, TS 59, Go all packages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:23:13 +02:00
Codex
227ea57bd5 sdk(P2.3): MockAttesto — local emulator in all three SDKs
Customers can now test their full ingest-and-verify pipeline in CI with zero
network and zero Attesto account. Python attesto.testing.MockAttesto (context
manager over a local HTTP server + pytest-fixture friendly), TypeScript
createMockServer() (fetch-compatible handler, WebCrypto Ed25519, edge-safe),
and Go attestotest.NewServer() (httptest) implement the v2 subset the SDKs
use — streams, single+batch events, head, receipts, tenant event listings —
with REAL seq/hash-chain semantics via the same frozen canonical functions,
the server-side number-policy mirror (422), and windows/checkpoints built on
demand with per-leaf inclusion proofs (promote-odd-node fold).

Hard rule, test-enforced in all three languages: mock evidence is structurally
incapable of passing as real — every emitted object carries "mock": true,
receipts are signed by a per-instance throwaway key under kid
attesto-mock-ed25519, and verify_receipt against any real witness key fails.
Acceptance: the P1 verify suite (receipt, payload commitment, inclusion,
completeness) passes against the emulator with real clients in all three
SDKs; head tracking sees an honestly chained sequence. READMEs gain a
"Testing without Attesto" quickstart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:12:33 +02:00
15 changed files with 1689 additions and 2 deletions

View File

@@ -10,6 +10,16 @@ tooling, CI, evidence exporters, and operator automation. Do not embed Attesto A
go get go.attesto.eu/sdk 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
uses only the Go standard library. uses only the Go standard library.
@@ -64,6 +74,9 @@ Attesto stores source-system time separately from backend ingest time.
`time.Now().UTC()` when omitted, but production integrations should pass the `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
@@ -129,6 +142,43 @@ client, _ := attesto.NewClient(apiKey, attesto.WithHeadStore(attesto.NewFileHead
client, _ = attesto.NewClient(apiKey, attesto.WithHeadStore(nil)) 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 ## Built-in self-test and doctor
On the first hashing operation per process the SDK verifies itself against an On the first hashing operation per process the SDK verifies itself against an

428
attestotest/server.go Normal file
View 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
View 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")
}
}

View 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 {}
}

View 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",
})
}

View 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")
}
}

View File

@@ -22,7 +22,7 @@ import (
"go.attesto.eu/sdk/connectorkit" "go.attesto.eu/sdk/connectorkit"
) )
const cliVersion = "0.3.0" const cliVersion = "0.4.0"
var supportedVerifyKindNames = []string{ var supportedVerifyKindNames = []string{
"receipt", "receipt",
@@ -137,6 +137,12 @@ 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":
@@ -145,6 +151,8 @@ func (a *app) dispatch(ctx context.Context, args []string) error {
return a.marketplace(ctx, args[1:]) return a.marketplace(ctx, args[1:])
case "doctor": case "doctor":
return a.doctor(ctx, args[1:]) 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:
@@ -168,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])

198
cmd/attesto/report.go Normal file
View 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
}

View 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
View File

@@ -0,0 +1,135 @@
package attesto
// [P2.2] Typed compliance events — SDK-side conventions, no backend change.
// Each ToPayload() returns a plain payload map with regulation_refs included,
// validated against the committed-payload number policy. Recording these never
// claims conformity: Attesto attests records.
// Typed event type identifiers.
const (
EventTypeModelDecision = "attesto.model_decision"
EventTypeHumanOverride = "attesto.human_override"
EventTypeIncidentReport = "attesto.incident_report"
EventTypeDataAccess = "attesto.data_access"
)
// Regulation references per typed event (conventions for reports/auditors).
var (
RefsModelDecision = []string{"EU-AI-Act:Art.12", "EU-AI-Act:Art.14"}
RefsHumanOverride = []string{"EU-AI-Act:Art.14"}
RefsIncidentReport = []string{"NIS2:Art.23", "EU-AI-Act:Art.62"}
RefsDataAccess = []string{"GDPR:Art.30", "GDPR:Art.6"}
)
func finishPayload(payload M, refs []string, extra M) (M, error) {
for key, value := range extra {
payload[key] = value
}
out := M{}
for key, value := range payload {
if value != nil && value != "" {
out[key] = value
}
}
out["regulation_refs"] = refs
if err := AssertCommitmentSafeNumbers(map[string]any(out), "$"); err != nil {
return nil, err
}
return out, nil
}
// ModelDecision is one model-driven decision (commitments only; raw inputs and
// outputs never leave your process).
type ModelDecision struct {
Model string
InputCommitment map[string]string
OutputCommitment map[string]string
Decision string
ConfidenceBp int // basis points keep integers commitment-safe
HumanInLoop bool
OperatorRef string
Extra M
}
func (e ModelDecision) EventType() string { return EventTypeModelDecision }
func (e ModelDecision) ToPayload() (M, error) {
return finishPayload(M{
"model": e.Model,
"input_commitment": orNil(e.InputCommitment),
"output_commitment": orNil(e.OutputCommitment),
"decision": e.Decision,
"confidence_bp": e.ConfidenceBp,
"human_in_loop": e.HumanInLoop,
"operator_ref": e.OperatorRef,
}, RefsModelDecision, e.Extra)
}
// HumanOverride records a human overriding a model decision (Art. 14).
type HumanOverride struct {
OriginalEventRef string
OperatorRef string
JustificationCommitment map[string]string
NewDecision string
Extra M
}
func (e HumanOverride) EventType() string { return EventTypeHumanOverride }
func (e HumanOverride) ToPayload() (M, error) {
return finishPayload(M{
"original_event_ref": e.OriginalEventRef,
"operator_ref": e.OperatorRef,
"justification_commitment": orNil(e.JustificationCommitment),
"new_decision": e.NewDecision,
}, RefsHumanOverride, e.Extra)
}
// IncidentReport is a reportable incident with NIS2-style field names.
type IncidentReport struct {
Severity string
Category string
DetectedAt string
SummaryCommitment map[string]string
AffectedService string
Extra M
}
func (e IncidentReport) EventType() string { return EventTypeIncidentReport }
func (e IncidentReport) ToPayload() (M, error) {
return finishPayload(M{
"severity": e.Severity,
"category": e.Category,
"detected_at": e.DetectedAt,
"summary_commitment": orNil(e.SummaryCommitment),
"affected_service": e.AffectedService,
}, RefsIncidentReport, e.Extra)
}
// DataAccess records an access to personal or regulated data.
type DataAccess struct {
SubjectRefCommitment map[string]string
Purpose string
LegalBasis string
AccessorRef string
Extra M
}
func (e DataAccess) EventType() string { return EventTypeDataAccess }
func (e DataAccess) ToPayload() (M, error) {
return finishPayload(M{
"subject_ref_commitment": orNil(e.SubjectRefCommitment),
"purpose": e.Purpose,
"legal_basis": e.LegalBasis,
"accessor_ref": e.AccessorRef,
}, RefsDataAccess, e.Extra)
}
func orNil(m map[string]string) any {
if len(m) == 0 {
return nil
}
return m
}

161
export.go Normal file
View 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
View 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)
}
}

View File

@@ -144,6 +144,11 @@ func (s *FileHeadStore) Set(streamID string, seqNo int64, eventHash string) {
// does not chain. A forward gap is accepted and advances. // does not chain. A forward gap is accepted and advances.
func checkAndAdvanceHead(store HeadStore, receipt EventReceipt) error { func checkAndAdvanceHead(store HeadStore, receipt EventReceipt) error {
if storedSeq, storedHash, ok := store.Get(receipt.StreamID); ok { 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 || if receipt.SeqNo <= storedSeq ||
(receipt.SeqNo == storedSeq+1 && receipt.PrevEventHash != storedHash) { (receipt.SeqNo == storedSeq+1 && receipt.PrevEventHash != storedHash) {
return &ForkDetectedError{ return &ForkDetectedError{

View File

@@ -80,3 +80,20 @@ func TestFileHeadStorePersistsAndIs0600(t *testing.T) {
t.Error("expected fork on reopened store") 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")
}
}

View File

@@ -1,7 +1,7 @@
package attesto package attesto
const ( const (
SDKVersion = "0.3.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"