From d6448af1ec1d9e4eb15196220eded69d9730264c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 8 Jun 2026 23:45:07 +0200 Subject: [PATCH] Add truth package verifier evidence gate --- cmd/attesto/main.go | 159 +++++++++++++++++++++++++++++++++++++++ cmd/attesto/main_test.go | 141 ++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) diff --git a/cmd/attesto/main.go b/cmd/attesto/main.go index 44ee450..caf0e2f 100644 --- a/cmd/attesto/main.go +++ b/cmd/attesto/main.go @@ -1,8 +1,10 @@ package main import ( + "archive/zip" "bufio" "context" + "crypto/sha256" "encoding/json" "errors" "flag" @@ -29,6 +31,7 @@ var supportedVerifyKindNames = []string{ "anchor", "ivc", "bundle", + "truth-package", } type cliConfig struct { @@ -124,6 +127,8 @@ func (a *app) dispatch(ctx context.Context, args []string) error { return a.verifyableObject(ctx, "anchors", args[1:], attesto.VerifyAnchor, "/v2/anchors/") 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": @@ -143,6 +148,28 @@ func (a *app) dispatch(ctx context.Context, args []string) error { } } +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") @@ -1315,6 +1342,138 @@ 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) diff --git a/cmd/attesto/main_test.go b/cmd/attesto/main_test.go index e81e9ba..c2008ec 100644 --- a/cmd/attesto/main_test.go +++ b/cmd/attesto/main_test.go @@ -1,8 +1,11 @@ package main import ( + "archive/zip" "bytes" + "crypto/sha256" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -51,6 +54,85 @@ func TestReceiptsVerifyOfflineGoldenVector(t *testing.T) { } } +func TestVerifyTruthPackageZip(t *testing.T) { + dir := t.TempDir() + zipFile := filepath.Join(dir, "truth-package.zip") + writeTruthPackageZip(t, zipFile, false) + + var stdout, stderr bytes.Buffer + code := run([]string{"--json", "verify", "truth-package", "--file", zipFile}, &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("truth package did not verify: %s", stdout.String()) + } + if out["packageSha256"] == "" || out["manifestSha256"] == "" { + t.Fatalf("missing hashes: %s", stdout.String()) + } +} + +func TestVerifyTruthPackageZipRejectsTamperedArtifact(t *testing.T) { + dir := t.TempDir() + zipFile := filepath.Join(dir, "truth-package-tampered.zip") + writeTruthPackageZip(t, zipFile, true) + + var stdout, stderr bytes.Buffer + code := run([]string{"--json", "verify", "truth-package", "--file", zipFile}, &stdout, &stderr, testEnv(t, nil)) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"ok": false`) || !strings.Contains(stdout.String(), "artifact sha256 mismatch") { + t.Fatalf("expected artifact mismatch: %s", stdout.String()) + } +} + +func TestVerifyTruthPackageZipRejectsForbiddenPath(t *testing.T) { + dir := t.TempDir() + zipFile := filepath.Join(dir, "truth-package-forbidden.zip") + out, err := os.Create(zipFile) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(out) + writeZipEntry(t, archive, "../evil.txt", []byte("x")) + writeZipEntry(t, archive, "attesto.truth-package.manifest.json", []byte(`{"schema":"attesto.truth-package.manifest.v1","artifacts":[]}`)) + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := out.Close(); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := run([]string{"--json", "verify", "truth-package", "--file", zipFile}, &stdout, &stderr, testEnv(t, nil)) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"ok": false`) || !strings.Contains(stdout.String(), "forbidden ZIP entry") { + t.Fatalf("expected forbidden ZIP entry rejection: %s", stdout.String()) + } +} + +func TestVerifyTruthPackageZipRejectsUnlistedEntry(t *testing.T) { + dir := t.TempDir() + zipFile := filepath.Join(dir, "truth-package-unlisted.zip") + writeTruthPackageZip(t, zipFile, false, "extra.txt") + + var stdout, stderr bytes.Buffer + code := run([]string{"--json", "verify", "truth-package", "--file", zipFile}, &stdout, &stderr, testEnv(t, nil)) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"ok": false`) || !strings.Contains(stdout.String(), "unlisted ZIP entry") { + t.Fatalf("expected unlisted ZIP entry rejection: %s", stdout.String()) + } +} + func TestConfigSetRedactsSecrets(t *testing.T) { dir := t.TempDir() config := filepath.Join(dir, "config.json") @@ -268,6 +350,65 @@ func writeMarketplaceManifest(t *testing.T, path string) { } } +func writeTruthPackageZip(t *testing.T, path string, tamper bool, extraEntries ...string) { + t.Helper() + events := []byte("id,type\nsev_1,inference\n") + proofs := []byte("{}\n") + manifest := []byte(`{"export":{"id":"exp_test"}}` + "\n") + artifacts := []map[string]any{ + {"path": "events.csv", "sha256": sha256Hex(events), "media_type": "text/csv", "size_bytes": len(events)}, + {"path": "proofs.json", "sha256": sha256Hex(proofs), "media_type": "application/json", "size_bytes": len(proofs)}, + {"path": "manifest.json", "sha256": sha256Hex(manifest), "media_type": "application/json", "size_bytes": len(manifest)}, + } + truthManifestRaw, err := json.MarshalIndent(map[string]any{ + "schema": "attesto.truth-package.manifest.v1", + "package_id": "tp_test", + "export_id": "exp_test", + "tenant_id": "tnt_test", + "package_version": "attesto.truth-package.v1", + "created_at": "2026-06-08T22:20:00Z", + "artifacts": artifacts, + }, "", " ") + if err != nil { + t.Fatal(err) + } + out, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer out.Close() + archive := zip.NewWriter(out) + writeZipEntry(t, archive, "events.csv", events) + if tamper { + writeZipEntry(t, archive, "proofs.json", []byte(`{"tampered":true}`+"\n")) + } else { + writeZipEntry(t, archive, "proofs.json", proofs) + } + writeZipEntry(t, archive, "manifest.json", manifest) + writeZipEntry(t, archive, "attesto.truth-package.manifest.json", append(truthManifestRaw, '\n')) + for _, extraEntry := range extraEntries { + writeZipEntry(t, archive, extraEntry, []byte("not listed\n")) + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } +} + +func writeZipEntry(t *testing.T, archive *zip.Writer, name string, data []byte) { + t.Helper() + writer, err := archive.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := writer.Write(data); err != nil { + t.Fatal(err) + } +} + +func sha256Hex(data []byte) string { + return fmt.Sprintf("%x", sha256.Sum256(data)) +} + func testEnv(t *testing.T, values map[string]string) func(string) string { t.Helper() return func(key string) string {