commit 61f3a217e6f00bf77174dd9439c93d57b0ee1c7a Author: Codex Date: Sun Jun 7 22:35:23 2026 +0200 Add SDK parity and Go CLI release readiness diff --git a/README.md b/README.md new file mode 100644 index 0000000..799bd76 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Attesto Go SDK + +Official Go SDK for Attesto 2.0 Proofstream. The default API base URL is +`https://verify.attesto.eu`. Use it from server-side, infrastructure, security +tooling, CI, evidence exporters, and operator automation. Do not embed Attesto API keys in browser bundles, mobile apps, or public artifacts. + +## Install + +```shell +go get git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go +``` + +The first release is VCS-resolved from the Attesto repository. It intentionally +uses only the Go standard library. + +## Quickstart + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + + attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go" +) + +func main() { + ctx := context.Background() + client, err := attesto.NewClient(os.Getenv("ATTESTO_API_KEY")) + if err != nil { + log.Fatal(err) + } + + stream, err := client.CreateStream(ctx, attesto.StreamCreateInput{ + UseCase: "ai-governance", + PolicyID: "policy-main", + }) + if err != nil { + log.Fatal(err) + } + + receipt, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{ + SourceRef: "decision-42", + Payload: attesto.M{ + "model": "risk-classifier", + "score": 0.92, + }, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Println(receipt.StreamEventID, receipt.EventHash) +} +``` + +## Verification + +Remote verification uses Attesto's public `/v2/verify` API. Offline receipt +verification uses `ATTESTO-PROOFSTREAM-001` canonical JSON, domain-separated +hashes, and Ed25519 signature verification locally. + +```go +report := attesto.VerifyReceiptOffline(receipt.Receipt, publicKeyHex) +if !report.OK { + log.Fatalf("receipt failed verification: %v", report.Problems) +} +``` + +## Operator and Admin Endpoints + +System-key clients are created with `attesto.NewClient`. Tenant/operator +endpoints, including connector installation and Local Vault installation +management, use `attesto.NewBearerClient` with a tenant bearer token obtained +from the dashboard session flow. + +Secrets returned once by connector creation are present only in the returned +struct and are never logged by the SDK. diff --git a/client.go b/client.go new file mode 100644 index 0000000..40a1920 --- /dev/null +++ b/client.go @@ -0,0 +1,561 @@ +package attesto + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +type Client struct { + baseURL string + bearer string + httpClient *http.Client + maxRetries int + userAgent string + validateKey bool +} + +type Option func(*Client) error + +var apiKeyPattern = regexp.MustCompile(`^atto_(?:live|test)_[0-9a-f]{32}$`) + +func NewClient(apiKey string, opts ...Option) (*Client, error) { + if !apiKeyPattern.MatchString(apiKey) { + return nil, errors.New("api key must match atto_live_<32 lowercase hex chars> or atto_test_<32 lowercase hex chars>") + } + return newClient(apiKey, true, opts...) +} + +func NewBearerClient(token string, opts ...Option) (*Client, error) { + if strings.TrimSpace(token) == "" { + return nil, errors.New("bearer token must not be blank") + } + return newClient(token, false, opts...) +} + +func newClient(bearer string, validateKey bool, opts ...Option) (*Client, error) { + client := &Client{ + baseURL: DefaultBaseURL, + bearer: bearer, + httpClient: &http.Client{Timeout: 10 * time.Second}, + maxRetries: 3, + userAgent: "attesto-go/" + SDKVersion, + validateKey: validateKey, + } + for _, opt := range opts { + if err := opt(client); err != nil { + return nil, err + } + } + if _, err := url.ParseRequestURI(client.baseURL); err != nil { + return nil, fmt.Errorf("base url must be an absolute URL: %w", err) + } + client.baseURL = strings.TrimRight(client.baseURL, "/") + return client, nil +} + +func WithBaseURL(baseURL string) Option { + return func(c *Client) error { + baseURL = strings.TrimSpace(strings.TrimRight(baseURL, "/")) + if baseURL == "" { + return errors.New("base url must not be blank") + } + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return errors.New("base url must be an absolute URL") + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return errors.New("base url must use http or https") + } + c.baseURL = baseURL + return nil + } +} + +func WithHTTPClient(httpClient *http.Client) Option { + return func(c *Client) error { + if httpClient == nil { + return errors.New("http client must not be nil") + } + c.httpClient = httpClient + return nil + } +} + +func WithMaxRetries(maxRetries int) Option { + return func(c *Client) error { + if maxRetries < 1 { + return errors.New("max retries must be at least 1") + } + c.maxRetries = maxRetries + return nil + } +} + +func WithUserAgent(userAgent string) Option { + return func(c *Client) error { + if strings.TrimSpace(userAgent) == "" { + return errors.New("user agent must not be blank") + } + c.userAgent = userAgent + return nil + } +} + +func (c *Client) CreateStream(ctx context.Context, input StreamCreateInput, options ...RequestOptions) (*Stream, error) { + var out Stream + if input.Metadata == nil { + input.Metadata = M{} + } + err := c.requestJSON(ctx, http.MethodPost, "/v2/streams", nil, input, idempotency(options), &out) + return &out, err +} + +func (c *Client) GetStreamHead(ctx context.Context, streamID string) (*StreamHead, error) { + var out StreamHead + err := c.requestJSON(ctx, http.MethodGet, "/v2/streams/"+url.PathEscape(streamID)+"/head", nil, nil, "", &out) + return &out, err +} + +func (c *Client) ListTenantStreams(ctx context.Context, systemID string, limit, offset int) ([]TenantStream, error) { + values := url.Values{} + if systemID != "" { + values.Set("systemId", systemID) + } + setPaging(values, limit, offset) + var out []TenantStream + err := c.requestJSON(ctx, http.MethodGet, "/v2/tenant/streams", values, nil, "", &out) + return out, err +} + +func (c *Client) GetTenantStream(ctx context.Context, streamID string) (*TenantStream, error) { + offset := 0 + for { + streams, err := c.ListTenantStreams(ctx, "", 500, offset) + if err != nil { + return nil, err + } + for _, stream := range streams { + if stream.StreamID == streamID { + return &stream, nil + } + } + if len(streams) < 500 { + return nil, fmt.Errorf("stream not found: %s", streamID) + } + offset += 500 + } +} + +func (c *Client) LogEvent(ctx context.Context, streamID string, input EventInput, options ...RequestOptions) (*EventReceipt, error) { + if input.EventType == "" { + input.EventType = "inference" + } + if input.SourceKind == "" { + input.SourceKind = "sdk" + } + if input.Payload == nil { + input.Payload = M{} + } + if input.Metadata == nil { + input.Metadata = M{} + } + var out EventReceipt + err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out) + return &out, err +} + +func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventInput, options ...RequestOptions) (*EventBatchResponse, error) { + if len(events) > 1000 { + return nil, errors.New("max 1000 events per batch") + } + for i := range events { + if events[i].EventType == "" { + events[i].EventType = "inference" + } + if events[i].SourceKind == "" { + events[i].SourceKind = "sdk" + } + if events[i].Payload == nil { + events[i].Payload = M{} + } + if events[i].Metadata == nil { + events[i].Metadata = M{} + } + } + body := M{"events": events} + var out EventBatchResponse + err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out) + return &out, err +} + +func (c *Client) GetReceipt(ctx context.Context, streamEventID string) (*EventReceipt, error) { + var out EventReceipt + err := c.requestJSON(ctx, http.MethodGet, "/v2/receipts/"+url.PathEscape(streamEventID), nil, nil, "", &out) + return &out, err +} + +func (c *Client) GetWindow(ctx context.Context, windowID string) (M, error) { + return c.getObject(ctx, "/v2/windows/"+url.PathEscape(windowID), nil) +} + +func (c *Client) GetCheckpoint(ctx context.Context, checkpointID string) (M, error) { + return c.getObject(ctx, "/v2/checkpoints/"+url.PathEscape(checkpointID), nil) +} + +func (c *Client) GetCheckpointConsistency(ctx context.Context, checkpointID, fromCheckpointID string) (M, error) { + values := url.Values{"from": []string{fromCheckpointID}} + return c.getObject(ctx, "/v2/checkpoints/"+url.PathEscape(checkpointID)+"/consistency", values) +} + +func (c *Client) GetWitnessPolicy(ctx context.Context, policyID string) (*WitnessPolicy, error) { + var out WitnessPolicy + err := c.requestJSON(ctx, http.MethodGet, "/v2/witness/policies/"+url.PathEscape(policyID), nil, nil, "", &out) + return &out, err +} + +func (c *Client) GetAnchorEpoch(ctx context.Context, anchorEpochID string) (M, error) { + return c.getObject(ctx, "/v2/anchors/"+url.PathEscape(anchorEpochID), nil) +} + +func (c *Client) GetIVCEpoch(ctx context.Context, ivcEpochID string) (M, error) { + return c.getObject(ctx, "/v2/ivc/epochs/"+url.PathEscape(ivcEpochID), nil) +} + +func (c *Client) BuildVerifierBundle(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) { + body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID} + var out VerifierBundle + err := c.requestJSON(ctx, http.MethodPost, "/v2/audit/packs", nil, body, idempotency(options), &out) + return &out, err +} + +func (c *Client) VerifyReceiptRemote(ctx context.Context, input ReceiptVerifyInput, options ...RequestOptions) (*VerifyReport, error) { + var out VerifyReport + err := c.requestJSON(ctx, http.MethodPost, "/v2/verify/receipt", nil, input, idempotency(options), &out) + return &out, err +} + +func (c *Client) VerifyObjectRemote(ctx context.Context, input OfflineVerifyInput, options ...RequestOptions) (*VerifyReport, error) { + var out VerifyReport + err := c.requestJSON(ctx, http.MethodPost, "/v2/verify", nil, input, idempotency(options), &out) + return &out, err +} + +func (c *Client) ListTenantStreamEvents(ctx context.Context, streamID string, limit, offset int) ([]M, error) { + return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/events", limit, offset) +} + +func (c *Client) ListTenantWindows(ctx context.Context, streamID string, limit, offset int) ([]M, error) { + return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/windows", limit, offset) +} + +func (c *Client) ListTenantCheckpoints(ctx context.Context, streamID string, limit, offset int) ([]M, error) { + return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/checkpoints", limit, offset) +} + +func (c *Client) ListForkEvidence(ctx context.Context, streamID string, limit, offset int) ([]M, error) { + return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/forks", limit, offset) +} + +func (c *Client) GetTenantProofState(ctx context.Context, streamID string) (M, error) { + return c.getObject(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/proof-state", nil) +} + +func (c *Client) ListTenantIVCEpochs(ctx context.Context, streamID string, limit, offset int) ([]M, error) { + return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/ivc/epochs", limit, offset) +} + +func (c *Client) BuildTenantAuditPack(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) { + body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID} + var out VerifierBundle + err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/audit/packs", nil, body, idempotency(options), &out) + return &out, err +} + +func (c *Client) ListSignedWebhookConnectors(ctx context.Context, limit, offset int) ([]Connector, error) { + return c.listConnectors(ctx, "/v2/tenant/connectors/signed-webhooks", limit, offset) +} + +func (c *Client) CreateSignedWebhookConnector(ctx context.Context, input ConnectorCreateInput, options ...RequestOptions) (*Connector, error) { + var out Connector + err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/signed-webhooks", nil, input, idempotency(options), &out) + return &out, err +} + +func (c *Client) RevokeSignedWebhookConnector(ctx context.Context, connectorID string) error { + return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/signed-webhooks/"+url.PathEscape(connectorID)) +} + +func (c *Client) ListS3ObjectConnectors(ctx context.Context, limit, offset int) ([]Connector, error) { + return c.listConnectors(ctx, "/v2/tenant/connectors/s3-objects", limit, offset) +} + +func (c *Client) CreateS3ObjectConnector(ctx context.Context, input S3ConnectorCreateInput, options ...RequestOptions) (*Connector, error) { + var out Connector + err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects", nil, input, idempotency(options), &out) + return &out, err +} + +func (c *Client) CommitS3Object(ctx context.Context, connectorID string, body M, options ...RequestOptions) (*EventReceipt, error) { + var out EventReceipt + err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID)+"/commit", nil, body, idempotency(options), &out) + return &out, err +} + +func (c *Client) RevokeS3ObjectConnector(ctx context.Context, connectorID string) error { + return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID)) +} + +func (c *Client) ListRepositoryWebhookConnectors(ctx context.Context, limit, offset int) ([]Connector, error) { + return c.listConnectors(ctx, "/v2/tenant/connectors/repository-webhooks", limit, offset) +} + +func (c *Client) CreateRepositoryWebhookConnector(ctx context.Context, input RepositoryConnectorCreateInput, options ...RequestOptions) (*Connector, error) { + var out Connector + err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/repository-webhooks", nil, input, idempotency(options), &out) + return &out, err +} + +func (c *Client) RevokeRepositoryWebhookConnector(ctx context.Context, connectorID string) error { + return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/repository-webhooks/"+url.PathEscape(connectorID)) +} + +func (c *Client) IngestSignedWebhookEvent(ctx context.Context, connectorID string, event EventInput, secret string) (*EventReceipt, error) { + raw, err := json.Marshal(event) + if err != nil { + return nil, err + } + headers := SignedConnectorWebhookHeaders(secret, raw, 0) + var out EventReceipt + err = c.requestRaw(ctx, http.MethodPost, "/v2/connectors/signed-webhooks/"+url.PathEscape(connectorID)+"/events", nil, raw, headers, "", &out) + return &out, err +} + +func (c *Client) IngestRepositoryWebhookEvent(ctx context.Context, connectorID string, rawBody []byte, headers map[string]string) (*EventReceipt, error) { + var out EventReceipt + err := c.requestRaw(ctx, http.MethodPost, "/v2/connectors/repository-webhooks/"+url.PathEscape(connectorID)+"/events", nil, rawBody, headers, "", &out) + return &out, err +} + +func (c *Client) ListLocalVaultInstallations(ctx context.Context, limit, offset int) ([]LocalVaultInstallation, error) { + values := url.Values{} + setPaging(values, limit, offset) + var out []LocalVaultInstallation + err := c.requestJSON(ctx, http.MethodGet, "/v2/tenant/local-vault/installations", values, nil, "", &out) + return out, err +} + +func (c *Client) CreateLocalVaultInstallation(ctx context.Context, input LocalVaultInstallationCreateInput, options ...RequestOptions) (*LocalVaultInstallation, error) { + var out LocalVaultInstallation + err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/local-vault/installations", nil, input, idempotency(options), &out) + return &out, err +} + +func (c *Client) RevokeLocalVaultInstallation(ctx context.Context, installationID string) error { + return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/local-vault/installations/"+url.PathEscape(installationID)) +} + +func (c *Client) RelayLocalVaultEvent(ctx context.Context, installationID string, envelope M, payload M, envelopeHash, signatureHex, publicKeyHex string) (*EventReceipt, error) { + body := M{"envelope": envelope, "payload": payload} + headers := map[string]string{ + "X-Attesto-Local-Vault-Envelope-Hash": envelopeHash, + "X-Attesto-Local-Vault-Signature": signatureHex, + "X-Attesto-Local-Vault-Public-Key": publicKeyHex, + } + var out EventReceipt + err := c.requestRaw(ctx, http.MethodPost, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/events", nil, mustJSON(body), headers, "", &out) + return &out, err +} + +func (c *Client) SubmitLocalVaultWitnessReceipt(ctx context.Context, installationID string, receipt M, options ...RequestOptions) (M, error) { + return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"receipt": receipt}, idempotency(options)) +} + +func (c *Client) SubmitLocalVaultForkEvidence(ctx context.Context, installationID string, forkEvidence M, options ...RequestOptions) (M, error) { + return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"forkEvidence": forkEvidence}, idempotency(options)) +} + +func (c *Client) getObject(ctx context.Context, path string, values url.Values) (M, error) { + var out M + err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out) + return out, err +} + +func (c *Client) postObject(ctx context.Context, path string, body M, idempotencyKey string) (M, error) { + var out M + err := c.requestJSON(ctx, http.MethodPost, path, nil, body, idempotencyKey, &out) + return out, err +} + +func (c *Client) getList(ctx context.Context, path string, limit, offset int) ([]M, error) { + values := url.Values{} + setPaging(values, limit, offset) + var out []M + err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out) + return out, err +} + +func (c *Client) listConnectors(ctx context.Context, path string, limit, offset int) ([]Connector, error) { + values := url.Values{} + setPaging(values, limit, offset) + var out []Connector + err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out) + return out, err +} + +func (c *Client) requestNoBody(ctx context.Context, method, path string) error { + return c.requestRaw(ctx, method, path, nil, nil, nil, "", nil) +} + +func (c *Client) requestJSON(ctx context.Context, method, path string, values url.Values, body any, idempotencyKey string, out any) error { + var raw []byte + var err error + if body != nil { + raw, err = json.Marshal(body) + if err != nil { + return err + } + } + return c.requestRaw(ctx, method, path, values, raw, nil, idempotencyKey, out) +} + +func (c *Client) requestRaw(ctx context.Context, method, path string, values url.Values, body []byte, extraHeaders map[string]string, idempotencyKey string, out any) error { + if values != nil && len(values) > 0 { + path += "?" + values.Encode() + } + var lastErr error + for attempt := 1; attempt <= c.maxRetries; attempt++ { + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.bearer) + req.Header.Set("User-Agent", c.userAgent) + req.Header.Set("X-Attesto-SDK", c.userAgent) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if idempotencyKey != "" { + req.Header.Set("Idempotency-Key", idempotencyKey) + } + for key, value := range extraHeaders { + req.Header.Set(key, value) + } + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = err + if attempt == c.maxRetries { + break + } + sleep(attempt) + continue + } + err = decodeResponse(resp, out) + if err == nil { + return nil + } + lastErr = err + var apiErr *APIError + if errors.As(err, &apiErr) && !retryable(apiErr.StatusCode) { + return err + } + if attempt < c.maxRetries { + sleep(attempt) + } + } + return lastErr +} + +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("attesto api error: status=%d message=%s", e.StatusCode, e.Message) +} + +func decodeResponse(resp *http.Response, out any) error { + defer resp.Body.Close() + if resp.StatusCode == http.StatusNoContent { + return nil + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + if out == nil || len(raw) == 0 { + return nil + } + return json.Unmarshal(raw, out) + } + message := http.StatusText(resp.StatusCode) + var parsed M + if json.Unmarshal(raw, &parsed) == nil { + if detail, ok := parsed["detail"].(string); ok && detail != "" { + message = detail + } + } + return &APIError{StatusCode: resp.StatusCode, Message: sanitizeMessage(message)} +} + +func retryable(status int) bool { + switch status { + case http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return true + default: + return false + } +} + +func idempotency(options []RequestOptions) string { + if len(options) > 0 && options[0].IdempotencyKey != "" { + return options[0].IdempotencyKey + } + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(raw[:]) +} + +func setPaging(values url.Values, limit, offset int) { + if limit > 0 { + values.Set("limit", fmt.Sprintf("%d", limit)) + } + if offset > 0 { + values.Set("offset", fmt.Sprintf("%d", offset)) + } +} + +func sleep(attempt int) { + time.Sleep(time.Duration(100*attempt) * time.Millisecond) +} + +func mustJSON(value any) []byte { + raw, err := json.Marshal(value) + if err != nil { + panic(err) + } + return raw +} + +func sanitizeMessage(message string) string { + for _, marker := range []string{"sk_live_", "sk_test_", "pk_live_", "pk_test_", "npm_", "pypi-", "atto_live_", "atto_test_"} { + if strings.Contains(message, marker) { + return "redacted" + } + } + return message +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..c0fb78a --- /dev/null +++ b/client_test.go @@ -0,0 +1,100 @@ +package attesto + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const testAPIKey = "atto_test_0123456789abcdef0123456789abcdef" + +func TestClientCallsProductionV2Endpoints(t *testing.T) { + var seen []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Method+" "+r.URL.String()) + if r.Header.Get("Authorization") != "Bearer "+testAPIKey { + t.Fatalf("authorization header missing") + } + if !strings.HasPrefix(r.Header.Get("X-Attesto-SDK"), "attesto-go/") { + t.Fatalf("sdk header missing: %s", r.Header.Get("X-Attesto-SDK")) + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v2/streams": + json.NewEncoder(w).Encode(Stream{ + StreamID: "str_123", SystemID: "sys_123", UseCase: "ai-governance", PolicyID: "policy-main", Status: "active", Created: true, + }) + case r.Method == http.MethodPost && r.URL.Path == "/v2/streams/str_123/events": + if r.Header.Get("Idempotency-Key") == "" { + t.Fatalf("idempotency key missing") + } + json.NewEncoder(w).Encode(EventReceipt{ + StreamID: "str_123", StreamEventID: "evt_123", SeqNo: 1, EventHash: strings.Repeat("a", 64), StreamHeadHash: strings.Repeat("b", 64), + Receipt: SignedReceipt{Payload: M{}, ReceiptHash: strings.Repeat("c", 64), Signature: ReceiptSignature{Alg: "ed25519"}}, + }) + case r.Method == http.MethodPost && r.URL.Path == "/v2/verify": + json.NewEncoder(w).Encode(VerifyReport{Kind: VerifyReceipt, OK: true, Problems: []string{}, Result: "accepted"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client, err := NewClient(testAPIKey, WithBaseURL(server.URL), WithMaxRetries(1)) + if err != nil { + t.Fatalf("client: %v", err) + } + ctx := context.Background() + stream, err := client.CreateStream(ctx, StreamCreateInput{UseCase: "ai-governance", PolicyID: "policy-main"}) + if err != nil { + t.Fatalf("create stream: %v", err) + } + if stream.StreamID != "str_123" { + t.Fatalf("stream id mismatch: %s", stream.StreamID) + } + receipt, err := client.LogEvent(ctx, "str_123", EventInput{SourceRef: "go-test"}) + if err != nil { + t.Fatalf("log event: %v", err) + } + if receipt.StreamEventID != "evt_123" { + t.Fatalf("receipt id mismatch: %s", receipt.StreamEventID) + } + report, err := client.VerifyObjectRemote(ctx, OfflineVerifyInput{Kind: VerifyReceipt, Object: M{"receipt": "object"}}) + if err != nil { + t.Fatalf("verify object: %v", err) + } + if !report.OK { + t.Fatalf("verify report failed: %#v", report) + } + if len(seen) != 3 { + t.Fatalf("request count mismatch: %v", seen) + } +} + +func TestBearerClientCanCallTenantEndpoints(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tenant-token" { + t.Fatalf("tenant bearer header missing") + } + if r.URL.Path != "/v2/tenant/streams" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]TenantStream{{StreamID: "str_tenant", SystemID: "sys_123", UseCase: "audit", PolicyID: "policy", Status: "active"}}) + })) + defer server.Close() + client, err := NewBearerClient("tenant-token", WithBaseURL(server.URL), WithMaxRetries(1)) + if err != nil { + t.Fatalf("client: %v", err) + } + streams, err := client.ListTenantStreams(context.Background(), "", 100, 0) + if err != nil { + t.Fatalf("list streams: %v", err) + } + if len(streams) != 1 || streams[0].StreamID != "str_tenant" { + t.Fatalf("unexpected streams: %#v", streams) + } +} diff --git a/cmd/attesto/main.go b/cmd/attesto/main.go new file mode 100644 index 0000000..5229916 --- /dev/null +++ b/cmd/attesto/main.go @@ -0,0 +1,1280 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go" +) + +const cliVersion = "0.2.0" + +var supportedVerifyKindNames = []string{ + "receipt", + "stream", + "window", + "checkpoint", + "consistency", + "anchor", + "ivc", + "bundle", +} + +type cliConfig struct { + BaseURL string `json:"baseUrl,omitempty"` + APIKey string `json:"apiKey,omitempty"` + Token string `json:"token,omitempty"` +} + +type app struct { + out io.Writer + err io.Writer + env func(string) string + jsonOutput bool + baseURL string + apiKey string + token string + configPath string +} + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, os.Getenv)) +} + +func run(args []string, stdout, stderr io.Writer, getenv func(string) string) int { + a := &app{out: stdout, err: stderr, env: getenv} + fs := flag.NewFlagSet("attesto", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.BoolVar(&a.jsonOutput, "json", false, "write machine-readable JSON") + fs.StringVar(&a.baseURL, "base-url", "", "Attesto API base URL") + fs.StringVar(&a.apiKey, "api-key-env", "", "environment variable containing an Attesto system API key") + fs.StringVar(&a.token, "token-env", "", "environment variable containing a tenant bearer token") + fs.StringVar(&a.configPath, "config", "", "config file path") + if err := fs.Parse(args); err != nil { + return 2 + } + remaining := fs.Args() + if len(remaining) == 0 { + a.fail("command required") + return 2 + } + if a.apiKey != "" { + a.apiKey = getenv(a.apiKey) + } + if a.token != "" { + a.token = getenv(a.token) + } + if a.configPath == "" { + a.configPath = defaultConfigPath(getenv) + } + cfg, _ := readConfig(a.configPath) + if a.baseURL == "" { + a.baseURL = cfg.BaseURL + } + if a.apiKey == "" { + a.apiKey = cfg.APIKey + } + if a.token == "" { + a.token = cfg.Token + } + if a.baseURL == "" { + a.baseURL = attesto.DefaultBaseURL + } + if err := a.dispatch(context.Background(), remaining); err != nil { + a.fail(err.Error()) + return 1 + } + return 0 +} + +func (a *app) dispatch(ctx context.Context, args []string) error { + switch args[0] { + case "version": + return a.write(map[string]any{"name": "attesto", "version": cliVersion, "sdkVersion": attesto.SDKVersion, "verifyKinds": supportedVerifyKindNames}) + case "config": + return a.config(args[1:]) + case "login": + return a.configSet(args[1:]) + case "logout": + return writeConfig(a.configPath, cliConfig{BaseURL: a.baseURL}) + case "streams": + return a.streams(ctx, args[1:]) + case "events": + return a.events(ctx, args[1:]) + case "receipts": + return a.receipts(ctx, args[1:]) + case "windows": + return a.verifyableObject(ctx, "windows", args[1:], attesto.VerifyWindow, "/v2/windows/") + case "checkpoints": + return a.checkpoints(ctx, args[1:]) + case "witnesses": + return a.witnesses(ctx, args[1:]) + case "anchors": + return a.verifyableObject(ctx, "anchors", args[1:], attesto.VerifyAnchor, "/v2/anchors/") + case "bundles": + return a.bundles(ctx, args[1:]) + case "fork-evidence": + return a.forkEvidence(ctx, args[1:]) + case "quorum": + return a.quorum(ctx, args[1:]) + case "ivc": + return a.ivc(ctx, args[1:]) + case "connectors": + return a.connectors(ctx, args[1:]) + case "local-vault": + return a.localVault(ctx, args[1:]) + case "readiness": + return a.readiness(args[1:]) + default: + return fmt.Errorf("unknown command: %s", args[0]) + } +} + +func (a *app) config(args []string) error { + if len(args) == 0 { + return errors.New("config subcommand required") + } + switch args[0] { + case "get": + cfg, err := readConfig(a.configPath) + if err != nil { + return err + } + return a.write(redactMap(map[string]any{"baseUrl": cfg.BaseURL, "apiKey": cfg.APIKey, "token": cfg.Token})) + case "set": + return a.configSet(args[1:]) + default: + return fmt.Errorf("unknown config subcommand: %s", args[0]) + } +} + +func (a *app) configSet(args []string) error { + fs := flag.NewFlagSet("config set", flag.ContinueOnError) + fs.SetOutput(a.err) + baseURL := fs.String("base-url", a.baseURL, "API base URL") + apiKeyEnv := fs.String("api-key-env", "", "environment variable containing system API key") + tokenEnv := fs.String("token-env", "", "environment variable containing tenant bearer token") + if err := fs.Parse(args); err != nil { + return err + } + cfg, _ := readConfig(a.configPath) + cfg.BaseURL = *baseURL + if *apiKeyEnv != "" { + cfg.APIKey = a.env(*apiKeyEnv) + } + if *tokenEnv != "" { + cfg.Token = a.env(*tokenEnv) + } + if err := writeConfig(a.configPath, cfg); err != nil { + return err + } + return a.write(map[string]any{"ok": true, "path": a.configPath, "stored": redactMap(map[string]any{"baseUrl": cfg.BaseURL, "apiKey": cfg.APIKey, "token": cfg.Token})}) +} + +func (a *app) streams(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("streams subcommand required") + } + switch args[0] { + case "create": + fs := flag.NewFlagSet("streams create", flag.ContinueOnError) + fs.SetOutput(a.err) + useCase := fs.String("use-case", "", "stream use case") + policyID := fs.String("policy-id", "", "witness policy id") + metadataFile := fs.String("metadata-file", "", "JSON metadata file") + if err := fs.Parse(args[1:]); err != nil { + return err + } + metadata, err := readOptionalObject(*metadataFile) + if err != nil { + return err + } + client, err := a.systemClient() + if err != nil { + return err + } + stream, err := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: *useCase, PolicyID: *policyID, Metadata: metadata}) + if err != nil { + return err + } + return a.write(stream) + case "get": + fs := flag.NewFlagSet("streams get", flag.ContinueOnError) + fs.SetOutput(a.err) + streamID := fs.String("stream-id", "", "stream id") + if err := fs.Parse(args[1:]); err != nil { + return err + } + client, err := a.bearerClient() + if err != nil { + return err + } + stream, err := client.GetTenantStream(ctx, *streamID) + if err != nil { + return err + } + return a.write(stream) + case "head": + fs := flag.NewFlagSet("streams head", flag.ContinueOnError) + fs.SetOutput(a.err) + streamID := fs.String("stream-id", "", "stream id") + if err := fs.Parse(args[1:]); err != nil { + return err + } + client, err := a.systemClient() + if err != nil { + return err + } + head, err := client.GetStreamHead(ctx, *streamID) + if err != nil { + return err + } + return a.write(head) + default: + return fmt.Errorf("unknown streams subcommand: %s", args[0]) + } +} + +func (a *app) events(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("events subcommand required") + } + client, err := a.systemClient() + if err != nil { + return err + } + switch args[0] { + case "log": + fs := flag.NewFlagSet("events log", flag.ContinueOnError) + fs.SetOutput(a.err) + streamID := fs.String("stream-id", "", "stream id") + sourceRef := fs.String("source-ref", "", "source reference") + eventType := fs.String("event-type", "inference", "event type") + payloadFile := fs.String("payload-file", "", "JSON payload file") + metadataFile := fs.String("metadata-file", "", "JSON metadata file") + if err := fs.Parse(args[1:]); err != nil { + return err + } + payload, err := readOptionalObject(*payloadFile) + if err != nil { + return err + } + metadata, err := readOptionalObject(*metadataFile) + if err != nil { + return err + } + receipt, err := client.LogEvent(ctx, *streamID, attesto.EventInput{SourceRef: *sourceRef, EventType: *eventType, Payload: payload, Metadata: metadata}) + if err != nil { + return err + } + return a.write(receipt) + case "batch": + fs := flag.NewFlagSet("events batch", flag.ContinueOnError) + fs.SetOutput(a.err) + streamID := fs.String("stream-id", "", "stream id") + file := fs.String("file", "", "JSON array or {events:[...]} file") + if err := fs.Parse(args[1:]); err != nil { + return err + } + events, err := readEventsFile(*file) + if err != nil { + return err + } + result, err := client.LogEvents(ctx, *streamID, events) + if err != nil { + return err + } + return a.write(result) + default: + return fmt.Errorf("unknown events subcommand: %s", args[0]) + } +} + +func (a *app) receipts(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("receipts subcommand required") + } + switch args[0] { + case "get": + fs := flag.NewFlagSet("receipts get", flag.ContinueOnError) + fs.SetOutput(a.err) + eventID := fs.String("event-id", "", "stream event id") + if err := fs.Parse(args[1:]); err != nil { + return err + } + client, err := a.systemClient() + if err != nil { + return err + } + receipt, err := client.GetReceipt(ctx, *eventID) + if err != nil { + return err + } + return a.write(receipt) + case "verify": + fs := flag.NewFlagSet("receipts verify", flag.ContinueOnError) + fs.SetOutput(a.err) + file := fs.String("file", "", "receipt JSON file") + publicKeyHex := fs.String("public-key-hex", "", "Ed25519 public key hex") + remote := fs.Bool("remote", false, "verify through /v2/verify/receipt") + if err := fs.Parse(args[1:]); err != nil { + return err + } + receipt, err := readReceipt(*file) + if err != nil { + return err + } + if *remote { + client, err := a.systemClient() + if err != nil { + return err + } + report, err := client.VerifyReceiptRemote(ctx, attesto.ReceiptVerifyInput{Receipt: receipt, PublicKeyHex: *publicKeyHex}) + if err != nil { + return err + } + return a.write(report) + } + return a.write(attesto.VerifyReceiptOffline(receipt, *publicKeyHex)) + default: + return fmt.Errorf("unknown receipts subcommand: %s", args[0]) + } +} + +func (a *app) verifyableObject(ctx context.Context, group string, args []string, kind attesto.VerifyKind, getPrefix string) error { + if len(args) == 0 { + return fmt.Errorf("%s subcommand required", group) + } + switch args[0] { + case "get": + fs := flag.NewFlagSet(group+" get", flag.ContinueOnError) + fs.SetOutput(a.err) + id := fs.String("id", "", "object id") + if err := fs.Parse(args[1:]); err != nil { + return err + } + client, err := a.systemClient() + if err != nil { + return err + } + obj, err := getProofObject(ctx, client, kind, *id, getPrefix) + if err != nil { + return err + } + return a.write(obj) + case "verify": + return a.remoteVerify(ctx, args[1:], kind) + default: + return fmt.Errorf("unknown %s subcommand: %s", group, args[0]) + } +} + +func (a *app) checkpoints(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("checkpoints subcommand required") + } + if args[0] == "consistency" { + fs := flag.NewFlagSet("checkpoints consistency", flag.ContinueOnError) + fs.SetOutput(a.err) + checkpointID := fs.String("checkpoint-id", "", "target checkpoint id") + fromID := fs.String("from", "", "source checkpoint id") + if err := fs.Parse(args[1:]); err != nil { + return err + } + client, err := a.systemClient() + if err != nil { + return err + } + obj, err := client.GetCheckpointConsistency(ctx, *checkpointID, *fromID) + if err != nil { + return err + } + return a.write(obj) + } + return a.verifyableObject(ctx, "checkpoints", args, attesto.VerifyCheckpoint, "/v2/checkpoints/") +} + +func (a *app) witnesses(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("witnesses subcommand required") + } + switch args[0] { + case "policies": + fs := flag.NewFlagSet("witnesses policies", flag.ContinueOnError) + fs.SetOutput(a.err) + policyID := fs.String("policy-id", "", "policy id") + if err := fs.Parse(args[1:]); err != nil { + return err + } + client, err := a.systemClient() + if err != nil { + return err + } + policy, err := client.GetWitnessPolicy(ctx, *policyID) + if err != nil { + return err + } + return a.write(policy) + case "status", "receipts": + fs := flag.NewFlagSet("witnesses "+args[0], flag.ContinueOnError) + fs.SetOutput(a.err) + streamID := fs.String("stream-id", "", "stream id") + if err := fs.Parse(args[1:]); err != nil { + return err + } + client, err := a.bearerClient() + if err != nil { + return err + } + state, err := client.GetTenantProofState(ctx, *streamID) + if err != nil { + return err + } + return a.write(state) + default: + return fmt.Errorf("unknown witnesses subcommand: %s", args[0]) + } +} + +func (a *app) bundles(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("bundles subcommand required") + } + switch args[0] { + case "build": + fs := flag.NewFlagSet("bundles build", flag.ContinueOnError) + fs.SetOutput(a.err) + fromID := fs.String("from", "", "from checkpoint id") + toID := fs.String("to", "", "to checkpoint id") + tenant := fs.Bool("tenant", false, "use tenant audit-pack endpoint") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if *tenant { + client, err := a.bearerClient() + if err != nil { + return err + } + bundle, err := client.BuildTenantAuditPack(ctx, *fromID, *toID) + if err != nil { + return err + } + return a.write(bundle) + } + client, err := a.systemClient() + if err != nil { + return err + } + bundle, err := client.BuildVerifierBundle(ctx, *fromID, *toID) + if err != nil { + return err + } + return a.write(bundle) + case "get": + fs := flag.NewFlagSet("bundles get", flag.ContinueOnError) + fs.SetOutput(a.err) + file := fs.String("file", "", "local verifier bundle JSON file") + if err := fs.Parse(args[1:]); err != nil { + return err + } + obj, err := readObject(*file) + if err != nil { + return err + } + return a.write(obj) + case "verify": + return a.remoteVerify(ctx, args[1:], attesto.VerifyBundle) + case "offline-verify": + fs := flag.NewFlagSet("bundles offline-verify", flag.ContinueOnError) + fs.SetOutput(a.err) + file := fs.String("file", "", "bundle JSON file") + if err := fs.Parse(args[1:]); err != nil { + return err + } + obj, err := readObject(*file) + if err != nil { + return err + } + return a.write(verifyBundleHash(obj)) + default: + return fmt.Errorf("unknown bundles subcommand: %s", args[0]) + } +} + +func (a *app) forkEvidence(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("fork-evidence subcommand required") + } + switch args[0] { + case "inspect": + fs := flag.NewFlagSet("fork-evidence inspect", flag.ContinueOnError) + fs.SetOutput(a.err) + streamID := fs.String("stream-id", "", "stream id") + if err := fs.Parse(args[1:]); err != nil { + return err + } + client, err := a.bearerClient() + if err != nil { + return err + } + forks, err := client.ListForkEvidence(ctx, *streamID, 100, 0) + if err != nil { + return err + } + return a.write(forks) + case "verify": + fs := flag.NewFlagSet("fork-evidence verify", flag.ContinueOnError) + fs.SetOutput(a.err) + file := fs.String("file", "", "fork evidence JSON file") + if err := fs.Parse(args[1:]); err != nil { + return err + } + obj, err := readObject(*file) + if err != nil { + return err + } + return a.write(verifyDomainObject("fork", obj, "evidence", "evidenceHash", "evidence_hash")) + default: + return fmt.Errorf("unknown fork-evidence subcommand: %s", args[0]) + } +} + +func (a *app) quorum(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("quorum subcommand required") + } + switch args[0] { + case "inspect": + fs := flag.NewFlagSet("quorum inspect", flag.ContinueOnError) + fs.SetOutput(a.err) + streamID := fs.String("stream-id", "", "stream id") + if err := fs.Parse(args[1:]); err != nil { + return err + } + client, err := a.bearerClient() + if err != nil { + return err + } + state, err := client.GetTenantProofState(ctx, *streamID) + if err != nil { + return err + } + return a.write(state) + case "verify": + fs := flag.NewFlagSet("quorum verify", flag.ContinueOnError) + fs.SetOutput(a.err) + file := fs.String("file", "", "quorum JSON file") + if err := fs.Parse(args[1:]); err != nil { + return err + } + obj, err := readObject(*file) + if err != nil { + return err + } + return a.write(verifyQuorumObject(obj)) + default: + return fmt.Errorf("unknown quorum subcommand: %s", args[0]) + } +} + +func (a *app) ivc(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("ivc subcommand required") + } + if args[0] != "epochs" { + return fmt.Errorf("unknown ivc subcommand: %s", args[0]) + } + if len(args) < 2 { + return errors.New("ivc epochs subcommand required") + } + switch args[1] { + case "get": + fs := flag.NewFlagSet("ivc epochs get", flag.ContinueOnError) + fs.SetOutput(a.err) + id := fs.String("id", "", "ivc epoch id") + if err := fs.Parse(args[2:]); err != nil { + return err + } + client, err := a.systemClient() + if err != nil { + return err + } + obj, err := client.GetIVCEpoch(ctx, *id) + if err != nil { + return err + } + return a.write(obj) + case "verify": + return a.remoteVerify(ctx, args[2:], attesto.VerifyIVC) + default: + return fmt.Errorf("unknown ivc epochs subcommand: %s", args[1]) + } +} + +func (a *app) connectors(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("connectors subcommand required") + } + switch args[0] { + case "create": + return a.connectorCreate(ctx, args[1:]) + case "ingest": + return a.connectorIngest(ctx, args[1:]) + case "revoke": + return a.connectorRevoke(ctx, args[1:]) + case "verify": + fs := flag.NewFlagSet("connectors verify", flag.ContinueOnError) + fs.SetOutput(a.err) + secretEnv := fs.String("secret-env", "", "environment variable containing connector secret") + file := fs.String("file", "", "event JSON file") + timestamp := fs.Int64("timestamp", 0, "timestamp used for expected signature") + signature := fs.String("signature", "", "signature hex to verify") + if err := fs.Parse(args[1:]); err != nil { + return err + } + raw, err := os.ReadFile(*file) + if err != nil { + return err + } + _, expected := attesto.SignConnectorWebhookPayload(a.env(*secretEnv), raw, *timestamp) + return a.write(map[string]any{"ok": hmacEqual(expected, *signature), "expectedSignatureMatches": hmacEqual(expected, *signature)}) + default: + return fmt.Errorf("unknown connectors subcommand: %s", args[0]) + } +} + +func (a *app) connectorCreate(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("connectors create", flag.ContinueOnError) + fs.SetOutput(a.err) + kind := fs.String("type", "", "signed-webhook, s3-object, or repository-webhook") + file := fs.String("file", "", "connector create JSON file") + if err := fs.Parse(args); err != nil { + return err + } + client, err := a.bearerClient() + if err != nil { + return err + } + raw, err := os.ReadFile(*file) + if err != nil { + return err + } + switch *kind { + case "signed-webhook": + var input attesto.ConnectorCreateInput + if err := json.Unmarshal(raw, &input); err != nil { + return err + } + out, err := client.CreateSignedWebhookConnector(ctx, input) + if err != nil { + return err + } + return a.write(out) + case "s3-object": + var input attesto.S3ConnectorCreateInput + if err := json.Unmarshal(raw, &input); err != nil { + return err + } + out, err := client.CreateS3ObjectConnector(ctx, input) + if err != nil { + return err + } + return a.write(out) + case "repository-webhook": + var input attesto.RepositoryConnectorCreateInput + if err := json.Unmarshal(raw, &input); err != nil { + return err + } + out, err := client.CreateRepositoryWebhookConnector(ctx, input) + if err != nil { + return err + } + return a.write(out) + default: + return errors.New("connector type must be signed-webhook, s3-object, or repository-webhook") + } +} + +func (a *app) connectorIngest(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("connectors ingest", flag.ContinueOnError) + fs.SetOutput(a.err) + kind := fs.String("type", "signed-webhook", "signed-webhook or repository-webhook") + connectorID := fs.String("connector-id", "", "connector id") + file := fs.String("file", "", "event JSON file") + secretEnv := fs.String("secret-env", "", "environment variable containing signed-webhook secret") + headersFile := fs.String("headers-file", "", "repository webhook headers JSON file") + if err := fs.Parse(args); err != nil { + return err + } + client, err := a.systemClient() + if err != nil { + return err + } + raw, err := os.ReadFile(*file) + if err != nil { + return err + } + if *kind == "repository-webhook" { + headers, err := readStringMap(*headersFile) + if err != nil { + return err + } + out, err := client.IngestRepositoryWebhookEvent(ctx, *connectorID, raw, headers) + if err != nil { + return err + } + return a.write(out) + } + var event attesto.EventInput + if err := json.Unmarshal(raw, &event); err != nil { + return err + } + out, err := client.IngestSignedWebhookEvent(ctx, *connectorID, event, a.env(*secretEnv)) + if err != nil { + return err + } + return a.write(out) +} + +func (a *app) connectorRevoke(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("connectors revoke", flag.ContinueOnError) + fs.SetOutput(a.err) + kind := fs.String("type", "", "signed-webhook, s3-object, or repository-webhook") + connectorID := fs.String("connector-id", "", "connector id") + if err := fs.Parse(args); err != nil { + return err + } + client, err := a.bearerClient() + if err != nil { + return err + } + switch *kind { + case "signed-webhook": + return client.RevokeSignedWebhookConnector(ctx, *connectorID) + case "s3-object": + return client.RevokeS3ObjectConnector(ctx, *connectorID) + case "repository-webhook": + return client.RevokeRepositoryWebhookConnector(ctx, *connectorID) + default: + return errors.New("connector type must be signed-webhook, s3-object, or repository-webhook") + } +} + +func (a *app) localVault(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("local-vault subcommand required") + } + switch args[0] { + case "install": + fs := flag.NewFlagSet("local-vault install", flag.ContinueOnError) + fs.SetOutput(a.err) + file := fs.String("file", "", "installation JSON file") + if err := fs.Parse(args[1:]); err != nil { + return err + } + raw, err := os.ReadFile(*file) + if err != nil { + return err + } + var input attesto.LocalVaultInstallationCreateInput + if err := json.Unmarshal(raw, &input); err != nil { + return err + } + client, err := a.bearerClient() + if err != nil { + return err + } + out, err := client.CreateLocalVaultInstallation(ctx, input) + if err != nil { + return err + } + return a.write(out) + case "relay": + fs := flag.NewFlagSet("local-vault relay", flag.ContinueOnError) + fs.SetOutput(a.err) + installationID := fs.String("installation-id", "", "installation id") + file := fs.String("file", "", "relay JSON file") + if err := fs.Parse(args[1:]); err != nil { + return err + } + obj, err := readObject(*file) + if err != nil { + return err + } + client, err := a.systemClient() + if err != nil { + return err + } + out, err := client.RelayLocalVaultEvent(ctx, *installationID, objectField(obj, "envelope"), objectField(obj, "payload"), stringField(obj, "envelopeHash", "envelope_hash"), stringField(obj, "signatureHex", "signature_hex"), stringField(obj, "publicKeyHex", "public_key_hex")) + if err != nil { + return err + } + return a.write(out) + case "spool": + return a.localVaultSpool(args[1:]) + case "status": + return a.localVaultStatus(args[1:]) + case "witness": + return a.localVaultWitness(ctx, args[1:], false) + case "fork-evidence": + return a.localVaultWitness(ctx, args[1:], true) + case "revoke": + fs := flag.NewFlagSet("local-vault revoke", flag.ContinueOnError) + fs.SetOutput(a.err) + installationID := fs.String("installation-id", "", "installation id") + if err := fs.Parse(args[1:]); err != nil { + return err + } + client, err := a.bearerClient() + if err != nil { + return err + } + return client.RevokeLocalVaultInstallation(ctx, *installationID) + default: + return fmt.Errorf("unknown local-vault subcommand: %s", args[0]) + } +} + +func (a *app) localVaultSpool(args []string) error { + fs := flag.NewFlagSet("local-vault spool", flag.ContinueOnError) + fs.SetOutput(a.err) + spoolFile := fs.String("spool-file", "", "spool JSONL path") + file := fs.String("file", "", "event JSON file to append") + if err := fs.Parse(args); err != nil { + return err + } + raw, err := os.ReadFile(*file) + if err != nil { + return err + } + var obj any + if err := json.Unmarshal(raw, &obj); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(*spoolFile), 0o700); err != nil { + return err + } + fh, err := os.OpenFile(*spoolFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer fh.Close() + canonical, err := attesto.CanonicalJSON(obj) + if err != nil { + return err + } + if _, err := fh.Write(append(canonical, '\n')); err != nil { + return err + } + return a.write(map[string]any{"ok": true, "spoolFile": *spoolFile}) +} + +func (a *app) localVaultStatus(args []string) error { + fs := flag.NewFlagSet("local-vault status", flag.ContinueOnError) + fs.SetOutput(a.err) + spoolFile := fs.String("spool-file", "", "spool JSONL path") + if err := fs.Parse(args); err != nil { + return err + } + fh, err := os.Open(*spoolFile) + if err != nil { + return err + } + defer fh.Close() + var count int + scanner := bufio.NewScanner(fh) + for scanner.Scan() { + count++ + } + if err := scanner.Err(); err != nil { + return err + } + info, err := os.Stat(*spoolFile) + if err != nil { + return err + } + return a.write(map[string]any{"ok": true, "spoolFile": *spoolFile, "events": count, "bytes": info.Size()}) +} + +func (a *app) localVaultWitness(ctx context.Context, args []string, fork bool) error { + fs := flag.NewFlagSet("local-vault witness", flag.ContinueOnError) + fs.SetOutput(a.err) + installationID := fs.String("installation-id", "", "installation id") + file := fs.String("file", "", "witness receipt or fork evidence JSON file") + if err := fs.Parse(args); err != nil { + return err + } + obj, err := readObject(*file) + if err != nil { + return err + } + client, err := a.systemClient() + if err != nil { + return err + } + if fork { + out, err := client.SubmitLocalVaultForkEvidence(ctx, *installationID, obj) + if err != nil { + return err + } + return a.write(out) + } + out, err := client.SubmitLocalVaultWitnessReceipt(ctx, *installationID, obj) + if err != nil { + return err + } + return a.write(out) +} + +func (a *app) readiness(args []string) error { + if len(args) == 0 { + return errors.New("readiness subcommand required") + } + defaults := map[string]string{ + "lifecycle": "release/attesto-2.0-lifecycle-readiness/result.json", + "fork-defense": "release/attesto-2.0-fork-defense-readiness/result.json", + "quorum": "release/attesto-2.0-quorum-readiness/result.json", + "assurance": "release/attesto-2.0-assurance-readiness/result.json", + "connectors": "release/attesto-2.0-connector-assurance-readiness/result.json", + "local-vault": "release/attesto-2.0-local-vault-assurance-readiness/result.json", + "nova": "release/attesto-2.0-nova-evolution-readiness/result.json", + "production": "release/attesto-2.0-production-readiness/manifest.json", + } + path, ok := defaults[args[0]] + if !ok { + return fmt.Errorf("unknown readiness subcommand: %s", args[0]) + } + fs := flag.NewFlagSet("readiness "+args[0], flag.ContinueOnError) + fs.SetOutput(a.err) + file := fs.String("file", path, "readiness JSON file") + if err := fs.Parse(args[1:]); err != nil { + return err + } + obj, err := readObject(*file) + if err != nil { + return err + } + return a.write(validateReadiness(args[0], *file, obj)) +} + +func (a *app) remoteVerify(ctx context.Context, args []string, kind attesto.VerifyKind) error { + fs := flag.NewFlagSet("verify", flag.ContinueOnError) + fs.SetOutput(a.err) + file := fs.String("file", "", "proof object JSON file") + publicKeyHex := fs.String("public-key-hex", "", "Ed25519 public key hex when required") + if err := fs.Parse(args); err != nil { + return err + } + obj, err := readObject(*file) + if err != nil { + return err + } + client, err := a.systemClient() + if err != nil { + return err + } + report, err := client.VerifyObjectRemote(ctx, attesto.OfflineVerifyInput{Kind: kind, Object: obj, PublicKeyHex: *publicKeyHex}) + if err != nil { + return err + } + return a.write(report) +} + +func (a *app) systemClient() (*attesto.Client, error) { + if a.apiKey == "" { + return nil, errors.New("system API key required; configure it with --api-key-env or attesto config set --api-key-env") + } + return attesto.NewClient(a.apiKey, attesto.WithBaseURL(a.baseURL), attesto.WithHTTPClient(http.DefaultClient)) +} + +func (a *app) bearerClient() (*attesto.Client, error) { + if a.token == "" { + return nil, errors.New("tenant bearer token required; configure it with --token-env or attesto config set --token-env") + } + return attesto.NewBearerClient(a.token, attesto.WithBaseURL(a.baseURL), attesto.WithHTTPClient(http.DefaultClient)) +} + +func (a *app) write(value any) error { + value = redactValue(value) + encoder := json.NewEncoder(a.out) + encoder.SetEscapeHTML(false) + if a.jsonOutput { + encoder.SetIndent("", " ") + } + return encoder.Encode(value) +} + +func (a *app) fail(message string) { + _ = json.NewEncoder(a.err).Encode(map[string]any{"ok": false, "error": sanitize(message)}) +} + +func readObject(path string) (attesto.M, error) { + if path == "" { + return nil, errors.New("file is required") + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var obj attesto.M + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, err + } + return obj, nil +} + +func readOptionalObject(path string) (attesto.M, error) { + if path == "" { + return attesto.M{}, nil + } + return readObject(path) +} + +func readReceipt(path string) (attesto.SignedReceipt, error) { + obj, err := readObject(path) + if err != nil { + return attesto.SignedReceipt{}, err + } + if nested, ok := obj["receipt"].(map[string]any); ok { + obj = nested + } + raw, err := json.Marshal(obj) + if err != nil { + return attesto.SignedReceipt{}, err + } + var receipt attesto.SignedReceipt + return receipt, json.Unmarshal(raw, &receipt) +} + +func readEventsFile(path string) ([]attesto.EventInput, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var wrapper struct { + Events []attesto.EventInput `json:"events"` + } + if err := json.Unmarshal(raw, &wrapper); err == nil && wrapper.Events != nil { + return wrapper.Events, nil + } + var events []attesto.EventInput + return events, json.Unmarshal(raw, &events) +} + +func readStringMap(path string) (map[string]string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var out map[string]string + return out, json.Unmarshal(raw, &out) +} + +func getProofObject(ctx context.Context, client *attesto.Client, kind attesto.VerifyKind, id string, _ string) (attesto.M, error) { + switch kind { + case attesto.VerifyWindow: + return client.GetWindow(ctx, id) + case attesto.VerifyCheckpoint: + return client.GetCheckpoint(ctx, id) + case attesto.VerifyAnchor: + return client.GetAnchorEpoch(ctx, id) + case attesto.VerifyIVC: + return client.GetIVCEpoch(ctx, id) + default: + return nil, fmt.Errorf("get is not defined for proof object kind %s", kind) + } +} + +func verifyBundleHash(obj attesto.M) map[string]any { + return verifyDomainObject("bundle", obj, "payload", "bundleHash", "bundle_hash") +} + +func verifyDomainObject(domainKey string, obj attesto.M, payloadKey, camelHash, snakeHash string) map[string]any { + payload := objectField(obj, payloadKey) + hash := stringField(obj, camelHash, snakeHash) + computed, err := attesto.DomainHashHex(attesto.ProofstreamDomains[domainKey], payload) + ok := err == nil && computed == hash + problems := []string{} + if err != nil { + problems = append(problems, err.Error()) + } + if computed != hash { + problems = append(problems, domainKey+" hash mismatch") + } + return map[string]any{"ok": ok, "computedHash": computed, "expectedHash": hash, "problems": problems} +} + +func verifyQuorumObject(obj attesto.M) map[string]any { + accepted := numberField(obj, "acceptedWitnessCount", "accepted_witness_count", "witnessReceiptCount", "witness_receipt_count") + threshold := numberField(obj, "quorumThreshold", "quorum_threshold") + ok := threshold > 0 && accepted >= threshold + problems := []string{} + if threshold <= 0 { + problems = append(problems, "quorum threshold missing") + } + if accepted < threshold { + problems = append(problems, "quorum threshold not satisfied") + } + return map[string]any{"ok": ok, "acceptedWitnessCount": accepted, "quorumThreshold": threshold, "problems": problems} +} + +func validateReadiness(kind, path string, obj attesto.M) map[string]any { + ok, _ := obj["ok"].(bool) + if !ok { + if summary, okSummary := obj["summary"].(map[string]any); okSummary { + if summaryOK, okBool := summary["ok"].(bool); okBool { + ok = summaryOK + } + } + } + problems := obj["problems"] + if problems == nil { + problems = []any{} + } + return map[string]any{"ok": ok, "kind": kind, "file": path, "problems": problems} +} + +func defaultConfigPath(getenv func(string) string) string { + if value := getenv("ATTESTO_CONFIG"); value != "" { + return value + } + if value := getenv("XDG_CONFIG_HOME"); value != "" { + return filepath.Join(value, "attesto", "config.json") + } + if home := getenv("HOME"); home != "" { + return filepath.Join(home, ".config", "attesto", "config.json") + } + return "attesto-config.json" +} + +func readConfig(path string) (cliConfig, error) { + var cfg cliConfig + raw, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + return cfg, json.Unmarshal(raw, &cfg) +} + +func writeConfig(path string, cfg cliConfig) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + raw, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(raw, '\n'), 0o600) +} + +func redactValue(value any) any { + raw, err := json.Marshal(value) + if err != nil { + return value + } + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + return value + } + return redactAny(decoded) +} + +func redactAny(value any) any { + switch v := value.(type) { + case map[string]any: + out := make(map[string]any, len(v)) + for key, child := range v { + lower := strings.ToLower(key) + if strings.Contains(lower, "secret") || strings.Contains(lower, "token") || strings.Contains(lower, "apikey") || strings.Contains(lower, "api_key") { + if s, ok := child.(string); ok && s != "" { + out[key] = mask(s) + continue + } + } + out[key] = redactAny(child) + } + return out + case []any: + out := make([]any, len(v)) + for i, child := range v { + out[i] = redactAny(child) + } + return out + default: + return v + } +} + +func redactMap(value map[string]any) map[string]any { + return redactAny(value).(map[string]any) +} + +func sanitize(message string) string { + for _, marker := range []string{"sk_live_", "sk_test_", "pk_live_", "pk_test_", "npm_", "pypi-", "atto_live_", "atto_test_"} { + if strings.Contains(message, marker) { + return "redacted" + } + } + return message +} + +func mask(secret string) string { + if len(secret) <= 8 { + return "redacted" + } + return secret[:4] + "..." + secret[len(secret)-4:] +} + +func objectField(obj attesto.M, keys ...string) attesto.M { + for _, key := range keys { + if value, ok := obj[key].(map[string]any); ok { + return attesto.M(value) + } + } + return attesto.M{} +} + +func stringField(obj attesto.M, keys ...string) string { + for _, key := range keys { + if value, ok := obj[key].(string); ok { + return value + } + } + return "" +} + +func numberField(obj attesto.M, keys ...string) int { + for _, key := range keys { + switch value := obj[key].(type) { + case float64: + return int(value) + case int: + return value + case string: + parsed, _ := strconv.Atoi(value) + return parsed + } + } + return 0 +} + +func hmacEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + var diff byte + for i := range a { + diff |= a[i] ^ b[i] + } + return diff == 0 +} diff --git a/cmd/attesto/main_test.go b/cmd/attesto/main_test.go new file mode 100644 index 0000000..9d7d1f3 --- /dev/null +++ b/cmd/attesto/main_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +const cliTestAPIKey = "atto_test_0123456789abcdef0123456789abcdef" + +func TestVersionJSON(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"--json", "version"}, &stdout, &stderr, testEnv(t, nil)) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + var out map[string]any + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("json: %v", err) + } + if out["name"] != "attesto" || out["version"] == "" { + t.Fatalf("unexpected version output: %s", stdout.String()) + } +} + +func TestReceiptsVerifyOfflineGoldenVector(t *testing.T) { + vector := loadVector(t) + dir := t.TempDir() + receiptFile := filepath.Join(dir, "receipt.json") + rawReceipt, _ := json.Marshal(vector["receipt"]) + if err := os.WriteFile(receiptFile, rawReceipt, 0o600); err != nil { + t.Fatal(err) + } + publicKey := vector["signing"].(map[string]any)["public_key_hex"].(string) + var stdout, stderr bytes.Buffer + code := run([]string{"--json", "receipts", "verify", "--file", receiptFile, "--public-key-hex", publicKey}, &stdout, &stderr, testEnv(t, nil)) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + var out map[string]any + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("json: %v", err) + } + if out["ok"] != true { + t.Fatalf("receipt did not verify: %s", stdout.String()) + } +} + +func TestConfigSetRedactsSecrets(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, "config.json") + env := testEnv(t, map[string]string{ + "ATTESTO_CONFIG": config, + "ATT_API_KEY": cliTestAPIKey, + "ATT_TOKEN": "tenant-token-secret", + }) + var stdout, stderr bytes.Buffer + code := run([]string{"--json", "config", "set", "--api-key-env", "ATT_API_KEY", "--token-env", "ATT_TOKEN"}, &stdout, &stderr, env) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + if strings.Contains(stdout.String(), cliTestAPIKey) || strings.Contains(stdout.String(), "tenant-token-secret") { + t.Fatalf("secret leaked in output: %s", stdout.String()) + } + raw, err := os.ReadFile(config) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), cliTestAPIKey) { + t.Fatalf("config did not persist api key") + } +} + +func TestStreamsCreateCallsAPI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/streams" || r.Method != http.MethodPost { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer "+cliTestAPIKey { + t.Fatalf("missing auth") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"streamId":"str_cli","systemId":"sys_cli","useCase":"audit","policyId":"policy","status":"active","lastSeqNo":0,"created":true}`)) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + code := run([]string{"--json", "--base-url", server.URL, "--api-key-env", "ATT_API_KEY", "streams", "create", "--use-case", "audit", "--policy-id", "policy"}, &stdout, &stderr, testEnv(t, map[string]string{"ATT_API_KEY": cliTestAPIKey})) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "str_cli") { + t.Fatalf("unexpected stdout: %s", stdout.String()) + } +} + +func loadVector(t *testing.T) map[string]any { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "golden-vectors", "proofstream-v0.1-alpha", "one-stream-two-events.json")) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out +} + +func testEnv(t *testing.T, values map[string]string) func(string) string { + t.Helper() + return func(key string) string { + if value, ok := values[key]; ok { + return value + } + return "" + } +} diff --git a/examples/proofstream/main.go b/examples/proofstream/main.go new file mode 100644 index 0000000..9dd096d --- /dev/null +++ b/examples/proofstream/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go" +) + +func main() { + ctx := context.Background() + client, err := attesto.NewClient(os.Getenv("ATTESTO_API_KEY")) + if err != nil { + log.Fatal(err) + } + stream, err := client.CreateStream(ctx, attesto.StreamCreateInput{ + UseCase: "ai-governance", + PolicyID: "policy-main", + }) + if err != nil { + log.Fatal(err) + } + receipt, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{ + SourceRef: "example-decision", + Payload: attesto.M{ + "decision": "approved", + "reason": "policy-threshold-met", + }, + }) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s %s\n", receipt.StreamEventID, receipt.EventHash) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..72c1bf3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go + +go 1.24 diff --git a/proofstream.go b/proofstream.go new file mode 100644 index 0000000..fb8a334 --- /dev/null +++ b/proofstream.go @@ -0,0 +1,254 @@ +package attesto + +import ( + "bytes" + "crypto/ed25519" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "sort" + "strings" + "time" +) + +var ProofstreamDomains = map[string]string{ + "anchor": "attesto.v2.anchor", + "bundle": "attesto.v2.bundle", + "checkpoint": "attesto.v2.checkpoint", + "consistency": "attesto.v2.consistency", + "event": "attesto.v2.event", + "fork": "attesto.v2.fork", + "ivc": "attesto.v2.ivc", + "receipt": "attesto.v2.receipt", + "stream": "attesto.v2.stream", + "window": "attesto.v2.window", + "witness": "attesto.v2.witness", + "witness_policy": "attesto.v2.witness_policy", +} + +func CanonicalJSON(value any) ([]byte, error) { + normalized, err := normalizeCanonical(value) + if err != nil { + return nil, err + } + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(normalized); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} + +func CanonicalJSONHex(value any) (string, error) { + raw, err := CanonicalJSON(value) + if err != nil { + return "", err + } + return hex.EncodeToString(raw), nil +} + +func DomainHashHex(domain string, value any) (string, error) { + raw, err := CanonicalJSON(value) + if err != nil { + return "", err + } + h := sha256.New() + h.Write([]byte(domain)) + h.Write([]byte{0}) + h.Write(raw) + return hex.EncodeToString(h.Sum(nil)), nil +} + +func SHA256Hex(value []byte) string { + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) +} + +func SignConnectorWebhookPayload(secret string, body []byte, timestamp int64) (string, string) { + if timestamp == 0 { + timestamp = time.Now().Unix() + } + ts := fmt.Sprintf("%d", timestamp) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(ts)) + mac.Write([]byte(".")) + mac.Write(body) + return ts, hex.EncodeToString(mac.Sum(nil)) +} + +func SignedConnectorWebhookHeaders(secret string, body []byte, timestamp int64) map[string]string { + ts, sig := SignConnectorWebhookPayload(secret, body, timestamp) + return map[string]string{ + "X-Attesto-Connector-Timestamp": ts, + "X-Attesto-Connector-Signature": sig, + } +} + +func VerifyReceiptOffline(receipt SignedReceipt, publicKeyHex string) VerifyReport { + problems := make([]string, 0) + hash, err := DomainHashHex(ProofstreamDomains["receipt"], receipt.Payload) + if err != nil { + problems = append(problems, "receipt payload is not canonical-json compatible") + } else if hash != receipt.ReceiptHash { + problems = append(problems, "receipt hash mismatch") + } + if strings.ToLower(receipt.Signature.Alg) != "ed25519" { + problems = append(problems, "unsupported receipt signature algorithm") + } + signatureHex := receipt.Signature.SignatureHex + if signatureHex == "" { + problems = append(problems, "receipt signature missing") + } + if err == nil && signatureHex != "" { + publicKey, keyErr := hex.DecodeString(strings.TrimPrefix(strings.ToLower(publicKeyHex), "0x")) + signature, sigErr := hex.DecodeString(strings.TrimPrefix(strings.ToLower(signatureHex), "0x")) + payloadBytes, payloadErr := CanonicalJSON(receipt.Payload) + if keyErr != nil || len(publicKey) != ed25519.PublicKeySize { + problems = append(problems, "invalid public key") + } else if sigErr != nil || len(signature) != ed25519.SignatureSize { + problems = append(problems, "invalid receipt signature") + } else if payloadErr != nil { + problems = append(problems, "receipt payload is not canonical-json compatible") + } else { + message := make([]byte, 0, len(ProofstreamDomains["receipt"])+1+len(payloadBytes)) + message = append(message, []byte(ProofstreamDomains["receipt"])...) + message = append(message, 0) + message = append(message, payloadBytes...) + if !ed25519.Verify(ed25519.PublicKey(publicKey), message, signature) { + problems = append(problems, "receipt signature mismatch") + } + } + } + ok := len(problems) == 0 + result := "failed" + if ok { + result = "accepted" + } + eventHash, _ := receipt.Payload["event_hash"].(string) + if eventHash == "" { + eventHash, _ = receipt.Payload["eventHash"].(string) + } + return VerifyReport{ + Kind: VerifyReceipt, + OK: ok, + ReceiptHash: hash, + EventHash: eventHash, + Problems: problems, + Protocol: ProofstreamProtocol, + ProtocolVersion: ProtocolVersionAlpha, + Subject: subjectFromPayload(receipt.Payload), + Result: result, + Checks: []VerificationCheck{{ + Name: "receipt", + Result: result, + Details: M{"problems": problems}, + }}, + } +} + +func normalizeCanonical(value any) (any, error) { + switch v := value.(type) { + case nil, string, bool: + return v, nil + case time.Time: + return v.UTC().Format("2006-01-02T15:04:05.000Z"), nil + case []byte: + return hex.EncodeToString(v), nil + case int: + return v, nil + case int8: + return int64(v), nil + case int16: + return int64(v), nil + case int32: + return int64(v), nil + case int64: + return v, nil + case uint: + return v, nil + case uint8: + return uint64(v), nil + case uint16: + return uint64(v), nil + case uint32: + return uint64(v), nil + case uint64: + return v, nil + case float32: + if math.IsInf(float64(v), 0) || math.IsNaN(float64(v)) { + return nil, errors.New("canonical JSON cannot encode non-finite numbers") + } + return v, nil + case float64: + if math.IsInf(v, 0) || math.IsNaN(v) { + return nil, errors.New("canonical JSON cannot encode non-finite numbers") + } + return v, nil + case []any: + out := make([]any, len(v)) + for i, item := range v { + normalized, err := normalizeCanonical(item) + if err != nil { + return nil, err + } + out[i] = normalized + } + return out, nil + case []M: + out := make([]any, len(v)) + for i, item := range v { + normalized, err := normalizeCanonical(item) + if err != nil { + return nil, err + } + out[i] = normalized + } + return out, nil + case map[string]any: + return normalizeMap(v) + case M: + return normalizeMap(map[string]any(v)) + default: + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, err + } + return normalizeCanonical(decoded) + } +} + +func normalizeMap(in map[string]any) (map[string]any, error) { + out := make(map[string]any, len(in)) + keys := make([]string, 0, len(in)) + for key := range in { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + normalized, err := normalizeCanonical(in[key]) + if err != nil { + return nil, err + } + out[key] = normalized + } + return out, nil +} + +func subjectFromPayload(payload M) M { + subject := M{} + for _, key := range []string{"tenant_id", "system_id", "stream_id", "stream_event_id", "seq_no", "from_seq_no", "to_seq_no", "checkpoint_id", "from_checkpoint_id", "to_checkpoint_id"} { + if value, ok := payload[key]; ok { + subject[key] = value + } + } + return subject +} diff --git a/proofstream_test.go b/proofstream_test.go new file mode 100644 index 0000000..ed3559a --- /dev/null +++ b/proofstream_test.go @@ -0,0 +1,81 @@ +package attesto + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestGoldenVectorCanonicalJSONAndHashes(t *testing.T) { + vector := loadGoldenVector(t) + events := vector["events"].([]any) + first := events[0].(map[string]any) + envelope := M(first["envelope"].(map[string]any)) + gotCanonicalHex, err := CanonicalJSONHex(envelope) + if err != nil { + t.Fatalf("canonical json: %v", err) + } + if gotCanonicalHex != first["canonical_json_hex"].(string) { + t.Fatalf("canonical json hex mismatch\ngot %s\nwant %s", gotCanonicalHex, first["canonical_json_hex"]) + } + domains := vector["domains"].(map[string]any) + gotHash, err := DomainHashHex(domains["event"].(string), envelope) + if err != nil { + t.Fatalf("domain hash: %v", err) + } + if gotHash != first["event_hash"].(string) { + t.Fatalf("event hash mismatch\ngot %s\nwant %s", gotHash, first["event_hash"]) + } +} + +func TestGoldenVectorReceiptOfflineVerification(t *testing.T) { + vector := loadGoldenVector(t) + rawReceipt, err := json.Marshal(vector["receipt"]) + if err != nil { + t.Fatal(err) + } + var receipt SignedReceipt + if err := json.Unmarshal(rawReceipt, &receipt); err != nil { + t.Fatalf("unmarshal receipt: %v", err) + } + signing := vector["signing"].(map[string]any) + report := VerifyReceiptOffline(receipt, signing["public_key_hex"].(string)) + if !report.OK { + t.Fatalf("receipt should verify: %#v", report.Problems) + } + receipt.Payload["event_hash"] = "00" + receipt.Payload["event_hash"].(string)[2:] + tampered := VerifyReceiptOffline(receipt, signing["public_key_hex"].(string)) + if tampered.OK { + t.Fatal("tampered receipt unexpectedly verified") + } +} + +func TestSignedConnectorWebhookHeaders(t *testing.T) { + headers := SignedConnectorWebhookHeaders("secret", []byte(`{"eventType":"decision"}`), 1710000000) + if headers["X-Attesto-Connector-Timestamp"] != "1710000000" { + t.Fatalf("timestamp mismatch: %v", headers) + } + signature := headers["X-Attesto-Connector-Signature"] + if _, err := hex.DecodeString(signature); err != nil { + t.Fatalf("signature is not hex: %v", err) + } + if len(signature) != 64 { + t.Fatalf("signature length mismatch: %d", len(signature)) + } +} + +func loadGoldenVector(t *testing.T) map[string]any { + t.Helper() + path := filepath.Join("..", "..", "golden-vectors", "proofstream-v0.1-alpha", "one-stream-two-events.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden vector: %v", err) + } + var data map[string]any + if err := json.Unmarshal(raw, &data); err != nil { + t.Fatalf("parse golden vector: %v", err) + } + return data +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..800110c --- /dev/null +++ b/types.go @@ -0,0 +1,271 @@ +package attesto + +import "encoding/json" + +type M map[string]any + +type RequestOptions struct { + IdempotencyKey string +} + +type StreamCreateInput struct { + UseCase string `json:"useCase"` + PolicyID string `json:"policyId"` + Metadata M `json:"metadata,omitempty"` +} + +type Stream struct { + StreamID string `json:"streamId"` + SystemID string `json:"systemId"` + UseCase string `json:"useCase"` + PolicyID string `json:"policyId"` + Status string `json:"status"` + LastSeqNo int64 `json:"lastSeqNo"` + LastEventHash string `json:"lastEventHash,omitempty"` + LastStreamHeadHash string `json:"lastStreamHeadHash,omitempty"` + Created bool `json:"created"` +} + +type TenantStream struct { + StreamID string `json:"streamId"` + SystemID string `json:"systemId"` + SystemName string `json:"systemName,omitempty"` + UseCase string `json:"useCase"` + PolicyID string `json:"policyId"` + Status string `json:"status"` + LastSeqNo int64 `json:"lastSeqNo"` + LastEventHash string `json:"lastEventHash,omitempty"` + LastStreamHeadHash string `json:"lastStreamHeadHash,omitempty"` + OpenedAt string `json:"openedAt"` + UpdatedAt string `json:"updatedAt"` +} + +type StreamHead struct { + StreamID string `json:"streamId"` + SystemID string `json:"systemId"` + Status string `json:"status"` + LastSeqNo int64 `json:"lastSeqNo"` + LastEventHash string `json:"lastEventHash,omitempty"` + LastStreamHeadHash string `json:"lastStreamHeadHash,omitempty"` +} + +type EventInput struct { + EventType string `json:"eventType,omitempty"` + OccurredAt string `json:"occurredAt,omitempty"` + SourceKind string `json:"sourceKind,omitempty"` + SourceRef string `json:"sourceRef"` + Payload M `json:"payload,omitempty"` + Metadata M `json:"metadata,omitempty"` +} + +type ReceiptSignature struct { + Alg string `json:"alg"` + KID string `json:"kid"` + KeyEpoch string `json:"keyEpoch,omitempty"` + SignatureHex string `json:"signatureHex,omitempty"` +} + +type SignedReceipt struct { + Payload M `json:"payload"` + ReceiptHash string `json:"receiptHash"` + Signature ReceiptSignature `json:"signature"` +} + +func (s *ReceiptSignature) UnmarshalJSON(raw []byte) error { + var data map[string]any + if err := json.Unmarshal(raw, &data); err != nil { + return err + } + s.Alg, _ = data["alg"].(string) + s.KID, _ = data["kid"].(string) + if value, ok := data["keyEpoch"].(string); ok { + s.KeyEpoch = value + } else if value, ok := data["key_epoch"].(string); ok { + s.KeyEpoch = value + } + if value, ok := data["signatureHex"].(string); ok { + s.SignatureHex = value + } else if value, ok := data["signature_hex"].(string); ok { + s.SignatureHex = value + } + return nil +} + +func (r *SignedReceipt) UnmarshalJSON(raw []byte) error { + var data map[string]json.RawMessage + if err := json.Unmarshal(raw, &data); err != nil { + return err + } + if payload, ok := data["payload"]; ok { + if err := json.Unmarshal(payload, &r.Payload); err != nil { + return err + } + } + if receiptHash, ok := data["receiptHash"]; ok { + if err := json.Unmarshal(receiptHash, &r.ReceiptHash); err != nil { + return err + } + } else if receiptHash, ok := data["receipt_hash"]; ok { + if err := json.Unmarshal(receiptHash, &r.ReceiptHash); err != nil { + return err + } + } + if signature, ok := data["signature"]; ok { + if err := json.Unmarshal(signature, &r.Signature); err != nil { + return err + } + } + return nil +} + +type EventReceipt struct { + StreamID string `json:"streamId"` + StreamEventID string `json:"streamEventId"` + SeqNo int64 `json:"seqNo"` + EventHash string `json:"eventHash"` + PrevEventHash string `json:"prevEventHash,omitempty"` + StreamHeadHash string `json:"streamHeadHash"` + Receipt SignedReceipt `json:"receipt"` + LocalVaultAck M `json:"localVaultAck,omitempty"` +} + +type EventBatchResponse struct { + Accepted int `json:"accepted"` + Receipts []EventReceipt `json:"receipts"` +} + +type VerifyKind string + +const ( + VerifyReceipt VerifyKind = "receipt" + VerifyStream VerifyKind = "stream" + VerifyWindow VerifyKind = "window" + VerifyCheckpoint VerifyKind = "checkpoint" + VerifyConsistency VerifyKind = "consistency" + VerifyAnchor VerifyKind = "anchor" + VerifyIVC VerifyKind = "ivc" + VerifyBundle VerifyKind = "bundle" +) + +type ReceiptVerifyInput struct { + Receipt any `json:"receipt"` + PublicKeyHex string `json:"publicKeyHex"` + StreamEventID string `json:"streamEventId,omitempty"` +} + +type OfflineVerifyInput struct { + Kind VerifyKind `json:"kind"` + Object M `json:"object"` + PublicKeyHex string `json:"publicKeyHex,omitempty"` +} + +type VerificationCheck struct { + Name string `json:"name"` + Result string `json:"result"` + Details M `json:"details,omitempty"` +} + +type VerifyReport struct { + Kind VerifyKind `json:"kind,omitempty"` + OK bool `json:"ok"` + ReceiptHash string `json:"receiptHash,omitempty"` + EventHash string `json:"eventHash,omitempty"` + Problems []string `json:"problems"` + Protocol string `json:"protocol,omitempty"` + ProtocolVersion string `json:"protocolVersion,omitempty"` + VerifiedAt string `json:"verifiedAt,omitempty"` + Subject M `json:"subject,omitempty"` + Result string `json:"result,omitempty"` + Checks []VerificationCheck `json:"checks,omitempty"` + Evidence []M `json:"evidence,omitempty"` +} + +type WitnessPolicy struct { + PolicyID string `json:"policyId"` + TenantID string `json:"tenantId"` + Mode string `json:"mode"` + Status string `json:"status"` + Enforced bool `json:"enforced"` + ManagedOnly bool `json:"managedOnly"` + QuorumThreshold int `json:"quorumThreshold"` + WitnessKeys []string `json:"witnessKeys"` + Payload M `json:"payload"` + PolicyHash string `json:"policyHash"` +} + +type VerifierBundle struct { + BundleID string `json:"bundleId"` + BundleHash string `json:"bundleHash"` + Payload M `json:"payload"` +} + +type ConnectorCreateInput struct { + StreamID string `json:"streamId"` + Label string `json:"label"` + Metadata M `json:"metadata,omitempty"` +} + +type S3ConnectorCreateInput struct { + StreamID string `json:"streamId"` + Label string `json:"label"` + Bucket string `json:"bucket"` + AccessKeyID string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + Region string `json:"region,omitempty"` + EndpointURL string `json:"endpointUrl,omitempty"` + Provider string `json:"provider,omitempty"` + AddressingStyle string `json:"addressingStyle,omitempty"` + Metadata M `json:"metadata,omitempty"` +} + +type RepositoryConnectorCreateInput struct { + StreamID string `json:"streamId"` + Label string `json:"label"` + Provider string `json:"provider"` + RepositoryURL string `json:"repositoryUrl,omitempty"` + Metadata M `json:"metadata,omitempty"` +} + +type Connector struct { + ConnectorID string `json:"connectorId"` + StreamID string `json:"streamId"` + SystemID string `json:"systemId"` + ConnectorType string `json:"connectorType"` + Label string `json:"label"` + Status string `json:"status"` + EndpointPath string `json:"endpointPath,omitempty"` + CommitEndpointPath string `json:"commitEndpointPath,omitempty"` + Provider string `json:"provider,omitempty"` + RepositoryURL string `json:"repositoryUrl,omitempty"` + Bucket string `json:"bucket,omitempty"` + Region string `json:"region,omitempty"` + EndpointURL string `json:"endpointUrl,omitempty"` + AddressingStyle string `json:"addressingStyle,omitempty"` + AccessKeyIDMasked string `json:"accessKeyIdMasked,omitempty"` + LastEventAt string `json:"lastEventAt,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + Secret string `json:"secret,omitempty"` +} + +type LocalVaultInstallationCreateInput struct { + StreamID string `json:"streamId"` + Label string `json:"label"` + KeyID string `json:"keyId"` + PublicKeyHex string `json:"publicKeyHex"` + Metadata M `json:"metadata,omitempty"` +} + +type LocalVaultInstallation struct { + InstallationID string `json:"installationId"` + StreamID string `json:"streamId"` + SystemID string `json:"systemId"` + Label string `json:"label"` + Status string `json:"status"` + KeyID string `json:"keyId"` + PublicKeyHex string `json:"publicKeyHex"` + EndpointPath string `json:"endpointPath"` + LastEventAt string `json:"lastEventAt,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..96cbd86 --- /dev/null +++ b/version.go @@ -0,0 +1,8 @@ +package attesto + +const ( + SDKVersion = "0.2.0" + DefaultBaseURL = "https://verify.attesto.eu" + ProofstreamProtocol = "ATTESTO-PROOFSTREAM-001" + ProtocolVersionAlpha = "0.1-alpha" +)