Add truth package verifier evidence gate
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
@@ -29,6 +31,7 @@ var supportedVerifyKindNames = []string{
|
|||||||
"anchor",
|
"anchor",
|
||||||
"ivc",
|
"ivc",
|
||||||
"bundle",
|
"bundle",
|
||||||
|
"truth-package",
|
||||||
}
|
}
|
||||||
|
|
||||||
type cliConfig struct {
|
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/")
|
return a.verifyableObject(ctx, "anchors", args[1:], attesto.VerifyAnchor, "/v2/anchors/")
|
||||||
case "bundles":
|
case "bundles":
|
||||||
return a.bundles(ctx, args[1:])
|
return a.bundles(ctx, args[1:])
|
||||||
|
case "verify":
|
||||||
|
return a.verify(ctx, args[1:])
|
||||||
case "fork-evidence":
|
case "fork-evidence":
|
||||||
return a.forkEvidence(ctx, args[1:])
|
return a.forkEvidence(ctx, args[1:])
|
||||||
case "quorum":
|
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 {
|
func (a *app) config(args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("config subcommand required")
|
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")
|
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 {
|
func verifyDomainObject(domainKey string, obj attesto.M, payloadKey, camelHash, snakeHash string) map[string]any {
|
||||||
payload := objectField(obj, payloadKey)
|
payload := objectField(obj, payloadKey)
|
||||||
hash := stringField(obj, camelHash, snakeHash)
|
hash := stringField(obj, camelHash, snakeHash)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"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) {
|
func TestConfigSetRedactsSecrets(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
config := filepath.Join(dir, "config.json")
|
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 {
|
func testEnv(t *testing.T, values map[string]string) func(string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return func(key string) string {
|
return func(key string) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user