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 +}