A trimmed (~1.7 KB) copy of the cross-language parity vectors now ships inside
each package (Python package-data JSON, Go go:embed, TS generated module). On
the first hashing operation per process each SDK recomputes the commitment
hash, the receipt domain-hash, and an inclusion fold against the vendored
vectors and fails closed (AttestoSelfTestError / ErrSelfTest) on any mismatch
— a corrupted install or diverging runtime can never silently produce wrong
evidence. Result is cached (including failure); cost <5 ms once. Corrupting a
vendored vector is test-asserted to fail closed in all three languages. The
frozen canonical primitives are untouched; the gate lives in the commitment/
verify entry points built on top of them.
attesto doctor: Go CLI subcommand and Python attesto.doctor(), producing a
deterministic {"ok", "checks"} report — vendored self-test, head-store
writability, number-policy dry-run on a sample payload, Ed25519 availability
(Python), and with credentials: reachability, protocol-header acceptance, and
clock skew vs the server Date header (warn >30 s; webhooks break at 300 s).
package_artifact_policy allows exactly attesto/_selftest_vectors.json in the
wheel (verified: built wheel contains it, policy green). READMEs updated.
This completes the last Phase-1 build item.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1855 lines
53 KiB
Go
1855 lines
53 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go"
|
|
"git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go/connectorkit"
|
|
)
|
|
|
|
const cliVersion = "0.2.0"
|
|
|
|
var supportedVerifyKindNames = []string{
|
|
"receipt",
|
|
"stream",
|
|
"window",
|
|
"checkpoint",
|
|
"consistency",
|
|
"anchor",
|
|
"ivc",
|
|
"bundle",
|
|
"truth-package",
|
|
}
|
|
|
|
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.anchors(ctx, args[1:])
|
|
case "bundles":
|
|
return a.bundles(ctx, args[1:])
|
|
case "verify":
|
|
return a.verify(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 "marketplace":
|
|
return a.marketplace(ctx, args[1:])
|
|
case "doctor":
|
|
return a.doctor(ctx, args[1:])
|
|
case "readiness":
|
|
return a.readiness(args[1:])
|
|
default:
|
|
return fmt.Errorf("unknown command: %s", args[0])
|
|
}
|
|
}
|
|
|
|
func (a *app) verify(ctx context.Context, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("verify subcommand required")
|
|
}
|
|
switch args[0] {
|
|
case "truth-package":
|
|
fs := flag.NewFlagSet("verify truth-package", flag.ContinueOnError)
|
|
fs.SetOutput(a.err)
|
|
file := fs.String("file", "", "truth package ZIP file")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
if *file == "" {
|
|
return errors.New("--file is required")
|
|
}
|
|
return a.write(verifyTruthPackageZip(*file))
|
|
default:
|
|
_ = ctx
|
|
return fmt.Errorf("unknown verify subcommand: %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")
|
|
occurredAt := fs.String("occurred-at", "", "source timestamp (RFC3339, defaults to current runtime time)")
|
|
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, OccurredAt: *occurredAt, 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])
|
|
}
|
|
}
|
|
|
|
// doctor diagnoses an SDK/CLI install: vendored parity self-test, head-store
|
|
// writability, number-policy dry-run on a sample payload, and (when an API key
|
|
// is configured) reachability, protocol-header acceptance, and clock skew vs
|
|
// the server Date header. Deterministic JSON report: {"ok": bool, "checks": {...}}.
|
|
func (a *app) doctor(ctx context.Context, args []string) error {
|
|
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
|
fs.SetOutput(a.err)
|
|
samplePayload := fs.String("sample-payload", "", "JSON file with a sample payload for the number-policy dry-run")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
checks := map[string]map[string]any{}
|
|
pass := func(name string, extra map[string]any) {
|
|
if extra == nil {
|
|
extra = map[string]any{}
|
|
}
|
|
extra["ok"] = true
|
|
checks[name] = extra
|
|
}
|
|
fail := func(name string, err error) {
|
|
checks[name] = map[string]any{"ok": false, "error": err.Error()}
|
|
}
|
|
|
|
if err := attesto.EnsureSelfTest(); err != nil {
|
|
fail("self_test", err)
|
|
} else {
|
|
pass("self_test", nil)
|
|
}
|
|
|
|
headStore := attesto.NewFileHeadStore("")
|
|
headStore.Set("__doctor__", 1, strings.Repeat("0", 64))
|
|
if seq, hash, ok := headStore.Get("__doctor__"); ok && seq == 1 && hash == strings.Repeat("0", 64) {
|
|
pass("head_store", nil)
|
|
} else {
|
|
fail("head_store", errors.New("head store readback failed"))
|
|
}
|
|
|
|
if *samplePayload != "" {
|
|
raw, err := os.ReadFile(*samplePayload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
var payload any
|
|
if err := decoder.Decode(&payload); err != nil {
|
|
return err
|
|
}
|
|
if err := attesto.AssertCommitmentSafeNumbers(payload, "$"); err != nil {
|
|
fail("number_policy", err)
|
|
} else {
|
|
pass("number_policy", nil)
|
|
}
|
|
}
|
|
|
|
if client, err := a.systemClient(); err == nil {
|
|
start := time.Now()
|
|
head, headErr := client.GetStreamHead(ctx, "__doctor-probe__")
|
|
_ = head
|
|
status := 0
|
|
var apiErr *attesto.APIError
|
|
if errors.As(headErr, &apiErr) {
|
|
status = apiErr.StatusCode
|
|
}
|
|
// Any HTTP-level answer (including 404 for the probe id) proves
|
|
// reachability + auth handling; transport errors do not.
|
|
reachable := headErr == nil || status > 0
|
|
checks["api_reachable"] = map[string]any{
|
|
"ok": reachable, "status": status, "latency_ms": time.Since(start).Milliseconds(),
|
|
}
|
|
checks["protocol_accepted"] = map[string]any{"ok": status != http.StatusUpgradeRequired, "status": status}
|
|
}
|
|
|
|
ok := true
|
|
for _, check := range checks {
|
|
if v, has := check["ok"].(bool); has && !v {
|
|
ok = false
|
|
}
|
|
}
|
|
return a.write(map[string]any{"ok": ok, "checks": checks})
|
|
}
|
|
|
|
func (a *app) anchors(ctx context.Context, args []string) error {
|
|
// `anchors verify <anchor_epoch_id> --rpc-url <url>` chains an API fetch
|
|
// with the on-chain check (eth_call getCommitment + tx receipt) against a
|
|
// customer-chosen RPC endpoint. Without --rpc-url, all subcommands keep the
|
|
// existing get / remote-verify behavior.
|
|
if len(args) > 0 && args[0] == "verify" {
|
|
rest := args[1:]
|
|
positional := ""
|
|
if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
|
|
positional, rest = rest[0], rest[1:]
|
|
}
|
|
fs := flag.NewFlagSet("anchors verify", flag.ContinueOnError)
|
|
fs.SetOutput(a.err)
|
|
id := fs.String("id", positional, "anchor epoch id")
|
|
rpcURL := fs.String("rpc-url", "", "JSON-RPC endpoint for the anchor's chain")
|
|
timeoutS := fs.Int("timeout-s", 15, "RPC timeout in seconds")
|
|
file := fs.String("file", "", "proof object JSON file (remote verify mode)")
|
|
publicKeyHex := fs.String("public-key-hex", "", "Ed25519 public key hex (remote verify mode)")
|
|
if err := fs.Parse(rest); err != nil {
|
|
return err
|
|
}
|
|
if *rpcURL == "" {
|
|
fallback := []string{}
|
|
if *file != "" {
|
|
fallback = append(fallback, "--file", *file)
|
|
}
|
|
if *publicKeyHex != "" {
|
|
fallback = append(fallback, "--public-key-hex", *publicKeyHex)
|
|
}
|
|
return a.remoteVerify(ctx, fallback, attesto.VerifyAnchor)
|
|
}
|
|
if *id == "" {
|
|
return errors.New("anchor epoch id is required (positional or --id)")
|
|
}
|
|
client, err := a.systemClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
anchor, err := client.GetAnchorEpoch(ctx, *id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
report := attesto.VerifyAnchorOnchain(ctx, anchor, *rpcURL, time.Duration(*timeoutS)*time.Second)
|
|
return a.write(report)
|
|
}
|
|
return a.verifyableObject(ctx, "anchors", args, attesto.VerifyAnchor, "/v2/anchors/")
|
|
}
|
|
|
|
func (a *app) checkpoints(ctx context.Context, args []string) error {
|
|
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) marketplace(ctx context.Context, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("marketplace subcommand required")
|
|
}
|
|
switch args[0] {
|
|
case "init":
|
|
return a.marketplaceInit(args[1:])
|
|
case "validate":
|
|
return a.marketplaceValidate(args[1:])
|
|
case "submit":
|
|
return a.marketplaceSubmit(ctx, args[1:])
|
|
case "review-list":
|
|
return a.marketplaceReviewList(ctx, args[1:])
|
|
case "publish":
|
|
return a.marketplacePublish(ctx, args[1:])
|
|
default:
|
|
return fmt.Errorf("unknown marketplace subcommand: %s", args[0])
|
|
}
|
|
}
|
|
|
|
func (a *app) marketplaceInit(args []string) error {
|
|
fs := flag.NewFlagSet("marketplace init", flag.ContinueOnError)
|
|
fs.SetOutput(a.err)
|
|
output := fs.String("output", "", "manifest output path")
|
|
slug := fs.String("slug", "", "lower-kebab-case connector slug")
|
|
name := fs.String("name", "", "connector display name")
|
|
version := fs.String("version", "", "semantic version")
|
|
category := fs.String("category", "", "marketplace category")
|
|
summary := fs.String("summary", "", "short public summary")
|
|
description := fs.String("description", "", "public description")
|
|
publisherSlug := fs.String("publisher-slug", "", "publisher slug")
|
|
publisherName := fs.String("publisher-name", "", "publisher display name")
|
|
repositoryURL := fs.String("repository-url", "", "public release source URL")
|
|
docsURL := fs.String("docs-url", "", "public documentation URL")
|
|
capabilitiesRaw := fs.String("capabilities", "", "comma-separated capabilities")
|
|
providerURL := fs.String("provider-url", "", "provider website URL")
|
|
authMode := fs.String("auth-mode", "", "connector auth mode")
|
|
authScopesRaw := fs.String("auth-scopes", "", "comma-separated provider scopes")
|
|
syncModesRaw := fs.String("sync-modes", "", "comma-separated sync modes")
|
|
eventTypesRaw := fs.String("event-types", "", "comma-separated source event types")
|
|
canaryRef := fs.String("canary-ref", "", "real canary/test-account evidence reference")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *output == "" {
|
|
return errors.New("output is required")
|
|
}
|
|
authScopes := splitCSV(*authScopesRaw)
|
|
syncModes := splitCSV(*syncModesRaw)
|
|
eventTypes := splitCSV(*eventTypesRaw)
|
|
manifest := connectorkit.Manifest{
|
|
SchemaVersion: "attesto.connector.v2",
|
|
Slug: *slug,
|
|
Name: *name,
|
|
Version: *version,
|
|
AssetType: "connector",
|
|
Category: *category,
|
|
Summary: *summary,
|
|
Description: *description,
|
|
Publisher: map[string]string{
|
|
"slug": *publisherSlug,
|
|
"name": *publisherName,
|
|
},
|
|
Repository: map[string]string{"url": *repositoryURL},
|
|
Documentation: map[string]string{
|
|
"url": *docsURL,
|
|
},
|
|
Capabilities: splitCSV(*capabilitiesRaw),
|
|
Evidence: map[string]bool{
|
|
"offlineVerification": true,
|
|
"receipts": true,
|
|
"witnessCompatible": true,
|
|
},
|
|
Security: map[string]bool{
|
|
"dependencyScan": true,
|
|
"secretScan": true,
|
|
},
|
|
SupportedLanguages: []string{"en", "nl", "de", "fr", "es", "pl", "it"},
|
|
Provider: map[string]any{
|
|
"id": *slug,
|
|
"name": *name,
|
|
"websiteUrl": *providerURL,
|
|
},
|
|
Auth: map[string]any{
|
|
"mode": *authMode,
|
|
"scopes": authScopes,
|
|
},
|
|
Sync: map[string]any{
|
|
"modes": syncModes,
|
|
"supportsReplay": true,
|
|
"rateLimitPolicy": "provider-documented",
|
|
},
|
|
EventTypes: eventTypes,
|
|
SourceTime: map[string]any{
|
|
"required": true,
|
|
"timezonePolicy": "source-timestamp-with-offset-required",
|
|
},
|
|
ConfigSchema: map[string]any{"type": "object"},
|
|
SecretSchema: map[string]any{"type": "object"},
|
|
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",
|
|
},
|
|
"canary": map[string]any{
|
|
"status": "green",
|
|
"ref": *canaryRef,
|
|
},
|
|
},
|
|
InstallRequirements: map[string]any{
|
|
"tenantLoginRequired": true,
|
|
"entitlementRequired": true,
|
|
},
|
|
Changelog: []map[string]any{
|
|
{
|
|
"version": *version,
|
|
"changes": []string{"Initial production connector manifest."},
|
|
},
|
|
},
|
|
}
|
|
if len(manifest.Capabilities) == 0 {
|
|
return errors.New("at least one capability is required")
|
|
}
|
|
result := connectorkit.ValidateManifest(manifest)
|
|
if !result.OK {
|
|
return a.write(map[string]any{"ok": false, "findings": result.Findings})
|
|
}
|
|
raw, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(*output, append(raw, '\n'), 0o600); err != nil {
|
|
return err
|
|
}
|
|
return a.write(map[string]any{"ok": true, "manifest": *output, "evidenceScore": result.EvidenceScore, "tier": result.Tier})
|
|
}
|
|
|
|
func (a *app) marketplaceValidate(args []string) error {
|
|
fs := flag.NewFlagSet("marketplace validate", flag.ContinueOnError)
|
|
fs.SetOutput(a.err)
|
|
file := fs.String("manifest-file", "", "connector manifest JSON file")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
manifest, err := readConnectorManifest(*file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := connectorkit.ValidateManifest(manifest)
|
|
return a.write(map[string]any{"ok": result.OK, "evidenceScore": result.EvidenceScore, "tier": result.Tier, "findings": result.Findings})
|
|
}
|
|
|
|
func (a *app) marketplaceSubmit(ctx context.Context, args []string) error {
|
|
fs := flag.NewFlagSet("marketplace submit", flag.ContinueOnError)
|
|
fs.SetOutput(a.err)
|
|
manifestFile := fs.String("manifest-file", "", "connector manifest JSON file")
|
|
sourceRef := fs.String("source-ref", "", "real connector release source URL")
|
|
visibility := fs.String("visibility", "private", "private or public")
|
|
pricingModel := fs.String("pricing-model", "free", "free or paid")
|
|
priceCents := fs.Int("price-cents", 0, "price in cents for paid assets")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
manifest, err := readConnectorManifest(*manifestFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := connectorkit.ValidateManifest(manifest)
|
|
if !result.OK {
|
|
return fmt.Errorf("manifest validation failed: %d finding(s)", len(result.Findings))
|
|
}
|
|
rawManifest, err := manifestToMap(manifest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var price *int
|
|
if *pricingModel == "paid" {
|
|
price = priceCents
|
|
}
|
|
client, err := a.bearerClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := client.SubmitMarketplaceAsset(ctx, attesto.MarketplaceAssetSubmitInput{
|
|
Manifest: rawManifest,
|
|
SourceRef: *sourceRef,
|
|
Visibility: *visibility,
|
|
PricingModel: *pricingModel,
|
|
PriceCents: price,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.write(out)
|
|
}
|
|
|
|
func (a *app) marketplaceReviewList(ctx context.Context, args []string) error {
|
|
fs := flag.NewFlagSet("marketplace review-list", flag.ContinueOnError)
|
|
fs.SetOutput(a.err)
|
|
state := fs.String("state", "pending", "pending, published, rejected, revoked, or all")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
client, err := a.bearerClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := client.ListMarketplaceReviewAssets(ctx, *state)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.write(out)
|
|
}
|
|
|
|
func (a *app) marketplacePublish(ctx context.Context, args []string) error {
|
|
fs := flag.NewFlagSet("marketplace publish", flag.ContinueOnError)
|
|
fs.SetOutput(a.err)
|
|
slug := fs.String("slug", "", "marketplace asset slug")
|
|
reason := fs.String("reason", "", "review reason")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
client, err := a.bearerClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := client.ApproveMarketplaceAsset(ctx, *slug, *reason)
|
|
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 readConnectorManifest(path string) (connectorkit.Manifest, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return connectorkit.Manifest{}, err
|
|
}
|
|
var manifest connectorkit.Manifest
|
|
return manifest, json.Unmarshal(raw, &manifest)
|
|
}
|
|
|
|
func manifestToMap(manifest connectorkit.Manifest) (attesto.M, error) {
|
|
raw, err := json.Marshal(manifest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out attesto.M
|
|
return out, json.Unmarshal(raw, &out)
|
|
}
|
|
|
|
func splitCSV(raw string) []string {
|
|
values := []string{}
|
|
for _, part := range strings.Split(raw, ",") {
|
|
value := strings.TrimSpace(part)
|
|
if value != "" {
|
|
values = append(values, value)
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
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 verifyTruthPackageZip(path string) map[string]any {
|
|
problems := []string{}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return map[string]any{"ok": false, "problems": []string{err.Error()}}
|
|
}
|
|
packageHash := fmt.Sprintf("%x", sha256.Sum256(raw))
|
|
reader, err := zip.OpenReader(path)
|
|
if err != nil {
|
|
return map[string]any{"ok": false, "packageSha256": packageHash, "problems": []string{err.Error()}}
|
|
}
|
|
defer reader.Close()
|
|
|
|
entries := map[string]*zip.File{}
|
|
for _, item := range reader.File {
|
|
name := item.Name
|
|
if truthPackageForbiddenZipPath(name) {
|
|
problems = append(problems, "forbidden ZIP entry: "+name)
|
|
continue
|
|
}
|
|
entries[name] = item
|
|
}
|
|
manifestFile := entries["attesto.truth-package.manifest.json"]
|
|
if manifestFile == nil {
|
|
problems = append(problems, "attesto.truth-package.manifest.json missing")
|
|
return map[string]any{"ok": false, "packageSha256": packageHash, "problems": problems}
|
|
}
|
|
manifestBytes, err := readZipFile(manifestFile)
|
|
if err != nil {
|
|
problems = append(problems, "truth package manifest read failed: "+err.Error())
|
|
return map[string]any{"ok": false, "packageSha256": packageHash, "problems": problems}
|
|
}
|
|
manifestHash := fmt.Sprintf("%x", sha256.Sum256(manifestBytes))
|
|
var manifest map[string]any
|
|
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
|
|
problems = append(problems, "truth package manifest is not JSON: "+err.Error())
|
|
return map[string]any{"ok": false, "packageSha256": packageHash, "manifestSha256": manifestHash, "problems": problems}
|
|
}
|
|
if stringField(manifest, "schema") != "attesto.truth-package.manifest.v1" {
|
|
problems = append(problems, "truth package manifest schema mismatch")
|
|
}
|
|
verifiedArtifacts := 0
|
|
allowedEntries := map[string]bool{
|
|
"attesto.truth-package.manifest.json": true,
|
|
"attesto.sig.json": true,
|
|
}
|
|
if artifacts, ok := manifest["artifacts"].([]any); ok {
|
|
for _, artifact := range artifacts {
|
|
row, ok := artifact.(map[string]any)
|
|
if !ok {
|
|
problems = append(problems, "artifact entry is not an object")
|
|
continue
|
|
}
|
|
artifactPath := stringField(row, "path")
|
|
expectedHash := stringField(row, "sha256")
|
|
if artifactPath == "" || expectedHash == "" {
|
|
problems = append(problems, "artifact path or sha256 missing")
|
|
continue
|
|
}
|
|
allowedEntries[artifactPath] = true
|
|
if truthPackageForbiddenZipPath(artifactPath) {
|
|
problems = append(problems, "forbidden artifact path: "+artifactPath)
|
|
continue
|
|
}
|
|
entry := entries[artifactPath]
|
|
if entry == nil {
|
|
problems = append(problems, "artifact missing from ZIP: "+artifactPath)
|
|
continue
|
|
}
|
|
data, err := readZipFile(entry)
|
|
if err != nil {
|
|
problems = append(problems, "artifact read failed: "+artifactPath)
|
|
continue
|
|
}
|
|
computed := fmt.Sprintf("%x", sha256.Sum256(data))
|
|
if computed != expectedHash {
|
|
problems = append(problems, "artifact sha256 mismatch: "+artifactPath)
|
|
continue
|
|
}
|
|
verifiedArtifacts++
|
|
}
|
|
} else {
|
|
problems = append(problems, "truth package artifacts list missing")
|
|
}
|
|
for entryName := range entries {
|
|
if !allowedEntries[entryName] {
|
|
problems = append(problems, "unlisted ZIP entry: "+entryName)
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"ok": len(problems) == 0,
|
|
"packageSha256": packageHash,
|
|
"manifestSha256": manifestHash,
|
|
"verifiedArtifacts": verifiedArtifacts,
|
|
"problems": problems,
|
|
}
|
|
}
|
|
|
|
func readZipFile(file *zip.File) ([]byte, error) {
|
|
reader, err := file.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer reader.Close()
|
|
return io.ReadAll(reader)
|
|
}
|
|
|
|
func truthPackageForbiddenZipPath(path string) bool {
|
|
clean := filepath.Clean(path)
|
|
if path == "" ||
|
|
strings.HasPrefix(path, "/") ||
|
|
strings.Contains(path, "\\") ||
|
|
strings.Contains(path, ":") ||
|
|
strings.HasPrefix(path, "__MACOSX/") ||
|
|
strings.Contains(path, "/__MACOSX/") ||
|
|
strings.HasSuffix(path, ".DS_Store") ||
|
|
strings.HasPrefix(filepath.Base(path), "._") ||
|
|
strings.Contains(path, "sftp://") ||
|
|
strings.Contains(path, "/home/") ||
|
|
clean == "." ||
|
|
strings.HasPrefix(clean, "../") ||
|
|
clean == ".." {
|
|
return true
|
|
}
|
|
for _, segment := range strings.Split(path, "/") {
|
|
if segment == ".." {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|