diff --git a/cmd/attesto/main.go b/cmd/attesto/main.go index f0cbdba..b5d0ae9 100644 --- a/cmd/attesto/main.go +++ b/cmd/attesto/main.go @@ -170,6 +170,30 @@ func (a *app) verify(ctx context.Context, args []string) error { return errors.New("--file is required") } return a.write(verifyTruthPackageZip(*file)) + case "file": + // [P3.4] Verify a portable *.attesto.json receipt export offline. + fs := flag.NewFlagSet("verify file", flag.ContinueOnError) + fs.SetOutput(a.err) + file := fs.String("file", "", "portable receipt export (*.attesto.json)") + publicKeyHex := fs.String("public-key-hex", "", "pinned witness key (omitting it verifies against the file's embedded hint)") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if *file == "" { + return errors.New("--file is required") + } + raw, err := os.ReadFile(*file) + if err != nil { + return err + } + report := attesto.VerifyReceiptExport(raw, *publicKeyHex) + if err := a.write(report); err != nil { + return err + } + if !report.OK { + return errors.New("verification failed") + } + return nil default: _ = ctx return fmt.Errorf("unknown verify subcommand: %s", args[0]) diff --git a/export.go b/export.go new file mode 100644 index 0000000..6dde371 --- /dev/null +++ b/export.go @@ -0,0 +1,161 @@ +package attesto + +// [P3.4] Portable receipt export — a self-contained `*.attesto.json`. +// Mirrors attesto.export (Python) and export.ts (TypeScript); the +// receipt-export.json parity corpus is normative for all three. + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +const ( + ExportFormat = "attesto-receipt-export" + ExportFormatVersion = 1 +) + +// ReceiptExport is the portable envelope. Receipt is kept as raw JSON so the +// export carries the receipt verbatim, exactly as the API returned it. +type ReceiptExport struct { + Format string `json:"format"` + FormatVersion int `json:"format_version"` + ExportedAt string `json:"exported_at"` + StreamID any `json:"stream_id,omitempty"` + SeqNo any `json:"seq_no,omitempty"` + EventHash any `json:"event_hash,omitempty"` + WitnessPublicKeyHex string `json:"witness_public_key_hex,omitempty"` + Receipt json.RawMessage `json:"receipt"` + Payload M `json:"payload,omitempty"` + PayloadCommitment M `json:"payload_commitment,omitempty"` +} + +func exportPick(source M, keys ...string) any { + for _, key := range keys { + if value, ok := source[key]; ok { + return value + } + } + return nil +} + +// ExportReceiptFile builds a portable export from a receipt's raw JSON (as +// returned by the API) and optionally writes it to path (empty = no write). +func ExportReceiptFile(receiptJSON []byte, path string, witnessPublicKeyHex string) (ReceiptExport, error) { + var parsed struct { + Payload M `json:"payload"` + } + if err := json.Unmarshal(receiptJSON, &parsed); err != nil { + return ReceiptExport{}, fmt.Errorf("receipt is not valid JSON: %w", err) + } + export := ReceiptExport{ + Format: ExportFormat, + FormatVersion: ExportFormatVersion, + ExportedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + StreamID: exportPick(parsed.Payload, "stream_id", "streamId"), + SeqNo: exportPick(parsed.Payload, "seq_no", "seqNo"), + EventHash: exportPick(parsed.Payload, "event_hash", "eventHash"), + WitnessPublicKeyHex: witnessPublicKeyHex, + Receipt: json.RawMessage(receiptJSON), + } + if path != "" { + raw, err := json.MarshalIndent(export, "", " ") + if err != nil { + return ReceiptExport{}, err + } + if err := os.WriteFile(path, append(raw, '\n'), 0o644); err != nil { + return ReceiptExport{}, err + } + } + return export, nil +} + +// VerifyReceiptExport verifies a portable export offline. publicKeyHex == "" +// falls back to the embedded hint (self-contained mode — proves internal +// consistency against the key the file itself names). +func VerifyReceiptExport(exportJSON []byte, publicKeyHex string) VerifyReport { + var export struct { + Format string `json:"format"` + FormatVersion int `json:"format_version"` + StreamID any `json:"stream_id"` + SeqNo any `json:"seq_no"` + EventHash any `json:"event_hash"` + WitnessPublicKeyHex string `json:"witness_public_key_hex"` + Receipt json.RawMessage `json:"receipt"` + Payload M `json:"payload"` + PayloadCommitment M `json:"payload_commitment"` + } + kind := VerifyKind("receipt-export") + if publicKeyHex == "" { + kind = "receipt-export-selfcontained" + } + fail := func(problems ...string) VerifyReport { + return VerifyReport{Kind: kind, OK: false, Problems: problems} + } + if err := json.Unmarshal(exportJSON, &export); err != nil { + return fail("export is not valid JSON: " + err.Error()) + } + var problems []string + if export.Format != ExportFormat { + problems = append(problems, "not an attesto receipt export (format field)") + } + if export.FormatVersion != ExportFormatVersion { + problems = append(problems, fmt.Sprintf("unsupported export format_version: %d", export.FormatVersion)) + } + if len(export.Receipt) == 0 { + problems = append(problems, "export carries no receipt object") + } + if len(problems) > 0 { + return fail(problems...) + } + + key := publicKeyHex + if key == "" { + key = export.WitnessPublicKeyHex + } + if key == "" { + return fail("no public key supplied and no embedded hint") + } + var receipt SignedReceipt + if err := json.Unmarshal(export.Receipt, &receipt); err != nil { + return fail("receipt is not valid JSON: " + err.Error()) + } + report := VerifyReceiptOffline(receipt, key) + problems = append(problems, report.Problems...) + + linkage := []struct { + name string + outer any + keys []string + }{ + {"stream_id", export.StreamID, []string{"stream_id", "streamId"}}, + {"seq_no", export.SeqNo, []string{"seq_no", "seqNo"}}, + {"event_hash", export.EventHash, []string{"event_hash", "eventHash"}}, + } + for _, link := range linkage { + inner := exportPick(receipt.Payload, link.keys...) + if link.outer == nil || inner == nil { + continue + } + // JSON numbers decode as float64 on both sides, so == is sound here. + if fmt.Sprint(link.outer) != fmt.Sprint(inner) { + problems = append(problems, "export linkage mismatch: "+link.name) + } + } + + if export.Payload != nil && export.PayloadCommitment != nil { + ok, err := VerifyPayloadCommitment(export.Payload, M{"payload_commitment": export.PayloadCommitment}) + if err != nil || !ok { + problems = append(problems, "embedded payload does not match payload_commitment") + } + } + + return VerifyReport{ + Kind: kind, + OK: len(problems) == 0, + ReceiptHash: report.ReceiptHash, + EventHash: report.EventHash, + Problems: problems, + } +} diff --git a/export_parity_test.go b/export_parity_test.go new file mode 100644 index 0000000..60db223 --- /dev/null +++ b/export_parity_test.go @@ -0,0 +1,77 @@ +package attesto + +// [P3.4] Receipt-export parity corpus — Go verifier. + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestReceiptExportParity(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "golden-vectors", "sdk-parity", "receipt-export.json")) + if err != nil { + t.Fatalf("read corpus: %v", err) + } + var corpus struct { + Cases []struct { + ID string `json:"id"` + ExpectOK bool `json:"expect_ok"` + PublicKeyHex *string `json:"public_key_hex"` + Export json.RawMessage `json:"export"` + } `json:"cases"` + } + if err := json.Unmarshal(raw, &corpus); err != nil { + t.Fatalf("parse corpus: %v", err) + } + if len(corpus.Cases) < 5 { + t.Fatalf("expected >=5 cases, got %d", len(corpus.Cases)) + } + for _, testCase := range corpus.Cases { + t.Run(testCase.ID, func(t *testing.T) { + key := "" + if testCase.PublicKeyHex != nil { + key = *testCase.PublicKeyHex + } + report := VerifyReceiptExport(testCase.Export, key) + if report.OK != testCase.ExpectOK { + t.Fatalf("ok=%v want %v (problems: %v)", report.OK, testCase.ExpectOK, report.Problems) + } + if testCase.PublicKeyHex == nil && testCase.ExpectOK && report.Kind != "receipt-export-selfcontained" { + t.Fatalf("kind=%q, want receipt-export-selfcontained", report.Kind) + } + }) + } +} + +func TestExportReceiptFileRoundTrip(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "golden-vectors", "sdk-parity", "receipt-export.json")) + if err != nil { + t.Fatalf("read corpus: %v", err) + } + var corpus struct { + Cases []struct { + PublicKeyHex *string `json:"public_key_hex"` + Export struct { + Receipt json.RawMessage `json:"receipt"` + } `json:"export"` + } `json:"cases"` + } + if err := json.Unmarshal(raw, &corpus); err != nil { + t.Fatalf("parse corpus: %v", err) + } + valid := corpus.Cases[0] + path := filepath.Join(t.TempDir(), "receipt.attesto.json") + if _, err := ExportReceiptFile(valid.Export.Receipt, path, *valid.PublicKeyHex); err != nil { + t.Fatalf("export: %v", err) + } + exported, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read export: %v", err) + } + report := VerifyReceiptExport(exported, *valid.PublicKeyHex) + if !report.OK { + t.Fatalf("round-trip verify failed: %v", report.Problems) + } +}