From 713faf2ab02052fe2dac78ebd01d8f0acee6770f Mon Sep 17 00:00:00 2001 From: TJF Date: Sun, 9 Aug 2026 19:15:03 -0400 Subject: [PATCH] feat(tracing): sign exported spans as verifiable receipts (#1505) Implements #688. Adds a SpanExporter that signs exported spans as Ed25519 receipts, chains them, and anchors the sequence with a signed checkpoint, so a third party can verify what a run did without trusting the collector, the database, or the operator. Standard library crypto only. No new dependencies, and no existing file is modified: it attaches through SetExporter the same way OTLP does. Design notes worth reviewing: - Coverage counts spans this exporter received, never spans the runtime executed. Collector.EmitSpan drops spans when its buffer fills, which is correct for observability and means an exporter can only sign what reaches it. Every checkpoint carries a note saying so, so a receipt set reads as a floor on what happened rather than a complete record. - The checkpoint is not optional. A hash chain cannot detect truncation: removing the newest receipts leaves every remaining link valid. Verification reports Valid=false when no checkpoint is supplied, because without one it can only say nothing was altered, never that nothing is missing. - A checkpoint stored beside its receipts is deletable by whoever deletes the receipts, so Sink keeps the two destinations separate to make publishing the anchor elsewhere straightforward. - Content is committed by digest rather than copied, since the package redacts previews and a second store of that content would widen a leak. - Ordering comes from an exporter sequence rather than a clock. - Revocation is not retroactive; RevokedAt covers the compromise case. - Signature and chain failures are reported separately, and a failed sink write does not advance the chain head. 26 tests, most adversarial: tampering, forged signatures under a claimed kid, cross-domain signature replay, validity windows, revocation ordering, middle deletion, reordering, end truncation, empty bundles, sink failure, and concurrent export. go vet and go test -race clean. Co-authored-by: tommylauren --- internal/tracing/receiptexport/README.md | 124 ++++ internal/tracing/receiptexport/exporter.go | 235 ++++++++ .../tracing/receiptexport/exporter_test.go | 339 +++++++++++ internal/tracing/receiptexport/filesink.go | 135 +++++ internal/tracing/receiptexport/receipt.go | 570 ++++++++++++++++++ .../tracing/receiptexport/receipt_test.go | 371 ++++++++++++ internal/tracing/receiptexport/verify.go | 164 +++++ 7 files changed, 1938 insertions(+) create mode 100644 internal/tracing/receiptexport/README.md create mode 100644 internal/tracing/receiptexport/exporter.go create mode 100644 internal/tracing/receiptexport/exporter_test.go create mode 100644 internal/tracing/receiptexport/filesink.go create mode 100644 internal/tracing/receiptexport/receipt.go create mode 100644 internal/tracing/receiptexport/receipt_test.go create mode 100644 internal/tracing/receiptexport/verify.go diff --git a/internal/tracing/receiptexport/README.md b/internal/tracing/receiptexport/README.md new file mode 100644 index 00000000..54a270b2 --- /dev/null +++ b/internal/tracing/receiptexport/README.md @@ -0,0 +1,124 @@ +# receiptexport + +Signs exported spans as Ed25519 receipts so a third party can verify what a run +did without trusting the collector, the database, or the operator. + +Implements `tracing.SpanExporter`, so it attaches the same way OTLP does and +changes nothing in the collector. + +## Why this is not just a trace + +A trace says what the runtime emitted. A receipt says what a named key attested +to, in what order, and whether that record has been altered since. The +difference matters the moment a run crosses an organisational boundary, or +someone has to explain a quiet failure after the fact. + +## Usage + +```go +key, err := receiptexport.GenerateSigningKey() +if err != nil { + return err +} + +sink, err := receiptexport.NewFileSink( + "/var/lib/goclaw/receipts.jsonl", + "/var/lib/goclaw/checkpoint.json", +) +if err != nil { + return err +} + +exporter, err := receiptexport.New(receiptexport.Config{ + Key: key, + Sink: sink, + SpanTypes: []string{"tool_call"}, // omit to sign everything + CheckpointEvery: 100, +}) +if err != nil { + return err +} + +collector.SetExporter(exporter) +``` + +Publish only the public half for verifiers: + +```go +keyring := receiptexport.NewKeyring(key.PublicEntry()) +``` + +Verify with nothing but the evidence and that keyring: + +```go +receipts, checkpoint, err := receiptexport.LoadBundle(receiptPath, checkpointPath) +report, err := receiptexport.VerifyReceipts(receipts, checkpoint, keyring) +``` + +## The checkpoint is not optional + +A hash chain detects modification, reordering, and deletion from the middle. It +cannot detect truncation: remove the newest receipts and every remaining link +still verifies clean. + +The checkpoint closes that by committing to a receipt count and a terminal +hash. `VerifyReceipts` reports `Valid: false` when no checkpoint is supplied, +because without one it can only say "nothing I was given has been altered", +never "nothing is missing". + +**A checkpoint stored beside the receipts it anchors is deletable by whoever +deletes the receipts.** To be worth anything it has to reach somewhere the +operator cannot quietly rewrite: a build log, an object store with retention, or +the commit the run produced. `Sink` keeps receipt and checkpoint destinations +separate so that publishing step is easy to add. + +## Coverage, and what it does not claim + +`Collector.EmitSpan` drops spans when its buffer is full. That is correct for +observability, since tracing must never block a run, and it means an exporter +can only sign what it receives. + +So `Coverage` counts spans **this exporter saw**, never spans the runtime +executed, and it carries a note saying so into every checkpoint. A signed +receipt set is a floor on what happened, not a complete record. + +Quantifying the gap would need a drop counter in the collector, which this +package deliberately does not add. Happy to follow up with one if it is wanted. + +## What a verified receipt establishes + +> This span, with exactly these fields, was exported by the holder of this key +> at this time, and the verified sequence has not been altered since. + +## What it does not establish + +Returned in every verification report rather than left to a reader to infer: + +- Not that these are all the spans the runtime produced. +- Not that a tool call was correct, authorised, or safe. +- Not that receipts signed by a compromised key were truthful. +- Without a checkpoint, not that receipts were not removed from the end. + +## Design notes + +**Content is committed by digest, not copied.** The tracing package redacts +previews for a reason. Duplicating that content into a second, separately +stored artifact would widen the blast radius of a leak while adding nothing a +digest does not already prove. + +**Ordering comes from an exporter sequence, not a clock.** Ordering evidence by +a timestamp invites an older stamp recorded later to make honest history look +altered. + +**Revocation is not retroactive.** A receipt signed while the key was trusted +stays valid, because the alternative silently voids honest history every time +an operator rotates. `RevokedAt` exists for the compromise case. + +**Signature and chain failures are reported separately.** They are different +failures calling for different responses, and a deleted receipt leaves every +remaining signature genuine. + +**A failed write does not advance the chain head**, so a transient sink outage +cannot produce a permanent break that looks identical to tampering. + +**Standard library only**, plus `google/uuid` which the repository already uses. diff --git a/internal/tracing/receiptexport/exporter.go b/internal/tracing/receiptexport/exporter.go new file mode 100644 index 00000000..b03b22fe --- /dev/null +++ b/internal/tracing/receiptexport/exporter.go @@ -0,0 +1,235 @@ +package receiptexport + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// Sink receives signed receipts and checkpoints. Implementations decide where +// evidence lands: a file, an object store, or a commit trailer. +// +// The sink is separated from the exporter because where the checkpoint goes is +// what determines whether any of this is worth having. A checkpoint written +// beside the database is deletable by the same person who deletes the +// receipts, so the anchor only means something once it reaches somewhere the +// operator cannot quietly rewrite. +type Sink interface { + WriteReceipt(ctx context.Context, receipt SpanReceipt) error + WriteCheckpoint(ctx context.Context, checkpoint Checkpoint) error +} + +// Config configures the receipt exporter. +type Config struct { + // Key signs receipts and checkpoints. Required. + Key *SigningKey + // Sink receives the output. Required. + Sink Sink + // CheckpointEvery emits an interim checkpoint after this many receipts. + // Zero means only emit one at Shutdown, which leaves everything after the + // last checkpoint truncatable if the process is killed. + CheckpointEvery uint64 + // SpanTypes filters which spans are signed. Empty signs everything. + // Signing only "tool_call" is the common case and keeps the evidence set + // small enough to review by hand. + SpanTypes []string + // Logger defaults to slog.Default(). + Logger *slog.Logger +} + +// Exporter signs exported spans and implements tracing.SpanExporter. +// +// It never blocks the collector and never panics into it. The SpanExporter +// contract has ExportSpans return nothing, so an exporter cannot report a +// failure upward; the only honest options are to log, count, and keep the +// counts in the checkpoint so a verifier sees that something went wrong. +type Exporter struct { + key *SigningKey + sink Sink + checkpointEvery uint64 + spanTypes map[string]struct{} + log *slog.Logger + + mu sync.Mutex + sequence uint64 + previousHash string + coverage Coverage + sinceLast uint64 + closed bool +} + +// New builds a receipt exporter. +func New(cfg Config) (*Exporter, error) { + if cfg.Key == nil { + return nil, fmt.Errorf("signing key is required") + } + if cfg.Sink == nil { + return nil, fmt.Errorf("sink is required") + } + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + var types map[string]struct{} + if len(cfg.SpanTypes) > 0 { + types = make(map[string]struct{}, len(cfg.SpanTypes)) + for _, t := range cfg.SpanTypes { + types[t] = struct{}{} + } + } + return &Exporter{ + key: cfg.Key, + sink: cfg.Sink, + checkpointEvery: cfg.CheckpointEvery, + spanTypes: types, + log: logger, + coverage: Coverage{ + Note: "Counts spans this exporter received. Spans dropped by the collector under buffer pressure never reach an exporter and are not counted here.", + }, + }, nil +} + +// ExportSpans signs and emits receipts. It implements tracing.SpanExporter. +// +// Returns nothing by interface contract, so every failure is logged and +// counted rather than propagated. A signing failure must never take down a +// run that was otherwise fine. +func (e *Exporter) ExportSpans(ctx context.Context, spans []store.SpanData) { + if e == nil || len(spans) == 0 { + return + } + + e.mu.Lock() + defer e.mu.Unlock() + + if e.closed { + e.log.Warn("receiptexport: spans arrived after shutdown, not signed", "count", len(spans)) + return + } + + for _, span := range spans { + if !e.wants(span) { + continue + } + e.coverage.SpansReceived++ + + receipt, hash, err := e.signLocked(span) + if err != nil { + e.coverage.SpansFailed++ + e.log.Error("receiptexport: could not sign span", + "span_id", span.ID, "span_type", span.SpanType, "error", err) + continue + } + if err := e.sink.WriteReceipt(ctx, receipt); err != nil { + e.coverage.SpansFailed++ + e.log.Error("receiptexport: could not write receipt", + "span_id", span.ID, "error", err) + continue + } + + // Advance only once the receipt has actually landed. Advancing before + // the write would leave the next receipt chaining to a hash nobody + // holds, turning a transient sink error into a permanent break that + // looks identical to tampering. + e.previousHash = hash + e.sequence++ + e.coverage.SpansSigned++ + e.sinceLast++ + } + + if e.checkpointEvery > 0 && e.sinceLast >= e.checkpointEvery { + if err := e.checkpointLocked(ctx); err != nil { + e.log.Error("receiptexport: could not write interim checkpoint", "error", err) + } + } +} + +// signLocked builds and signs a receipt against the current chain head and +// returns it with its hash. Caller must hold the mutex. +// +// Deliberately free of side effects: it does not advance the chain head or the +// sequence. The caller does that only after the receipt has been durably +// written, so a failed write leaves the next receipt chaining to the last one +// that actually landed. +func (e *Exporter) signLocked(span store.SpanData) (SpanReceipt, string, error) { + payload, err := PayloadFromSpan(span, e.sequence, e.previousHash, time.Now().UTC()) + if err != nil { + return SpanReceipt{}, "", err + } + receipt, err := SignReceipt(payload, e.key) + if err != nil { + return SpanReceipt{}, "", err + } + hash, err := ReceiptHash(receipt) + if err != nil { + return SpanReceipt{}, "", err + } + return receipt, hash, nil +} + +func (e *Exporter) wants(span store.SpanData) bool { + if e.spanTypes == nil { + return true + } + _, ok := e.spanTypes[span.SpanType] + return ok +} + +// Checkpoint emits a completeness anchor immediately. +// +// Publish the result somewhere the operator cannot rewrite. A checkpoint +// sitting beside the receipts it anchors proves very little. +func (e *Exporter) Checkpoint(ctx context.Context) error { + e.mu.Lock() + defer e.mu.Unlock() + return e.checkpointLocked(ctx) +} + +func (e *Exporter) checkpointLocked(ctx context.Context) error { + payload := CheckpointPayload{ + Type: CheckpointType, + Version: 1, + Sequence: e.sequence, + ReceiptCount: e.coverage.SpansSigned, + TerminalReceiptHash: e.previousHash, + Coverage: e.coverage, + SignedAt: time.Now().UTC().Format(time.RFC3339Nano), + } + checkpoint, err := SignCheckpoint(payload, e.key) + if err != nil { + return fmt.Errorf("sign checkpoint: %w", err) + } + if err := e.sink.WriteCheckpoint(ctx, checkpoint); err != nil { + return fmt.Errorf("write checkpoint: %w", err) + } + e.sinceLast = 0 + return nil +} + +// Shutdown emits a final checkpoint. It implements tracing.SpanExporter. +// +// This is the anchor that makes truncation detectable for the whole run, so a +// failure here is returned rather than swallowed. +func (e *Exporter) Shutdown(ctx context.Context) error { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return nil + } + e.closed = true + if err := e.checkpointLocked(ctx); err != nil { + return fmt.Errorf("final checkpoint: %w", err) + } + return nil +} + +// Coverage reports what this exporter has seen so far. +func (e *Exporter) Coverage() Coverage { + e.mu.Lock() + defer e.mu.Unlock() + return e.coverage +} diff --git a/internal/tracing/receiptexport/exporter_test.go b/internal/tracing/receiptexport/exporter_test.go new file mode 100644 index 00000000..4bcecd93 --- /dev/null +++ b/internal/tracing/receiptexport/exporter_test.go @@ -0,0 +1,339 @@ +package receiptexport + +import ( + "context" + "fmt" + "io" + "log/slog" + "sync" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// memSink captures output and can be told to fail, so tests can prove the +// exporter survives a sink that is down without taking the run with it. +type memSink struct { + mu sync.Mutex + receipts []SpanReceipt + checkpoints []Checkpoint + failWrites bool +} + +func (s *memSink) WriteReceipt(_ context.Context, r SpanReceipt) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.failWrites { + return fmt.Errorf("sink unavailable") + } + s.receipts = append(s.receipts, r) + return nil +} + +func (s *memSink) WriteCheckpoint(_ context.Context, c Checkpoint) error { + s.mu.Lock() + defer s.mu.Unlock() + s.checkpoints = append(s.checkpoints, c) + return nil +} + +func (s *memSink) snapshot() ([]SpanReceipt, []Checkpoint) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]SpanReceipt{}, s.receipts...), append([]Checkpoint{}, s.checkpoints...) +} + +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func newTestExporter(t *testing.T, cfg Config) (*Exporter, *memSink, *SigningKey) { + t.Helper() + key, err := GenerateSigningKey() + if err != nil { + t.Fatal(err) + } + sink := &memSink{} + cfg.Key = key + cfg.Sink = sink + if cfg.Logger == nil { + cfg.Logger = quietLogger() + } + exp, err := New(cfg) + if err != nil { + t.Fatal(err) + } + return exp, sink, key +} + +func TestExporterSignsAndChainsSpans(t *testing.T) { + exp, sink, key := newTestExporter(t, Config{}) + ctx := context.Background() + + exp.ExportSpans(ctx, []store.SpanData{testSpan("a"), testSpan("b"), testSpan("c")}) + if err := exp.Shutdown(ctx); err != nil { + t.Fatal(err) + } + + receipts, checkpoints := sink.snapshot() + if len(receipts) != 3 { + t.Fatalf("expected 3 receipts, got %d", len(receipts)) + } + if len(checkpoints) != 1 { + t.Fatalf("expected a final checkpoint, got %d", len(checkpoints)) + } + + report, err := VerifyReceipts(receipts, &checkpoints[0], testKeyring(t, key)) + if err != nil { + t.Fatal(err) + } + if !report.Valid { + t.Fatalf("exported evidence should verify: %+v", report) + } +} + +func TestExporterFiltersBySpanType(t *testing.T) { + exp, sink, _ := newTestExporter(t, Config{SpanTypes: []string{"tool_call"}}) + ctx := context.Background() + + llm := testSpan("llm") + llm.SpanType = "llm_call" + + exp.ExportSpans(ctx, []store.SpanData{testSpan("tool"), llm}) + _ = exp.Shutdown(ctx) + + receipts, _ := sink.snapshot() + if len(receipts) != 1 { + t.Fatalf("expected only the tool_call span, got %d receipts", len(receipts)) + } + if receipts[0].Payload.SpanType != "tool_call" { + t.Fatalf("wrong span signed: %s", receipts[0].Payload.SpanType) + } +} + +// ExportSpans returns nothing by interface contract, so a broken sink must be +// counted and logged rather than propagated. A signing problem must never take +// down a run that was otherwise fine. +func TestSinkFailureIsCountedNotPropagated(t *testing.T) { + exp, sink, key := newTestExporter(t, Config{}) + ctx := context.Background() + + sink.failWrites = true + exp.ExportSpans(ctx, []store.SpanData{testSpan("a"), testSpan("b")}) + sink.failWrites = false + exp.ExportSpans(ctx, []store.SpanData{testSpan("c")}) + + if err := exp.Shutdown(ctx); err != nil { + t.Fatal(err) + } + + coverage := exp.Coverage() + if coverage.SpansReceived != 3 { + t.Fatalf("expected 3 received, got %d", coverage.SpansReceived) + } + if coverage.SpansFailed != 2 { + t.Fatalf("expected 2 failures, got %d", coverage.SpansFailed) + } + if coverage.SpansSigned != 1 { + t.Fatalf("expected 1 signed, got %d", coverage.SpansSigned) + } + + // The surviving receipt must still verify, and the checkpoint must carry + // the failure count so a reader sees the gap rather than a clean run. + receipts, checkpoints := sink.snapshot() + report, _ := VerifyReceipts(receipts, &checkpoints[0], testKeyring(t, key)) + if !report.Valid { + t.Fatalf("the receipts that did land should verify: %+v", report) + } + if report.Coverage == nil || report.Coverage.SpansFailed != 2 { + t.Fatal("the checkpoint must carry the failure count") + } +} + +// A failed write must not advance the chain head, or a transient sink error +// would leave a permanent unexplained break. +func TestFailedWriteDoesNotBreakTheChain(t *testing.T) { + exp, sink, key := newTestExporter(t, Config{}) + ctx := context.Background() + + exp.ExportSpans(ctx, []store.SpanData{testSpan("a")}) + sink.failWrites = true + exp.ExportSpans(ctx, []store.SpanData{testSpan("lost")}) + sink.failWrites = false + exp.ExportSpans(ctx, []store.SpanData{testSpan("c")}) + _ = exp.Shutdown(ctx) + + receipts, checkpoints := sink.snapshot() + report, _ := VerifyReceipts(receipts, &checkpoints[0], testKeyring(t, key)) + if !report.ChainIntact { + t.Fatalf("chain should remain intact across a failed write: %+v", report.Results) + } +} + +func TestCoverageNeverClaimsRuntimeCompleteness(t *testing.T) { + exp, _, _ := newTestExporter(t, Config{}) + coverage := exp.Coverage() + if coverage.Note == "" { + t.Fatal("coverage must carry a note distinguishing exporter reach from runtime activity") + } +} + +func TestInterimCheckpoints(t *testing.T) { + exp, sink, _ := newTestExporter(t, Config{CheckpointEvery: 2}) + ctx := context.Background() + + exp.ExportSpans(ctx, []store.SpanData{testSpan("a"), testSpan("b")}) + _, checkpoints := sink.snapshot() + if len(checkpoints) != 1 { + t.Fatalf("expected an interim checkpoint after 2 receipts, got %d", len(checkpoints)) + } + + exp.ExportSpans(ctx, []store.SpanData{testSpan("c"), testSpan("d")}) + _ = exp.Shutdown(ctx) + + _, checkpoints = sink.snapshot() + if len(checkpoints) != 3 { + t.Fatalf("expected two interim checkpoints plus a final one, got %d", len(checkpoints)) + } +} + +func TestSpansAfterShutdownAreNotSigned(t *testing.T) { + exp, sink, _ := newTestExporter(t, Config{}) + ctx := context.Background() + + if err := exp.Shutdown(ctx); err != nil { + t.Fatal(err) + } + exp.ExportSpans(ctx, []store.SpanData{testSpan("late")}) + + receipts, _ := sink.snapshot() + if len(receipts) != 0 { + t.Fatal("a span arriving after the final checkpoint must not be silently appended") + } +} + +func TestShutdownIsIdempotent(t *testing.T) { + exp, sink, _ := newTestExporter(t, Config{}) + ctx := context.Background() + + if err := exp.Shutdown(ctx); err != nil { + t.Fatal(err) + } + if err := exp.Shutdown(ctx); err != nil { + t.Fatalf("second shutdown should be a no-op, got %v", err) + } + _, checkpoints := sink.snapshot() + if len(checkpoints) != 1 { + t.Fatalf("expected exactly one final checkpoint, got %d", len(checkpoints)) + } +} + +func TestNewRequiresKeyAndSink(t *testing.T) { + if _, err := New(Config{}); err == nil { + t.Fatal("a key is required") + } + key, _ := GenerateSigningKey() + if _, err := New(Config{Key: key}); err == nil { + t.Fatal("a sink is required") + } +} + +func TestConcurrentExportKeepsTheChainConsistent(t *testing.T) { + exp, sink, key := newTestExporter(t, Config{}) + ctx := context.Background() + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + exp.ExportSpans(ctx, []store.SpanData{testSpan(fmt.Sprintf("s%d", n))}) + }(i) + } + wg.Wait() + if err := exp.Shutdown(ctx); err != nil { + t.Fatal(err) + } + + receipts, checkpoints := sink.snapshot() + if len(receipts) != 8 { + t.Fatalf("expected 8 receipts, got %d", len(receipts)) + } + report, _ := VerifyReceipts(receipts, &checkpoints[0], testKeyring(t, key)) + if !report.Valid { + t.Fatalf("concurrent export must still produce a verifiable chain: %+v", report) + } +} + +func TestFileSinkRoundTrip(t *testing.T) { + dir := t.TempDir() + receiptPath := dir + "/receipts.jsonl" + checkpointPath := dir + "/checkpoint.json" + + sink, err := NewFileSink(receiptPath, checkpointPath) + if err != nil { + t.Fatal(err) + } + key, err := GenerateSigningKey() + if err != nil { + t.Fatal(err) + } + exp, err := New(Config{Key: key, Sink: sink, Logger: quietLogger()}) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + exp.ExportSpans(ctx, []store.SpanData{testSpan("a"), testSpan("b")}) + if err := exp.Shutdown(ctx); err != nil { + t.Fatal(err) + } + + // Verify from disk with a keyring holding only public material, which is + // what an auditor would actually have. + receipts, checkpoint, err := LoadBundle(receiptPath, checkpointPath) + if err != nil { + t.Fatal(err) + } + independent := NewKeyring(KeyringEntry{Kid: key.Kid, Alg: key.Alg, PublicKey: key.PublicKey}) + + report, err := VerifyReceipts(receipts, checkpoint, independent) + if err != nil { + t.Fatal(err) + } + if !report.Valid { + t.Fatalf("evidence read back from disk should verify: %+v", report) + } + if report.ReceiptsChecked != 2 { + t.Fatalf("expected 2 receipts, got %d", report.ReceiptsChecked) + } +} + +func TestFileSinkDetectsTruncatedEvidenceFile(t *testing.T) { + dir := t.TempDir() + receiptPath := dir + "/receipts.jsonl" + checkpointPath := dir + "/checkpoint.json" + + sink, _ := NewFileSink(receiptPath, checkpointPath) + key, _ := GenerateSigningKey() + exp, _ := New(Config{Key: key, Sink: sink, Logger: quietLogger()}) + + ctx := context.Background() + exp.ExportSpans(ctx, []store.SpanData{testSpan("a"), testSpan("b"), testSpan("c")}) + _ = exp.Shutdown(ctx) + + // Someone removes the last line of the evidence file. Every remaining + // signature and internal link stays valid; only the anchor catches it. + receipts, checkpoint, _ := LoadBundle(receiptPath, checkpointPath) + truncated := receipts[:len(receipts)-1] + + independent := NewKeyring(KeyringEntry{Kid: key.Kid, Alg: key.Alg, PublicKey: key.PublicKey}) + report, _ := VerifyReceipts(truncated, checkpoint, independent) + + if report.SignaturesInvalid != 0 || !report.ChainIntact { + t.Fatal("truncation leaves signatures and links intact, which is why the anchor is needed") + } + if report.Valid || report.CheckpointValid { + t.Fatal("the checkpoint must reject a truncated evidence file") + } +} diff --git a/internal/tracing/receiptexport/filesink.go b/internal/tracing/receiptexport/filesink.go new file mode 100644 index 00000000..8ef217e6 --- /dev/null +++ b/internal/tracing/receiptexport/filesink.go @@ -0,0 +1,135 @@ +package receiptexport + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +// FileSink writes receipts as JSON Lines and keeps the latest checkpoint in its +// own file, so evidence can be collected with no infrastructure at all. +// +// It is deliberately the simplest thing that works, and it is not sufficient on +// its own. A checkpoint written next to the receipts it anchors is deletable by +// whoever deletes the receipts. To be worth something the checkpoint has to +// reach somewhere the operator cannot quietly rewrite: a build log, an object +// store with retention, or the commit that the run produced. CheckpointPath is +// separate from ReceiptPath so that publishing step is easy to bolt on. +type FileSink struct { + mu sync.Mutex + receiptPath string + checkpointPath string +} + +// NewFileSink creates the parent directories and returns a sink. +func NewFileSink(receiptPath, checkpointPath string) (*FileSink, error) { + if receiptPath == "" || checkpointPath == "" { + return nil, fmt.Errorf("receipt and checkpoint paths are required") + } + for _, path := range []string{receiptPath, checkpointPath} { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, fmt.Errorf("create directory for %s: %w", path, err) + } + } + return &FileSink{receiptPath: receiptPath, checkpointPath: checkpointPath}, nil +} + +// WriteReceipt appends one receipt as a JSON line. +func (s *FileSink) WriteReceipt(_ context.Context, receipt SpanReceipt) error { + line, err := json.Marshal(receipt) + if err != nil { + return fmt.Errorf("marshal receipt: %w", err) + } + + s.mu.Lock() + defer s.mu.Unlock() + + file, err := os.OpenFile(s.receiptPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("open receipt file: %w", err) + } + defer file.Close() + + if _, err := file.Write(append(line, '\n')); err != nil { + return fmt.Errorf("append receipt: %w", err) + } + // Durability matters more than throughput here: a receipt lost to the page + // cache on a crash is indistinguishable from one that was never signed. + return file.Sync() +} + +// WriteCheckpoint replaces the checkpoint file. Only the latest matters, since +// each one commits to the full count and terminal hash. +func (s *FileSink) WriteCheckpoint(_ context.Context, checkpoint Checkpoint) error { + encoded, err := json.MarshalIndent(checkpoint, "", " ") + if err != nil { + return fmt.Errorf("marshal checkpoint: %w", err) + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Write and rename, so a crash mid-write cannot leave a truncated anchor + // that would fail verification of an otherwise honest run. + temp := s.checkpointPath + ".tmp" + if err := os.WriteFile(temp, append(encoded, '\n'), 0o600); err != nil { + return fmt.Errorf("write checkpoint: %w", err) + } + if err := os.Rename(temp, s.checkpointPath); err != nil { + return fmt.Errorf("replace checkpoint: %w", err) + } + return nil +} + +// LoadBundle reads receipts and the checkpoint back for verification. +func LoadBundle(receiptPath, checkpointPath string) ([]SpanReceipt, *Checkpoint, error) { + raw, err := os.ReadFile(receiptPath) // #nosec G304 -- operator-supplied evidence path + if err != nil { + return nil, nil, fmt.Errorf("read receipts: %w", err) + } + + var receipts []SpanReceipt + for _, line := range splitLines(raw) { + var receipt SpanReceipt + if err := json.Unmarshal(line, &receipt); err != nil { + return nil, nil, fmt.Errorf("parse receipt: %w", err) + } + receipts = append(receipts, receipt) + } + + var checkpoint *Checkpoint + if checkpointPath != "" { + rawCheckpoint, err := os.ReadFile(checkpointPath) // #nosec G304 -- operator-supplied evidence path + if err != nil { + return nil, nil, fmt.Errorf("read checkpoint: %w", err) + } + var parsed Checkpoint + if err := json.Unmarshal(rawCheckpoint, &parsed); err != nil { + return nil, nil, fmt.Errorf("parse checkpoint: %w", err) + } + checkpoint = &parsed + } + + return receipts, checkpoint, nil +} + +func splitLines(raw []byte) [][]byte { + var lines [][]byte + start := 0 + for i, b := range raw { + if b != '\n' { + continue + } + if i > start { + lines = append(lines, raw[start:i]) + } + start = i + 1 + } + if start < len(raw) { + lines = append(lines, raw[start:]) + } + return lines +} diff --git a/internal/tracing/receiptexport/receipt.go b/internal/tracing/receiptexport/receipt.go new file mode 100644 index 00000000..fc8b9147 --- /dev/null +++ b/internal/tracing/receiptexport/receipt.go @@ -0,0 +1,570 @@ +// Package receiptexport signs exported spans as Ed25519 receipts so a third +// party can verify what a GoClaw run did without trusting the collector, the +// database, or the operator who runs them. +// +// It answers a different question from OTLP. A trace says what the runtime +// emitted. A receipt says what a named key attested to, in what order, and +// whether that record has been altered since. That difference matters the +// moment a run crosses an organisational boundary or someone has to explain a +// quiet failure after the fact. +// +// # What a verified receipt establishes +// +// This span, with exactly these fields, was exported by the holder of this key +// at this time, and the verified sequence has not been altered since. +// +// # What it does not establish +// +// That the span is a complete record of what happened. The collector drops +// spans when its buffers fill (see Collector.EmitSpan), so an exporter can only +// sign what it receives. Coverage is therefore reported as spans this exporter +// saw, never as spans the runtime executed. See the Coverage type. +// +// It also does not establish that a tool call was correct or authorised, that +// the operator is honest, or that a key compromised at export time produced +// truthful receipts. Signing binds authorship and order, not judgement. +// +// # Dependencies +// +// Standard library only, plus google/uuid which the repository already uses. +package receiptexport + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const ( + // ReceiptType identifies the signed payload schema. + ReceiptType = "goclaw.span_receipt.v1" + // KeyringType identifies the trust document schema. + KeyringType = "goclaw.receipt_keyring.v1" + // CheckpointType identifies the completeness anchor schema. + CheckpointType = "goclaw.receipt_checkpoint.v1" + // SigningAlg is the only algorithm this package accepts. + SigningAlg = "Ed25519" + + // signingDomain is mixed into every signature so a signature over one kind + // of object can never verify as another. + signingDomain = "goclaw/span-receipt/v1" + // checkpointDomain separates checkpoint signatures from receipt signatures. + checkpointDomain = "goclaw/receipt-checkpoint/v1" +) + +// SpanReceiptPayload is the signed record of one exported span. +// +// Previews and metadata are committed to by digest rather than copied. The +// tracing package redacts previews for a reason, and duplicating that content +// into a second, separately stored artifact would widen the blast radius of a +// leak while adding nothing a digest does not already prove. +type SpanReceiptPayload struct { + Type string `json:"type"` + Version int `json:"version"` + + // Sequence is the exporter's own monotonic counter, not a timestamp. + // Ordering evidence by a clock invites an older stamp recorded later to + // make honest history look altered. + Sequence uint64 `json:"sequence"` + + SpanID string `json:"span_id"` + TraceID string `json:"trace_id"` + ParentSpanID string `json:"parent_span_id,omitempty"` + TenantID string `json:"tenant_id"` + TeamID string `json:"team_id,omitempty"` + AgentID string `json:"agent_id,omitempty"` + + SpanType string `json:"span_type"` + Name string `json:"name,omitempty"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + + // Tool identity is the reason this feature exists, so it is signed + // explicitly rather than left inside an opaque content digest. + ToolName string `json:"tool_name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + + Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + InputTokens int `json:"input_tokens,omitempty"` + OutputTokens int `json:"output_tokens,omitempty"` + + StartTime string `json:"start_time"` + EndTime string `json:"end_time,omitempty"` + DurationMS int `json:"duration_ms,omitempty"` + + // ContentDigest commits to the previews, metadata and model params without + // carrying them. + ContentDigest string `json:"content_digest"` + + ExportedAt string `json:"exported_at"` + PrevReceiptHash string `json:"prev_receipt_hash,omitempty"` +} + +// Signature is the detached signature over a payload. +type Signature struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + Sig string `json:"sig"` +} + +// SpanReceipt is a payload with its signature. Keeping them separate lets a +// verifier recompute the exact signed bytes without stripping fields out of a +// merged object. +type SpanReceipt struct { + Payload SpanReceiptPayload `json:"payload"` + Signature Signature `json:"signature"` +} + +// SigningKey is an Ed25519 identity. Kid is derived from the public key so it +// cannot be reassigned to a different key. +type SigningKey struct { + Kid string `json:"kid"` + Alg string `json:"alg"` + PublicKey string `json:"public_key"` + privateKey ed25519.PrivateKey `json:"-"` +} + +// KeyringEntry is one trusted key with its validity window. +type KeyringEntry struct { + Kid string `json:"kid"` + Alg string `json:"alg"` + PublicKey string `json:"public_key"` + // NotBefore and NotAfter are RFC3339 timestamps bounding when the key may + // sign. Empty means unbounded. + NotBefore string `json:"not_before,omitempty"` + NotAfter string `json:"not_after,omitempty"` + Status string `json:"status,omitempty"` + RevokedAt string `json:"revoked_at,omitempty"` +} + +// Keyring is the trust input a verifier supplies from its own side. It is +// published separately from receipts on purpose: an artifact that carried its +// own trust anchor would let a producer install trust in its own output. +type Keyring struct { + Type string `json:"type"` + Version int `json:"version"` + Keys []KeyringEntry `json:"keys"` +} + +// Coverage records what this exporter saw. It never claims to describe what the +// runtime executed, because spans dropped by the collector under buffer +// pressure never reach an exporter and leave no machine-readable trace. +type Coverage struct { + SpansReceived uint64 `json:"spans_received"` + SpansSigned uint64 `json:"spans_signed"` + SpansFailed uint64 `json:"spans_failed"` + // Note is carried in the artifact so a reader cannot mistake exporter + // coverage for runtime coverage. + Note string `json:"note"` +} + +// CheckpointPayload is the completeness anchor. +// +// A hash chain detects modification and reordering, and detects deletion from +// the middle. It cannot detect truncation: remove the newest receipts and every +// remaining link still verifies. The checkpoint closes that by committing to a +// count and a terminal hash, and it is only worth anything if it is published +// somewhere the operator cannot quietly rewrite. +type CheckpointPayload struct { + Type string `json:"type"` + Version int `json:"version"` + Sequence uint64 `json:"sequence"` + + ReceiptCount uint64 `json:"receipt_count"` + TerminalReceiptHash string `json:"terminal_receipt_hash,omitempty"` + Coverage Coverage `json:"coverage"` + SignedAt string `json:"signed_at"` +} + +// Checkpoint is a signed completeness anchor. +type Checkpoint struct { + Payload CheckpointPayload `json:"payload"` + Signature Signature `json:"signature"` +} + +// Bundle is a self-contained export for offline verification. +type Bundle struct { + Type string `json:"type"` + Version int `json:"version"` + Receipts []SpanReceipt `json:"receipts"` + Checkpoint *Checkpoint `json:"checkpoint,omitempty"` + // KeyringHint is a convenience. Verifying an artifact with a key the + // artifact supplied proves only internal consistency. + KeyringHint *Keyring `json:"keyring_hint,omitempty"` + VerificationNote string `json:"verification_note"` +} + +// GenerateSigningKey creates a fresh Ed25519 identity. +// +// This is a convenience for getting started, not a recommendation for +// production. Where the private key lives is what determines whether a +// signature is worth anything. +func GenerateSigningKey() (*SigningKey, error) { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + return nil, fmt.Errorf("generate ed25519 key: %w", err) + } + pubB64 := base64.StdEncoding.EncodeToString(pub) + return &SigningKey{ + Kid: KeyIDFromPublicKey(pubB64), + Alg: SigningAlg, + PublicKey: pubB64, + privateKey: priv, + }, nil +} + +// NewSigningKey adopts an existing Ed25519 private key, for deployments that +// hold key material elsewhere. +func NewSigningKey(priv ed25519.PrivateKey) (*SigningKey, error) { + if len(priv) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("expected %d byte ed25519 private key, got %d", ed25519.PrivateKeySize, len(priv)) + } + pub, ok := priv.Public().(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("private key does not yield an ed25519 public key") + } + pubB64 := base64.StdEncoding.EncodeToString(pub) + return &SigningKey{ + Kid: KeyIDFromPublicKey(pubB64), + Alg: SigningAlg, + PublicKey: pubB64, + privateKey: priv, + }, nil +} + +// KeyIDFromPublicKey derives a deterministic key id, so a kid always checks +// against the key it claims to identify. +func KeyIDFromPublicKey(publicKeyB64 string) string { + raw, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil { + return "gck_invalid" + } + sum := sha256.Sum256(raw) + return "gck_" + hex.EncodeToString(sum[:])[:16] +} + +// PublicEntry returns the public half of a key for publication in a keyring. +func (k *SigningKey) PublicEntry() KeyringEntry { + return KeyringEntry{Kid: k.Kid, Alg: k.Alg, PublicKey: k.PublicKey, Status: "active"} +} + +// NewKeyring builds a trust document from public entries. +func NewKeyring(entries ...KeyringEntry) *Keyring { + keys := make([]KeyringEntry, 0, len(entries)) + for _, e := range entries { + if e.Alg == "" { + e.Alg = SigningAlg + } + if e.Status == "" { + e.Status = "active" + } + keys = append(keys, e) + } + return &Keyring{Type: KeyringType, Version: 1, Keys: keys} +} + +// canonicalJSON produces deterministic bytes: object keys sorted by code unit, +// arrays left in order, no insignificant whitespace. +// +// Both signer and verifier must derive identical bytes from the same value or +// every signature is a coin flip. Go marshals struct fields in declaration +// order, which is deterministic within this package but not something a +// verifier written in another language can rely on, so keys are sorted. +func canonicalJSON(v any) ([]byte, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var generic any + if err := dec.Decode(&generic); err != nil { + return nil, fmt.Errorf("decode for canonicalization: %w", err) + } + var buf bytes.Buffer + if err := writeCanonical(&buf, generic); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func writeCanonical(buf *bytes.Buffer, v any) error { + switch value := v.(type) { + case nil: + buf.WriteString("null") + case bool: + if value { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case json.Number: + buf.WriteString(value.String()) + case string: + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("marshal string: %w", err) + } + buf.Write(encoded) + case []any: + buf.WriteByte('[') + for i, item := range value { + if i > 0 { + buf.WriteByte(',') + } + if err := writeCanonical(buf, item); err != nil { + return err + } + } + buf.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(value)) + for k := range value { + keys = append(keys, k) + } + sort.Strings(keys) + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + encoded, err := json.Marshal(k) + if err != nil { + return fmt.Errorf("marshal key: %w", err) + } + buf.Write(encoded) + buf.WriteByte(':') + if err := writeCanonical(buf, value[k]); err != nil { + return err + } + } + buf.WriteByte('}') + default: + return fmt.Errorf("canonicalize: unsupported value of type %T", v) + } + return nil +} + +// DigestOf returns lowercase hex SHA-256 over canonical bytes. +func DigestOf(v any) (string, error) { + canonical, err := canonicalJSON(v) + if err != nil { + return "", err + } + sum := sha256.Sum256(canonical) + return hex.EncodeToString(sum[:]), nil +} + +func signingInput(domain string, payload any) ([]byte, error) { + canonical, err := canonicalJSON(payload) + if err != nil { + return nil, err + } + out := make([]byte, 0, len(domain)+1+len(canonical)) + out = append(out, domain...) + out = append(out, '\n') + out = append(out, canonical...) + return out, nil +} + +// ReceiptHash is the digest the next receipt chains to. It covers the whole +// envelope including the signature, so replacing a signature breaks the chain. +func ReceiptHash(r SpanReceipt) (string, error) { + return DigestOf(r) +} + +// PayloadFromSpan builds the signed fields from a span. +func PayloadFromSpan(span store.SpanData, sequence uint64, prevReceiptHash string, exportedAt time.Time) (SpanReceiptPayload, error) { + contentDigest, err := DigestOf(map[string]any{ + "input_preview": span.InputPreview, + "output_preview": span.OutputPreview, + "metadata": string(span.Metadata), + "model_params": string(span.ModelParams), + "total_cost": formatCost(span.TotalCost), + }) + if err != nil { + return SpanReceiptPayload{}, fmt.Errorf("content digest: %w", err) + } + + payload := SpanReceiptPayload{ + Type: ReceiptType, + Version: 1, + Sequence: sequence, + SpanID: span.ID.String(), + TraceID: span.TraceID.String(), + TenantID: span.TenantID.String(), + SpanType: span.SpanType, + Name: span.Name, + Status: span.Status, + Error: span.Error, + ToolName: span.ToolName, + ToolCallID: span.ToolCallID, + Model: span.Model, + Provider: span.Provider, + FinishReason: span.FinishReason, + InputTokens: span.InputTokens, + OutputTokens: span.OutputTokens, + StartTime: span.StartTime.UTC().Format(time.RFC3339Nano), + DurationMS: span.DurationMS, + ContentDigest: contentDigest, + ExportedAt: exportedAt.UTC().Format(time.RFC3339Nano), + PrevReceiptHash: prevReceiptHash, + } + if span.ParentSpanID != nil { + payload.ParentSpanID = span.ParentSpanID.String() + } + if span.TeamID != nil { + payload.TeamID = span.TeamID.String() + } + if span.AgentID != nil { + payload.AgentID = span.AgentID.String() + } + if span.EndTime != nil { + payload.EndTime = span.EndTime.UTC().Format(time.RFC3339Nano) + } + return payload, nil +} + +// formatCost renders a nullable float deterministically. Floats are not signed +// directly because a signer and verifier can disagree on representation. +func formatCost(cost *float64) string { + if cost == nil { + return "" + } + return fmt.Sprintf("%.10f", *cost) +} + +// SignReceipt signs a payload. +func SignReceipt(payload SpanReceiptPayload, key *SigningKey) (SpanReceipt, error) { + if key == nil || key.privateKey == nil { + return SpanReceipt{}, fmt.Errorf("signing key is required") + } + input, err := signingInput(signingDomain, payload) + if err != nil { + return SpanReceipt{}, err + } + sig := ed25519.Sign(key.privateKey, input) + return SpanReceipt{ + Payload: payload, + Signature: Signature{Alg: SigningAlg, Kid: key.Kid, Sig: base64.StdEncoding.EncodeToString(sig)}, + }, nil +} + +// SignCheckpoint signs a completeness anchor. +func SignCheckpoint(payload CheckpointPayload, key *SigningKey) (Checkpoint, error) { + if key == nil || key.privateKey == nil { + return Checkpoint{}, fmt.Errorf("signing key is required") + } + input, err := signingInput(checkpointDomain, payload) + if err != nil { + return Checkpoint{}, err + } + sig := ed25519.Sign(key.privateKey, input) + return Checkpoint{ + Payload: payload, + Signature: Signature{Alg: SigningAlg, Kid: key.Kid, Sig: base64.StdEncoding.EncodeToString(sig)}, + }, nil +} + +// resolveKey finds the key a receipt names and decides whether it was trusted +// at the moment the receipt claims to have been signed. +// +// Revocation is deliberately not retroactive: a receipt signed while the key +// was trusted stays valid, because the alternative silently voids honest +// history every time an operator rotates. RevokedAt exists for the compromise +// case, where everything from that moment on should fail. +func resolveKey(keyring *Keyring, kid string, at time.Time) (KeyringEntry, error) { + if keyring == nil { + return KeyringEntry{}, fmt.Errorf("keyring is required") + } + if keyring.Type != KeyringType { + return KeyringEntry{}, fmt.Errorf("unsupported keyring type %q", keyring.Type) + } + for _, key := range keyring.Keys { + if key.Kid != kid { + continue + } + if key.Alg != SigningAlg { + return KeyringEntry{}, fmt.Errorf("key %s uses unsupported algorithm %q", kid, key.Alg) + } + if KeyIDFromPublicKey(key.PublicKey) != key.Kid { + return KeyringEntry{}, fmt.Errorf("key %s does not match the public key it names", kid) + } + if err := checkWindow(key, at, kid); err != nil { + return KeyringEntry{}, err + } + return key, nil + } + return KeyringEntry{}, fmt.Errorf("no key in keyring for kid %s", kid) +} + +func checkWindow(key KeyringEntry, at time.Time, kid string) error { + if key.NotBefore != "" { + nb, err := time.Parse(time.RFC3339Nano, key.NotBefore) + if err != nil { + return fmt.Errorf("key %s has an unparseable not_before: %w", kid, err) + } + if at.Before(nb) { + return fmt.Errorf("receipt predates the validity window of key %s", kid) + } + } + if key.NotAfter != "" { + na, err := time.Parse(time.RFC3339Nano, key.NotAfter) + if err != nil { + return fmt.Errorf("key %s has an unparseable not_after: %w", kid, err) + } + if at.After(na) { + return fmt.Errorf("receipt postdates the validity window of key %s", kid) + } + } + if strings.EqualFold(key.Status, "revoked") && key.RevokedAt != "" { + ra, err := time.Parse(time.RFC3339Nano, key.RevokedAt) + if err != nil { + return fmt.Errorf("key %s has an unparseable revoked_at: %w", kid, err) + } + if !at.Before(ra) { + return fmt.Errorf("key %s was revoked before this receipt was signed", kid) + } + } + return nil +} + +func verifySignature(domain string, payload any, sig Signature, keyring *Keyring, at time.Time) error { + if sig.Alg != SigningAlg { + return fmt.Errorf("unsupported signature algorithm %q", sig.Alg) + } + key, err := resolveKey(keyring, sig.Kid, at) + if err != nil { + return err + } + pubRaw, err := base64.StdEncoding.DecodeString(key.PublicKey) + if err != nil { + return fmt.Errorf("public key for %s could not be decoded: %w", sig.Kid, err) + } + if len(pubRaw) != ed25519.PublicKeySize { + return fmt.Errorf("public key for %s is not an ed25519 key", sig.Kid) + } + sigRaw, err := base64.StdEncoding.DecodeString(sig.Sig) + if err != nil { + return fmt.Errorf("signature could not be decoded: %w", err) + } + input, err := signingInput(domain, payload) + if err != nil { + return err + } + if !ed25519.Verify(ed25519.PublicKey(pubRaw), input, sigRaw) { + return fmt.Errorf("signature does not verify over the payload") + } + return nil +} diff --git a/internal/tracing/receiptexport/receipt_test.go b/internal/tracing/receiptexport/receipt_test.go new file mode 100644 index 00000000..6a1c266a --- /dev/null +++ b/internal/tracing/receiptexport/receipt_test.go @@ -0,0 +1,371 @@ +package receiptexport + +import ( + "crypto/ed25519" + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +var testTime = time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) + +func testKeyring(t *testing.T, key *SigningKey) *Keyring { + t.Helper() + return NewKeyring(key.PublicEntry()) +} + +func testSpan(name string) store.SpanData { + return store.SpanData{ + ID: uuid.New(), + TraceID: uuid.New(), + TenantID: uuid.New(), + SpanType: "tool_call", + Name: name, + Status: "ok", + ToolName: "shell", + ToolCallID: "call_" + name, + StartTime: testTime, + InputPreview: "ls -la", + OutputPreview: "total 0", + } +} + +// signChain builds n chained receipts, so tests can attack a realistic sequence +// rather than a single artifact. +func signChain(t *testing.T, key *SigningKey, n int) ([]SpanReceipt, string) { + t.Helper() + receipts := make([]SpanReceipt, 0, n) + prev := "" + for i := 0; i < n; i++ { + payload, err := PayloadFromSpan(testSpan(string(rune('a'+i))), uint64(i), prev, testTime.Add(time.Duration(i)*time.Second)) + if err != nil { + t.Fatalf("payload %d: %v", i, err) + } + receipt, err := SignReceipt(payload, key) + if err != nil { + t.Fatalf("sign %d: %v", i, err) + } + hash, err := ReceiptHash(receipt) + if err != nil { + t.Fatalf("hash %d: %v", i, err) + } + receipts = append(receipts, receipt) + prev = hash + } + return receipts, prev +} + +func checkpointFor(t *testing.T, key *SigningKey, receipts []SpanReceipt, terminal string) *Checkpoint { + t.Helper() + cp, err := SignCheckpoint(CheckpointPayload{ + Type: CheckpointType, + Version: 1, + Sequence: uint64(len(receipts)), + ReceiptCount: uint64(len(receipts)), + TerminalReceiptHash: terminal, + Coverage: Coverage{SpansReceived: uint64(len(receipts)), SpansSigned: uint64(len(receipts))}, + SignedAt: testTime.Add(time.Hour).Format(time.RFC3339Nano), + }, key) + if err != nil { + t.Fatalf("sign checkpoint: %v", err) + } + return &cp +} + +func TestCanonicalJSONIsOrderIndependent(t *testing.T) { + a, err := canonicalJSON(map[string]any{"b": 1, "a": 2}) + if err != nil { + t.Fatal(err) + } + b, err := canonicalJSON(map[string]any{"a": 2, "b": 1}) + if err != nil { + t.Fatal(err) + } + if string(a) != string(b) { + t.Fatalf("canonical form depends on insertion order: %q vs %q", a, b) + } + if string(a) != `{"a":2,"b":1}` { + t.Fatalf("unexpected canonical form %q", a) + } +} + +func TestCanonicalJSONPreservesArrayOrder(t *testing.T) { + a, _ := canonicalJSON([]int{1, 2}) + b, _ := canonicalJSON([]int{2, 1}) + if string(a) == string(b) { + t.Fatal("array order must be significant") + } +} + +func TestKeyIDDerivesFromPublicKey(t *testing.T) { + key, err := GenerateSigningKey() + if err != nil { + t.Fatal(err) + } + if KeyIDFromPublicKey(key.PublicKey) != key.Kid { + t.Fatal("kid must be derivable from the public key it names") + } +} + +func TestKeyringEntryLyingAboutItsKeyIsRejected(t *testing.T) { + key, _ := GenerateSigningKey() + other, _ := GenerateSigningKey() + + lying := NewKeyring(KeyringEntry{Kid: key.Kid, Alg: SigningAlg, PublicKey: other.PublicKey}) + receipts, terminal := signChain(t, key, 1) + + report, err := VerifyReceipts(receipts, checkpointFor(t, key, receipts, terminal), lying) + if err != nil { + t.Fatal(err) + } + if report.Results[0].SignatureValid { + t.Fatal("a kid that does not match its public key must not verify") + } + if !strings.Contains(report.Results[0].SignatureError, "does not match the public key") { + t.Fatalf("unexpected error: %s", report.Results[0].SignatureError) + } +} + +func TestValidChainWithCheckpointVerifies(t *testing.T) { + key, _ := GenerateSigningKey() + receipts, terminal := signChain(t, key, 4) + + report, err := VerifyReceipts(receipts, checkpointFor(t, key, receipts, terminal), testKeyring(t, key)) + if err != nil { + t.Fatal(err) + } + if !report.Valid { + t.Fatalf("expected valid, got %+v", report) + } + if !report.CheckpointValid || report.SignaturesInvalid != 0 || !report.ChainIntact { + t.Fatalf("unexpected report: %+v", report) + } +} + +func TestTamperedPayloadFailsSignature(t *testing.T) { + key, _ := GenerateSigningKey() + receipts, terminal := signChain(t, key, 2) + receipts[1].Payload.ToolName = "rm" + + report, _ := VerifyReceipts(receipts, checkpointFor(t, key, receipts, terminal), testKeyring(t, key)) + if report.Results[1].SignatureValid { + t.Fatal("editing a signed field must break the signature") + } + if report.Valid { + t.Fatal("report must not be valid") + } +} + +func TestForgedSignatureUnderTrustedKidFails(t *testing.T) { + key, _ := GenerateSigningKey() + attacker, _ := GenerateSigningKey() + + payload, _ := PayloadFromSpan(testSpan("x"), 0, "", testTime) + forged, _ := SignReceipt(payload, attacker) + forged.Signature.Kid = key.Kid // claim a trusted key + + report, _ := VerifyReceipts([]SpanReceipt{forged}, nil, testKeyring(t, key)) + if report.Results[0].SignatureValid { + t.Fatal("a signature by another key must not verify under a claimed kid") + } +} + +// A receipt signature must not be replayable as a checkpoint signature, which +// is what the separate signing domains are for. +func TestSigningDomainsAreSeparated(t *testing.T) { + key, _ := GenerateSigningKey() + payload := CheckpointPayload{ + Type: CheckpointType, Version: 1, ReceiptCount: 0, + SignedAt: testTime.Format(time.RFC3339Nano), + } + + input, err := signingInput(signingDomain, payload) // deliberately the wrong domain + if err != nil { + t.Fatal(err) + } + wrongDomain := Checkpoint{ + Payload: payload, + Signature: Signature{ + Alg: SigningAlg, Kid: key.Kid, + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(key.privateKey, input)), + }, + } + + report, _ := VerifyReceipts(nil, &wrongDomain, testKeyring(t, key)) + if report.CheckpointValid { + t.Fatal("a signature made in the receipt domain must not verify as a checkpoint") + } +} + +func TestKeyValidityWindow(t *testing.T) { + key, _ := GenerateSigningKey() + receipts, terminal := signChain(t, key, 1) + + tooEarly := NewKeyring(KeyringEntry{ + Kid: key.Kid, Alg: SigningAlg, PublicKey: key.PublicKey, + NotBefore: testTime.Add(time.Hour).Format(time.RFC3339Nano), + }) + report, _ := VerifyReceipts(receipts, checkpointFor(t, key, receipts, terminal), tooEarly) + if report.Results[0].SignatureValid { + t.Fatal("a receipt signed before the key was valid must fail") + } + + tooLate := NewKeyring(KeyringEntry{ + Kid: key.Kid, Alg: SigningAlg, PublicKey: key.PublicKey, + NotAfter: testTime.Add(-time.Hour).Format(time.RFC3339Nano), + }) + report, _ = VerifyReceipts(receipts, checkpointFor(t, key, receipts, terminal), tooLate) + if report.Results[0].SignatureValid { + t.Fatal("a receipt signed after the key expired must fail") + } +} + +// Rotation must not silently void honest history, or nobody will ever rotate. +func TestRevocationIsNotRetroactive(t *testing.T) { + key, _ := GenerateSigningKey() + receipts, terminal := signChain(t, key, 1) + + revokedLater := NewKeyring(KeyringEntry{ + Kid: key.Kid, Alg: SigningAlg, PublicKey: key.PublicKey, + Status: "revoked", RevokedAt: testTime.Add(time.Hour).Format(time.RFC3339Nano), + }) + report, _ := VerifyReceipts(receipts, checkpointFor(t, key, receipts, terminal), revokedLater) + if !report.Results[0].SignatureValid { + t.Fatalf("a receipt signed before revocation must stay valid: %s", report.Results[0].SignatureError) + } + + revokedBefore := NewKeyring(KeyringEntry{ + Kid: key.Kid, Alg: SigningAlg, PublicKey: key.PublicKey, + Status: "revoked", RevokedAt: testTime.Add(-time.Hour).Format(time.RFC3339Nano), + }) + report, _ = VerifyReceipts(receipts, checkpointFor(t, key, receipts, terminal), revokedBefore) + if report.Results[0].SignatureValid { + t.Fatal("a receipt signed after revocation must fail") + } +} + +func TestDeletionFromTheMiddleIsDetected(t *testing.T) { + key, _ := GenerateSigningKey() + receipts, _ := signChain(t, key, 4) + + cut := append([]SpanReceipt{}, receipts[:2]...) + cut = append(cut, receipts[3]) + + report, _ := VerifyReceipts(cut, nil, testKeyring(t, key)) + if report.SignaturesInvalid != 0 { + t.Fatal("remaining signatures should still be genuine") + } + if report.ChainIntact { + t.Fatal("a missing link must be detected") + } +} + +func TestOneBreakDoesNotCascade(t *testing.T) { + key, _ := GenerateSigningKey() + receipts, _ := signChain(t, key, 5) + + cut := append([]SpanReceipt{receipts[0]}, receipts[2:]...) + report, _ := VerifyReceipts(cut, nil, testKeyring(t, key)) + + broken := 0 + for _, r := range report.Results { + if !r.ChainValid { + broken++ + } + } + if broken != 1 { + t.Fatalf("expected exactly one broken link, got %d", broken) + } +} + +// The failure a hash chain alone cannot catch. Removing the newest receipts +// leaves every remaining link and signature perfectly valid, so only an anchor +// committing to a count and terminal hash detects it. +func TestTruncationFromTheEndIsDetectedOnlyByTheCheckpoint(t *testing.T) { + key, _ := GenerateSigningKey() + receipts, terminal := signChain(t, key, 4) + checkpoint := checkpointFor(t, key, receipts, terminal) + + truncated := receipts[:2] + + withoutAnchor, _ := VerifyReceipts(truncated, nil, testKeyring(t, key)) + if withoutAnchor.SignaturesInvalid != 0 || !withoutAnchor.ChainIntact { + t.Fatal("truncation leaves signatures and internal links intact, which is the point") + } + if withoutAnchor.Valid { + t.Fatal("a report without a checkpoint must never be valid, since completeness is unproven") + } + + withAnchor, _ := VerifyReceipts(truncated, checkpoint, testKeyring(t, key)) + if withAnchor.CheckpointValid { + t.Fatal("the checkpoint must reject a truncated bundle") + } + if !strings.Contains(withAnchor.CheckpointError, "expects 4 receipts") { + t.Fatalf("unexpected checkpoint error: %s", withAnchor.CheckpointError) + } +} + +func TestEmptyBundleDoesNotVerify(t *testing.T) { + key, _ := GenerateSigningKey() + receipts, terminal := signChain(t, key, 3) + checkpoint := checkpointFor(t, key, receipts, terminal) + + // Handing someone zero receipts must not read as success. + report, _ := VerifyReceipts(nil, checkpoint, testKeyring(t, key)) + if report.CheckpointValid || report.Valid { + t.Fatal("an empty bundle must fail against a checkpoint naming receipts") + } + + bare, _ := VerifyReceipts(nil, nil, testKeyring(t, key)) + if bare.Valid { + t.Fatal("an empty bundle with no checkpoint must not be valid either") + } +} + +func TestGenuinelyEmptyRunVerifies(t *testing.T) { + key, _ := GenerateSigningKey() + empty := checkpointFor(t, key, nil, "") + + report, _ := VerifyReceipts(nil, empty, testKeyring(t, key)) + if !report.Valid { + t.Fatalf("a run that produced nothing, anchored as such, should verify: %+v", report) + } +} + +func TestReportStatesWhatItDoesNotEstablish(t *testing.T) { + key, _ := GenerateSigningKey() + receipts, terminal := signChain(t, key, 1) + report, _ := VerifyReceipts(receipts, checkpointFor(t, key, receipts, terminal), testKeyring(t, key)) + + if len(report.DoesNotEstablish) == 0 { + t.Fatal("verification must state its limits") + } + joined := strings.Join(report.DoesNotEstablish, " ") + if !strings.Contains(joined, "drops spans") { + t.Fatal("the collector drop caveat must be carried in the report") + } +} + +func TestContentIsCommittedByDigestNotCopied(t *testing.T) { + span := testSpan("secret") + span.InputPreview = "sensitive-argument-value" + payload, err := PayloadFromSpan(span, 0, "", testTime) + if err != nil { + t.Fatal(err) + } + canonical, err := canonicalJSON(payload) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(canonical), "sensitive-argument-value") { + t.Fatal("previews must be committed to by digest, not copied into the receipt") + } + if payload.ContentDigest == "" { + t.Fatal("a content digest is required") + } +} diff --git a/internal/tracing/receiptexport/verify.go b/internal/tracing/receiptexport/verify.go new file mode 100644 index 00000000..369b5c0c --- /dev/null +++ b/internal/tracing/receiptexport/verify.go @@ -0,0 +1,164 @@ +package receiptexport + +import ( + "fmt" + "time" +) + +// ReceiptResult is the per-receipt outcome of verification. +// +// Signature validity and chain integrity are reported separately on purpose. +// A validly signed receipt pointing at the wrong predecessor means something +// was removed or reordered, which is a different failure from a forged +// signature and calls for a different response. Collapsing them into one +// boolean would hide the distinction that makes the chain worth having. +type ReceiptResult struct { + Index int `json:"index"` + SpanID string `json:"span_id"` + Sequence uint64 `json:"sequence"` + SignatureValid bool `json:"signature_valid"` + SignatureError string `json:"signature_error,omitempty"` + ChainValid bool `json:"chain_valid"` + ChainError string `json:"chain_error,omitempty"` +} + +// VerificationReport is the result of checking a set of receipts. +type VerificationReport struct { + Type string `json:"type"` + ReceiptsChecked int `json:"receipts_checked"` + SignaturesValid int `json:"signatures_valid"` + SignaturesInvalid int `json:"signatures_invalid"` + ChainIntact bool `json:"chain_intact"` + + // CheckpointChecked is false when no anchor was supplied. Without one, + // truncation is undetectable: remove the newest receipts and every + // remaining link still verifies. + CheckpointChecked bool `json:"checkpoint_checked"` + CheckpointValid bool `json:"checkpoint_valid"` + CheckpointError string `json:"checkpoint_error,omitempty"` + + Valid bool `json:"valid"` + Coverage *Coverage `json:"coverage,omitempty"` + Results []ReceiptResult `json:"results"` + Establishes string `json:"establishes"` + DoesNotEstablish []string `json:"does_not_establish"` +} + +// VerifyReceipts checks signatures and chain links over an ordered sequence. +// +// A checkpoint is optional but strongly recommended. Without one the report +// says so via CheckpointChecked, and Valid can only mean "nothing I was given +// has been altered", never "nothing is missing". +func VerifyReceipts(receipts []SpanReceipt, checkpoint *Checkpoint, keyring *Keyring) (VerificationReport, error) { + if keyring == nil { + return VerificationReport{}, fmt.Errorf("keyring is required") + } + + report := VerificationReport{ + Type: "goclaw.receipt_verification.v1", + ReceiptsChecked: len(receipts), + ChainIntact: true, + Establishes: "Each verified span was exported and signed by the named key while that key was trusted, and the verified sequence has not been altered.", + DoesNotEstablish: []string{ + "It does not establish that these are all the spans the runtime produced. The collector drops spans under buffer pressure and an exporter can only sign what it receives.", + "It does not establish that a tool call was correct, authorised, or safe.", + "It does not establish that receipts signed by a compromised key were truthful.", + "Without a checkpoint it does not establish that no receipts were removed from the end of the sequence.", + }, + } + + previousHash := "" + for index, receipt := range receipts { + result := ReceiptResult{ + Index: index, + SpanID: receipt.Payload.SpanID, + Sequence: receipt.Payload.Sequence, + } + + if receipt.Payload.Type != ReceiptType { + result.SignatureError = fmt.Sprintf("unsupported receipt type %q", receipt.Payload.Type) + } else { + exportedAt, err := time.Parse(time.RFC3339Nano, receipt.Payload.ExportedAt) + if err != nil { + result.SignatureError = fmt.Sprintf("unparseable exported_at: %v", err) + } else if err := verifySignature(signingDomain, receipt.Payload, receipt.Signature, keyring, exportedAt); err != nil { + result.SignatureError = err.Error() + } else { + result.SignatureValid = true + report.SignaturesValid++ + } + } + + expected := "" + if index > 0 { + expected = previousHash + } + if receipt.Payload.PrevReceiptHash == expected { + result.ChainValid = true + } else { + report.ChainIntact = false + result.ChainError = fmt.Sprintf("expected prev_receipt_hash %q but found %q", expected, receipt.Payload.PrevReceiptHash) + } + + // Chain from what is actually present, so one break does not cascade + // into every later receipt reporting a failure it did not cause. + hash, err := ReceiptHash(receipt) + if err != nil { + return VerificationReport{}, fmt.Errorf("hash receipt %d: %w", index, err) + } + previousHash = hash + + report.Results = append(report.Results, result) + } + + report.SignaturesInvalid = report.ReceiptsChecked - report.SignaturesValid + + if checkpoint != nil { + report.CheckpointChecked = true + if err := verifyCheckpoint(*checkpoint, receipts, previousHash, keyring); err != nil { + report.CheckpointError = err.Error() + } else { + report.CheckpointValid = true + cov := checkpoint.Payload.Coverage + report.Coverage = &cov + } + } + + report.Valid = report.SignaturesInvalid == 0 && + report.ChainIntact && + report.CheckpointChecked && + report.CheckpointValid + + return report, nil +} + +// verifyCheckpoint closes the truncation hole a chain alone cannot. +func verifyCheckpoint(checkpoint Checkpoint, receipts []SpanReceipt, terminalHash string, keyring *Keyring) error { + if checkpoint.Payload.Type != CheckpointType { + return fmt.Errorf("unsupported checkpoint type %q", checkpoint.Payload.Type) + } + signedAt, err := time.Parse(time.RFC3339Nano, checkpoint.Payload.SignedAt) + if err != nil { + return fmt.Errorf("unparseable signed_at: %w", err) + } + if err := verifySignature(checkpointDomain, checkpoint.Payload, checkpoint.Signature, keyring, signedAt); err != nil { + return fmt.Errorf("checkpoint signature: %w", err) + } + if uint64(len(receipts)) != checkpoint.Payload.ReceiptCount { + return fmt.Errorf("checkpoint expects %d receipts but the bundle holds %d", + checkpoint.Payload.ReceiptCount, len(receipts)) + } + // An empty bundle is only acceptable when the anchor says the run produced + // nothing. Otherwise handing someone zero receipts would verify clean. + if len(receipts) == 0 { + if checkpoint.Payload.TerminalReceiptHash != "" { + return fmt.Errorf("checkpoint names a terminal receipt but the bundle is empty") + } + return nil + } + if checkpoint.Payload.TerminalReceiptHash != terminalHash { + return fmt.Errorf("checkpoint terminal hash %q does not match the last receipt %q", + checkpoint.Payload.TerminalReceiptHash, terminalHash) + } + return nil +}