diff --git a/README.md b/README.md index 7c8163d..7a8848f 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,17 @@ integrity. For scripts, add `--format json` to receive one machine-readable result object while keeping the same exit codes. Add `--quiet` (or `-q`) to text output to print only the `PASS:` summary on successful verification. +### Verify receipts from other producers + +The receipt protocol lives in a public, standard-library-only Go package: +[`pkg/receiptspec`](pkg/receiptspec). It holds the receipt type, canonical +hashing, Ed25519 signing, chain and export verification, and JSONL encoding. +The gateway and `agentgate-verify` both use it, so there is one +implementation. Another producer, such as the Clawdlinux operator, builds a +chain with `receiptspec.NewChain` and any `agentgate-verify` checks it with +`--source jsonl`. To record decisions instead of API calls, see +[`pkg/receiptspec/DECISION.md`](pkg/receiptspec/DECISION.md). + ### Development option To build from source instead of pulling the release image — useful when diff --git a/cmd/agentgate-verify/main.go b/cmd/agentgate-verify/main.go index c9593a5..8341adf 100644 --- a/cmd/agentgate-verify/main.go +++ b/cmd/agentgate-verify/main.go @@ -27,10 +27,9 @@ Licensed under the Apache License, Version 2.0. package main import ( - "bufio" - "bytes" "database/sql" "encoding/json" + "errors" "flag" "fmt" "io" @@ -39,6 +38,7 @@ import ( _ "modernc.org/sqlite" "github.com/Clawdlinux/agentgate/internal/receipt" + "github.com/Clawdlinux/agentgate/pkg/receiptspec" ) func main() { @@ -80,23 +80,23 @@ func run(args []string, stdout, stderr io.Writer) int { return 2 } - var explicitTrust []receipt.TrustedKey + var explicitTrust []receiptspec.TrustedKey if trustRoot != "" { trustData, err := os.ReadFile(trustRoot) if err != nil { fmt.Fprintf(stderr, "agentgate-verify: read trust root: %v\n", err) return 2 } - explicitTrust, err = receipt.LoadTrustedKeys(trustData) + explicitTrust, err = receiptspec.LoadTrustedKeys(trustData) if err != nil { fmt.Fprintf(stderr, "agentgate-verify: %v\n", err) return 2 } } - var explicitExpected *receipt.ExpectedHead + var explicitExpected *receiptspec.ExpectedHead if expectedHead != "" { - eh, err := receipt.ParseExpectedHead(expectedHead) + eh, err := receiptspec.ParseExpectedHead(expectedHead) if err != nil { fmt.Fprintf(stderr, "agentgate-verify: %v\n", err) return 2 @@ -105,45 +105,31 @@ func run(args []string, stdout, stderr io.Writer) int { } var ( - receipts []receipt.Receipt - embeddedKeys []receipt.TrustedKey - manifest *receipt.ExportManifest - err error + bundle receiptspec.Bundle + err error ) switch source { case "sqlite": - receipts, err = readSQLite(path) + bundle.Receipts, err = readSQLite(path) case "jsonl": - receipts, embeddedKeys, manifest, err = readJSONL(path) + bundle, err = readJSONL(path) } if err != nil { fmt.Fprintf(stderr, "agentgate-verify: read %s: %v\n", source, err) return 2 } - - trustedKeys := explicitTrust - if len(trustedKeys) == 0 { - if len(embeddedKeys) == 0 { - fmt.Fprintln(stderr, "agentgate-verify: --trust-root is required (the source has no embedded keys)") - return 2 - } - trustedKeys = embeddedKeys + if len(explicitTrust) == 0 && len(bundle.Keys) == 0 { + fmt.Fprintln(stderr, "agentgate-verify: --trust-root is required (the source has no embedded keys)") + return 2 } + manifest := bundle.Manifest - anchor := receipt.Anchor{} - expected := explicitExpected - if manifest != nil { - if err := receipt.VerifyManifest(*manifest, trustedKeys, embeddedKeys); err != nil { - fmt.Fprintf(stderr, "agentgate-verify: manifest: %v\n", err) - return 1 - } - anchor = receipt.Anchor{Seq: manifest.AnchorSeq, EntryHash: manifest.AnchorHash} - if expected == nil { - expected = &receipt.ExpectedHead{Seq: manifest.ResolvedTo, EntryHash: manifest.LastEntryHash} - } + result, err := receiptspec.VerifyBundle(bundle, explicitTrust, explicitExpected) + var manifestErr *receiptspec.ManifestError + if errors.As(err, &manifestErr) { + fmt.Fprintf(stderr, "agentgate-verify: manifest: %v\n", manifestErr.Err) + return 1 } - - result, err := receipt.VerifyChain(receipts, trustedKeys, anchor, expected) if err != nil { fmt.Fprintf(stderr, "agentgate-verify: %v\n", err) return 2 @@ -203,7 +189,7 @@ type jsonResult struct { Range string `json:"range,omitempty"` } -func writeJSONResult(stdout io.Writer, result receipt.VerifyResult, rangeKind string) { +func writeJSONResult(stdout io.Writer, result receiptspec.VerifyResult, rangeKind string) { output := jsonResult{ OK: result.OK, TotalReceipts: result.TotalReceipts, @@ -222,7 +208,7 @@ func writeJSONResult(stdout io.Writer, result receipt.VerifyResult, rangeKind st // readSQLite reads every row of the receipts table, ordered by seq, from a // local file. It never writes to the database. -func readSQLite(path string) ([]receipt.Receipt, error) { +func readSQLite(path string) ([]receiptspec.Receipt, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, err @@ -239,7 +225,7 @@ func readSQLite(path string) ([]receipt.Receipt, error) { } defer rows.Close() - var out []receipt.Receipt + var out []receiptspec.Receipt for rows.Next() { r, err := receipt.ScanReceiptRow(rows) if err != nil { @@ -250,57 +236,15 @@ func readSQLite(path string) ([]receipt.Receipt, error) { return out, rows.Err() } -// readJSONL reads path (or stdin when path is "-"), dispatching each -// non-empty line by its "type" field: "key" lines accumulate into -// embeddedKeys, a "manifest" line becomes manifest (at most one is -// expected; a later one overwrites, since a well-formed export has -// exactly one), and everything else ("receipt", or no "type" field at -// all — Phase 5's original plain format) accumulates into receipts. -func readJSONL(path string) (receipts []receipt.Receipt, embeddedKeys []receipt.TrustedKey, manifest *receipt.ExportManifest, err error) { - var rdr io.Reader +// readJSONL reads path (or stdin when path is "-") with receiptspec.ReadJSONL. +func readJSONL(path string) (receiptspec.Bundle, error) { if path == "-" { - rdr = os.Stdin - } else { - f, ferr := os.Open(path) - if ferr != nil { - return nil, nil, nil, ferr - } - defer f.Close() - rdr = f + return receiptspec.ReadJSONL(os.Stdin) } - - scanner := bufio.NewScanner(rdr) - scanner.Buffer(make([]byte, 1<<20), 1<<24) - lineNum := 0 - for scanner.Scan() { - lineNum++ - line := bytes.TrimSpace(scanner.Bytes()) - if len(line) == 0 { - continue - } - switch receipt.DetectJSONLLineType(line) { - case "manifest": - m, perr := receipt.ParseManifestLine(line) - if perr != nil { - return nil, nil, nil, fmt.Errorf("line %d: %w", lineNum, perr) - } - manifest = &m - case "key": - k, perr := receipt.ParseKeyLine(line) - if perr != nil { - return nil, nil, nil, fmt.Errorf("line %d: %w", lineNum, perr) - } - embeddedKeys = append(embeddedKeys, k) - default: - r, perr := receipt.ParseJSONLReceipt(line) - if perr != nil { - return nil, nil, nil, fmt.Errorf("line %d: %w", lineNum, perr) - } - receipts = append(receipts, r) - } - } - if serr := scanner.Err(); serr != nil { - return nil, nil, nil, serr + f, err := os.Open(path) + if err != nil { + return receiptspec.Bundle{}, err } - return receipts, embeddedKeys, manifest, nil + defer f.Close() + return receiptspec.ReadJSONL(f) } diff --git a/cmd/agentgate-verify/receiptspec_test.go b/cmd/agentgate-verify/receiptspec_test.go new file mode 100644 index 0000000..5ce613f --- /dev/null +++ b/cmd/agentgate-verify/receiptspec_test.go @@ -0,0 +1,81 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +// A second producer that only uses pkg/receiptspec must verify with this command. +func TestRunVerifiesReceiptspecChain(t *testing.T) { + priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{3}, ed25519.SeedSize)) + s := receiptspec.NewEd25519Signer(priv) + c := receiptspec.NewChain(s, 0, [32]byte{}) + record, err := receiptspec.HashRecord(map[string]any{"outcome": "require_approval", "record_version": 1}) + if err != nil { + t.Fatal(err) + } + var receipts []receiptspec.Receipt + for i := 0; i < 5; i++ { + r, err := c.Next(receiptspec.Fields{ + TimestampUnixNS: uint64(1_700_000_000_000_000_000 + i), + HumanPrincipal: "operator@cluster", + AgentKeyID: "operator", + Service: "operator", + Action: "decision:scale", + ParamsSHA256: record, + PolicyDecision: receiptspec.DecisionDeny, + StatusCode: 202, + }) + if err != nil { + t.Fatal(err) + } + receipts = append(receipts, r) + } + + dir := t.TempDir() + var buf bytes.Buffer + if err := receiptspec.WriteJSONL(&buf, receipts); err != nil { + t.Fatal(err) + } + chainPath := filepath.Join(dir, "chain.jsonl") + if err := os.WriteFile(chainPath, buf.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + trust, _ := json.Marshal(map[string]any{"keys": []map[string]any{{ + "kid": s.KID(), "public_key_hex": hex.EncodeToString(priv.Public().(ed25519.PublicKey)), "valid_from_seq": 1, + }}}) + trustPath := filepath.Join(dir, "trust.json") + if err := os.WriteFile(trustPath, trust, 0o600); err != nil { + t.Fatal(err) + } + + head := receipts[4] + var stdout, stderr bytes.Buffer + code := run([]string{"--source", "jsonl", "--path", chainPath, "--trust-root", trustPath, + "--expected-head", "5:" + hex.EncodeToString(head.EntryHash[:])}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit %d, stderr %s", code, stderr.String()) + } + + tampered := bytes.Replace(buf.Bytes(), []byte(`"latency_ms":0`), []byte(`"latency_ms":1`), 1) + if err := os.WriteFile(chainPath, tampered, 0o600); err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + if code := run([]string{"--source", "jsonl", "--path", chainPath, "--trust-root", trustPath}, &stdout, &stderr); code != 1 { + t.Fatalf("tampered exit %d, want 1", code) + } +} diff --git a/internal/receipt/encoding.go b/internal/receipt/encoding.go index ad3fa2d..588ad0b 100644 --- a/internal/receipt/encoding.go +++ b/internal/receipt/encoding.go @@ -5,54 +5,12 @@ Licensed under the Apache License, Version 2.0. package receipt -import ( - "crypto/sha256" - "encoding/binary" -) - -// HashDomainV1 separates AgentGate receipt hashes from other protocols and versions. -const HashDomainV1 = "agentgate.receipt.hash.v1" - -// CanonicalHashInput returns the validated v1 entry-hash preimage. -func CanonicalHashInput(receipt Receipt) ([]byte, error) { - receipt = snapshot(receipt) - if err := Validate(receipt); err != nil { - return nil, err - } +import "github.com/Clawdlinux/agentgate/pkg/receiptspec" - encoded := make([]byte, 0, 3110) - encoded = append(encoded, HashDomainV1...) - encoded = append(encoded, 0) - encoded = binary.LittleEndian.AppendUint64(encoded, receipt.Seq) - encoded = binary.LittleEndian.AppendUint64(encoded, receipt.TimestampUnixNS) - encoded = appendLP(encoded, receipt.HumanPrincipal) - encoded = appendLP(encoded, receipt.AgentKeyID) - encoded = binary.LittleEndian.AppendUint32(encoded, uint32(len(receipt.DelegationChain))) - for _, element := range receipt.DelegationChain { - encoded = appendLP(encoded, element) - } - encoded = appendLP(encoded, receipt.Service) - encoded = appendLP(encoded, receipt.Action) - encoded = append(encoded, receipt.ParamsSHA256[:]...) - encoded = appendLP(encoded, receipt.PolicyDecision) - encoded = binary.LittleEndian.AppendUint32(encoded, uint32(int32(receipt.StatusCode))) - encoded = binary.LittleEndian.AppendUint64(encoded, uint64(receipt.LatencyMS)) - encoded = appendLP(encoded, receipt.Error) - encoded = append(encoded, receipt.PrevHash[:]...) - encoded = appendLP(encoded, receipt.SignerKID) - return encoded, nil -} +// HashDomainV1 is receiptspec.HashDomainV1. +const HashDomainV1 = receiptspec.HashDomainV1 -// ComputeEntryHash returns SHA-256 over the complete canonical hash input. -func ComputeEntryHash(receipt Receipt) ([32]byte, error) { - encoded, err := CanonicalHashInput(receipt) - if err != nil { - return [32]byte{}, err - } - return sha256.Sum256(encoded), nil -} - -func appendLP(destination []byte, value string) []byte { - destination = binary.LittleEndian.AppendUint32(destination, uint32(len(value))) - return append(destination, value...) -} +var ( + CanonicalHashInput = receiptspec.CanonicalHashInput + ComputeEntryHash = receiptspec.ComputeEntryHash +) diff --git a/internal/receipt/export.go b/internal/receipt/export.go index 268d3f6..891e3e7 100644 --- a/internal/receipt/export.go +++ b/internal/receipt/export.go @@ -5,274 +5,29 @@ Licensed under the Apache License, Version 2.0. package receipt -import ( - "crypto/ed25519" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "sort" +import "github.com/Clawdlinux/agentgate/pkg/receiptspec" - "github.com/Clawdlinux/agentgate/internal/signer" +const ( + ExportManifestDomainV1 = receiptspec.ExportManifestDomainV1 + ExportFormatVersion = receiptspec.ExportFormatVersion ) -// ExportManifestDomainV1 separates export-manifest hashes from receipt -// entry hashes and every other protocol this package signs. -const ExportManifestDomainV1 = "agentgate.receipt.export-manifest.v1" - -// ExportFormatVersion is the only manifest format_version this build -// produces or accepts. -const ExportFormatVersion = 1 - -// ErrManifestSignatureInvalid means a manifest's signature did not verify -// under its claimed signer_kid — a tamper finding, not a config error. -var ErrManifestSignatureInvalid = errors.New("receipt: export manifest signature invalid") - -// ErrKeysetDigestMismatch means the embedded key lines do not match the -// manifest's own signed keyset_digest — a tamper finding. -var ErrKeysetDigestMismatch = errors.New("receipt: export manifest keyset digest mismatch") - -// ExportManifest is the first line of a signed bounded export: it binds -// the requested and resolved range, the anchor immediately before the -// export, the first/last exported entry hashes, the database's true head -// at snapshot time, and a digest of every embedded key line — signed by -// the same Ed25519 signer that signs every receipt. -type ExportManifest struct { - FormatVersion int - RequestedFrom uint64 - RequestedTo uint64 // 0 means the request left "to" unspecified - ResolvedTo uint64 - Count int - AnchorSeq uint64 - AnchorHash [32]byte - FirstEntryHash [32]byte - LastEntryHash [32]byte - HeadSeq uint64 - HeadHash [32]byte - KeysetDigest [32]byte - SignerKID string - Signature [64]byte -} - -// ComputeKeysetDigest returns a deterministic digest over every trusted -// key, independent of input order — sorted by kid before hashing. -func ComputeKeysetDigest(keys []TrustedKey) [32]byte { - sorted := make([]TrustedKey, len(keys)) - copy(sorted, keys) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].KID < sorted[j].KID }) - - var buf []byte - for _, k := range sorted { - buf = appendLP(buf, k.KID) - buf = append(buf, k.PublicKey...) - buf = binary.LittleEndian.AppendUint64(buf, k.ValidFromSeq) - if k.ValidUntilSeq != nil { - buf = append(buf, 1) - buf = binary.LittleEndian.AppendUint64(buf, *k.ValidUntilSeq) - } else { - buf = append(buf, 0) - } - } - return sha256.Sum256(buf) -} - -// canonicalManifestInput returns the manifest's signed preimage. It -// excludes SignerKID's signature (there is nothing to sign yet) but -// includes SignerKID itself, following the same pattern as -// CanonicalHashInput excluding EntryHash/Signature but including SignerKID. -func canonicalManifestInput(m ExportManifest) []byte { - encoded := make([]byte, 0, 256) - encoded = append(encoded, ExportManifestDomainV1...) - encoded = append(encoded, 0) - encoded = binary.LittleEndian.AppendUint32(encoded, uint32(m.FormatVersion)) - encoded = binary.LittleEndian.AppendUint64(encoded, m.RequestedFrom) - encoded = binary.LittleEndian.AppendUint64(encoded, m.RequestedTo) - encoded = binary.LittleEndian.AppendUint64(encoded, m.ResolvedTo) - encoded = binary.LittleEndian.AppendUint64(encoded, uint64(m.Count)) - encoded = binary.LittleEndian.AppendUint64(encoded, m.AnchorSeq) - encoded = append(encoded, m.AnchorHash[:]...) - encoded = append(encoded, m.FirstEntryHash[:]...) - encoded = append(encoded, m.LastEntryHash[:]...) - encoded = binary.LittleEndian.AppendUint64(encoded, m.HeadSeq) - encoded = append(encoded, m.HeadHash[:]...) - encoded = append(encoded, m.KeysetDigest[:]...) - encoded = appendLP(encoded, m.SignerKID) - return encoded -} - -// ComputeManifestHash returns the SHA-256 hash a manifest signs. -func ComputeManifestHash(m ExportManifest) [32]byte { - return sha256.Sum256(canonicalManifestInput(m)) -} - -// SignManifest sets m's SignerKID and Signature, signing with priv. -func SignManifest(m ExportManifest, kid string, priv ed25519.PrivateKey) ExportManifest { - m.SignerKID = kid - hash := ComputeManifestHash(m) - m.Signature = signer.Sign(priv, hash[:]) - return m -} - -// VerifyManifest checks m's signature against trustedKeys and that its -// own KeysetDigest matches embeddedKeys — both tamper findings, not -// configuration errors, since the manifest itself is what carries trust -// here (VER-09's "unknown signer_kid is a config error" reasoning does not -// apply: a manifest naming an unrecognized key inside a signed export it -// otherwise controls is a forgery attempt, not a stale local trust file). -func VerifyManifest(m ExportManifest, trustedKeys []TrustedKey, embeddedKeys []TrustedKey) error { - var key TrustedKey - found := false - for _, k := range trustedKeys { - if k.KID == m.SignerKID { - key, found = k, true - break - } - } - if !found { - return fmt.Errorf("%w: %s", ErrUnknownSignerKID, m.SignerKID) - } - hash := ComputeManifestHash(m) - if !signer.Verify(key.PublicKey, hash[:], m.Signature) { - return ErrManifestSignatureInvalid - } - if ComputeKeysetDigest(embeddedKeys) != m.KeysetDigest { - return ErrKeysetDigestMismatch - } - return nil -} - -// --- Typed JSONL lines: "manifest", "key", "receipt" --- - -type lineTypeProbe struct { - Type string `json:"type"` -} - -// DetectJSONLLineType returns "manifest", "key", or "receipt" for one -// JSONL line. A missing or empty "type" field defaults to "receipt" — -// Phase 5's original plain-receipt-per-line format has no such field, and -// must keep parsing exactly as it always has. -func DetectJSONLLineType(line []byte) string { - var probe lineTypeProbe - if err := json.Unmarshal(line, &probe); err != nil || probe.Type == "" { - return "receipt" - } - return probe.Type -} - -type manifestLineJSON struct { - Type string `json:"type"` - FormatVersion int `json:"format_version"` - RequestedFrom uint64 `json:"requested_from"` - RequestedTo uint64 `json:"requested_to,omitempty"` - ResolvedTo uint64 `json:"resolved_to"` - Count int `json:"count"` - AnchorSeq uint64 `json:"anchor_seq"` - AnchorHash string `json:"anchor_hash"` - FirstEntryHash string `json:"first_entry_hash"` - LastEntryHash string `json:"last_entry_hash"` - HeadSeq uint64 `json:"head_seq"` - HeadHash string `json:"head_hash"` - KeysetDigest string `json:"keyset_digest"` - SignerKID string `json:"signer_kid"` - Signature string `json:"signature"` -} - -// MarshalManifestLine encodes m as one JSONL line. -func MarshalManifestLine(m ExportManifest) ([]byte, error) { - line := manifestLineJSON{ - Type: "manifest", - FormatVersion: m.FormatVersion, - RequestedFrom: m.RequestedFrom, - RequestedTo: m.RequestedTo, - ResolvedTo: m.ResolvedTo, - Count: m.Count, - AnchorSeq: m.AnchorSeq, - AnchorHash: hex.EncodeToString(m.AnchorHash[:]), - FirstEntryHash: hex.EncodeToString(m.FirstEntryHash[:]), - LastEntryHash: hex.EncodeToString(m.LastEntryHash[:]), - HeadSeq: m.HeadSeq, - HeadHash: hex.EncodeToString(m.HeadHash[:]), - KeysetDigest: hex.EncodeToString(m.KeysetDigest[:]), - SignerKID: m.SignerKID, - Signature: hex.EncodeToString(m.Signature[:]), - } - return json.Marshal(line) -} - -// ParseManifestLine decodes one JSONL manifest line, rejecting any -// format_version other than ExportFormatVersion. -func ParseManifestLine(line []byte) (ExportManifest, error) { - var jl manifestLineJSON - if err := json.Unmarshal(line, &jl); err != nil { - return ExportManifest{}, fmt.Errorf("receipt: parse manifest line: %w", err) - } - if jl.FormatVersion != ExportFormatVersion { - return ExportManifest{}, fmt.Errorf("%w: got %d, want %d", ErrUnsupportedFormatVersion, jl.FormatVersion, ExportFormatVersion) - } - m := ExportManifest{ - FormatVersion: jl.FormatVersion, - RequestedFrom: jl.RequestedFrom, - RequestedTo: jl.RequestedTo, - ResolvedTo: jl.ResolvedTo, - Count: jl.Count, - AnchorSeq: jl.AnchorSeq, - HeadSeq: jl.HeadSeq, - SignerKID: jl.SignerKID, - } - for _, f := range []struct { - name string - hex string - dst []byte - }{ - {"anchor_hash", jl.AnchorHash, m.AnchorHash[:]}, - {"first_entry_hash", jl.FirstEntryHash, m.FirstEntryHash[:]}, - {"last_entry_hash", jl.LastEntryHash, m.LastEntryHash[:]}, - {"head_hash", jl.HeadHash, m.HeadHash[:]}, - {"keyset_digest", jl.KeysetDigest, m.KeysetDigest[:]}, - {"signature", jl.Signature, m.Signature[:]}, - } { - if err := decodeFixed(f.hex, f.dst); err != nil { - return ExportManifest{}, fmt.Errorf("receipt: manifest %s: %w", f.name, err) - } - } - return m, nil -} - -type keyLineJSON struct { - Type string `json:"type"` - KID string `json:"kid"` - PublicKeyHex string `json:"public_key_hex"` - ValidFromSeq uint64 `json:"valid_from_seq"` - ValidUntilSeq *uint64 `json:"valid_until_seq"` -} - -// MarshalKeyLine encodes k as one JSONL line. -func MarshalKeyLine(k TrustedKey) ([]byte, error) { - return json.Marshal(keyLineJSON{ - Type: "key", - KID: k.KID, - PublicKeyHex: hex.EncodeToString(k.PublicKey), - ValidFromSeq: k.ValidFromSeq, - ValidUntilSeq: k.ValidUntilSeq, - }) -} +var ( + ErrManifestSignatureInvalid = receiptspec.ErrManifestSignatureInvalid + ErrKeysetDigestMismatch = receiptspec.ErrKeysetDigestMismatch +) -// ParseKeyLine decodes one JSONL key line into a TrustedKey. -func ParseKeyLine(line []byte) (TrustedKey, error) { - var jl keyLineJSON - if err := json.Unmarshal(line, &jl); err != nil { - return TrustedKey{}, fmt.Errorf("receipt: parse key line: %w", err) - } - pub, err := hex.DecodeString(jl.PublicKeyHex) - if err != nil || len(pub) != ed25519.PublicKeySize { - return TrustedKey{}, fmt.Errorf("receipt: key line %s: public_key_hex must be %d bytes hex", jl.KID, ed25519.PublicKeySize) - } - return TrustedKey{ - KID: jl.KID, - PublicKey: ed25519.PublicKey(pub), - ValidFromSeq: jl.ValidFromSeq, - ValidUntilSeq: jl.ValidUntilSeq, - }, nil -} +// ExportManifest is receiptspec.ExportManifest. +type ExportManifest = receiptspec.ExportManifest + +var ( + ComputeKeysetDigest = receiptspec.ComputeKeysetDigest + ComputeManifestHash = receiptspec.ComputeManifestHash + SignManifest = receiptspec.SignManifest + VerifyManifest = receiptspec.VerifyManifest + DetectJSONLLineType = receiptspec.DetectJSONLLineType + MarshalManifestLine = receiptspec.MarshalManifestLine + ParseManifestLine = receiptspec.ParseManifestLine + MarshalKeyLine = receiptspec.MarshalKeyLine + ParseKeyLine = receiptspec.ParseKeyLine +) diff --git a/internal/receipt/jsonl.go b/internal/receipt/jsonl.go index 56a918c..eb2b3b7 100644 --- a/internal/receipt/jsonl.go +++ b/internal/receipt/jsonl.go @@ -5,123 +5,13 @@ Licensed under the Apache License, Version 2.0. package receipt -import ( - "encoding/hex" - "encoding/json" - "errors" - "fmt" -) - -// JSONLFormatVersion is the only format_version this milestone's JSONL -// reader and writer accept. -const JSONLFormatVersion = 1 - -// ErrUnsupportedFormatVersion is returned when a JSONL line or SQLite row -// declares a format_version this build does not understand (VER-09). -var ErrUnsupportedFormatVersion = errors.New("receipt: unsupported format_version") - -// jsonlReceiptV1 is the on-disk JSONL encoding of one Receipt. Fixed-length -// binary fields are lowercase hex, matching the committed research's -// "use lowercase hexadecimal for fixed binary values in JSONL" guidance — -// the canonical hash never depends on this encoding's field order. -type jsonlReceiptV1 struct { - Type string `json:"type,omitempty"` - FormatVersion int `json:"format_version"` - Seq uint64 `json:"seq"` - TimestampUnixNS uint64 `json:"timestamp_unix_ns"` - HumanPrincipal string `json:"human_principal"` - AgentKeyID string `json:"agent_key_id"` - DelegationChain []string `json:"delegation_chain"` - Service string `json:"service"` - Action string `json:"action"` - ParamsSHA256 string `json:"params_sha256"` - PolicyDecision string `json:"policy_decision"` - StatusCode int `json:"status_code"` - LatencyMS int64 `json:"latency_ms"` - Error string `json:"error"` - PrevHash string `json:"prev_hash"` - EntryHash string `json:"entry_hash"` - SignerKID string `json:"signer_kid"` - Signature string `json:"signature"` -} +import "github.com/Clawdlinux/agentgate/pkg/receiptspec" -// MarshalJSONLReceipt encodes r as one JSONL line (no trailing newline). -func MarshalJSONLReceipt(r Receipt) ([]byte, error) { - line := jsonlReceiptV1{ - Type: "receipt", - FormatVersion: JSONLFormatVersion, - Seq: r.Seq, - TimestampUnixNS: r.TimestampUnixNS, - HumanPrincipal: r.HumanPrincipal, - AgentKeyID: r.AgentKeyID, - DelegationChain: r.DelegationChain, - Service: r.Service, - Action: r.Action, - ParamsSHA256: hex.EncodeToString(r.ParamsSHA256[:]), - PolicyDecision: r.PolicyDecision, - StatusCode: r.StatusCode, - LatencyMS: r.LatencyMS, - Error: r.Error, - PrevHash: hex.EncodeToString(r.PrevHash[:]), - EntryHash: hex.EncodeToString(r.EntryHash[:]), - SignerKID: r.SignerKID, - Signature: hex.EncodeToString(r.Signature[:]), - } - return json.Marshal(line) -} +// JSONLFormatVersion is receiptspec.JSONLFormatVersion. +const JSONLFormatVersion = receiptspec.JSONLFormatVersion -// ParseJSONLReceipt decodes one JSONL line into a Receipt. It rejects any -// format_version other than JSONLFormatVersion and any fixed-length field -// whose decoded byte length does not match the protocol. -func ParseJSONLReceipt(line []byte) (Receipt, error) { - var jl jsonlReceiptV1 - if err := json.Unmarshal(line, &jl); err != nil { - return Receipt{}, fmt.Errorf("receipt: parse jsonl line: %w", err) - } - if jl.FormatVersion != JSONLFormatVersion { - return Receipt{}, fmt.Errorf("%w: got %d, want %d", ErrUnsupportedFormatVersion, jl.FormatVersion, JSONLFormatVersion) - } - - r := Receipt{ - Seq: jl.Seq, - TimestampUnixNS: jl.TimestampUnixNS, - HumanPrincipal: jl.HumanPrincipal, - AgentKeyID: jl.AgentKeyID, - DelegationChain: jl.DelegationChain, - Service: jl.Service, - Action: jl.Action, - PolicyDecision: jl.PolicyDecision, - StatusCode: jl.StatusCode, - LatencyMS: jl.LatencyMS, - Error: jl.Error, - SignerKID: jl.SignerKID, - } - - for _, f := range []struct { - name string - hex string - dst []byte - }{ - {"params_sha256", jl.ParamsSHA256, r.ParamsSHA256[:]}, - {"prev_hash", jl.PrevHash, r.PrevHash[:]}, - {"entry_hash", jl.EntryHash, r.EntryHash[:]}, - {"signature", jl.Signature, r.Signature[:]}, - } { - if err := decodeFixed(f.hex, f.dst); err != nil { - return Receipt{}, fmt.Errorf("receipt: %s: %w", f.name, err) - } - } - return r, nil -} - -func decodeFixed(hexStr string, dst []byte) error { - decoded, err := hex.DecodeString(hexStr) - if err != nil { - return err - } - if len(decoded) != len(dst) { - return fmt.Errorf("want %d bytes, got %d", len(dst), len(decoded)) - } - copy(dst, decoded) - return nil -} +var ( + ErrUnsupportedFormatVersion = receiptspec.ErrUnsupportedFormatVersion + MarshalJSONLReceipt = receiptspec.MarshalJSONLReceipt + ParseJSONLReceipt = receiptspec.ParseJSONLReceipt +) diff --git a/internal/receipt/ledger.go b/internal/receipt/ledger.go index 8f9ee37..e7de201 100644 --- a/internal/receipt/ledger.go +++ b/internal/receipt/ledger.go @@ -7,6 +7,7 @@ package receipt import ( "context" + "crypto/ed25519" "database/sql" "encoding/json" "errors" @@ -14,6 +15,7 @@ import ( "time" "github.com/Clawdlinux/agentgate/internal/signer" + "github.com/Clawdlinux/agentgate/pkg/receiptspec" ) // ErrLedgerAppend wraps every failure returned by Ledger.Append. Callers @@ -108,8 +110,8 @@ func (l *Ledger) Append(ctx context.Context, draft Draft) (Receipt, error) { return Receipt{}, fmt.Errorf("%w: signer key: %v", ErrLedgerAppend, err) } - r := Receipt{ - Seq: head + 1, + chain := receiptspec.NewChain(storeSigner{kid: keyRecord.KID, priv: priv}, head, prevHash) + r, err := chain.Next(receiptspec.Fields{ TimestampUnixNS: uint64(l.nowFn().UnixNano()), HumanPrincipal: draft.HumanPrincipal, AgentKeyID: draft.AgentKeyID, @@ -121,16 +123,10 @@ func (l *Ledger) Append(ctx context.Context, draft Draft) (Receipt, error) { StatusCode: draft.StatusCode, LatencyMS: draft.LatencyMS, Error: draft.Error, - PrevHash: prevHash, - SignerKID: keyRecord.KID, - } - - entryHash, err := ComputeEntryHash(r) + }) if err != nil { return Receipt{}, fmt.Errorf("%w: compute hash: %v", ErrLedgerAppend, err) } - r.EntryHash = entryHash - r.Signature = signer.Sign(priv, entryHash[:]) if err := insertReceipt(ctx, conn, r); err != nil { return Receipt{}, fmt.Errorf("%w: insert: %v", ErrLedgerAppend, err) @@ -143,6 +139,19 @@ func (l *Ledger) Append(ctx context.Context, draft Draft) (Receipt, error) { return r, nil } +// storeSigner signs with the signer store's active key under its stored KID. +type storeSigner struct { + kid string + priv ed25519.PrivateKey +} + +func (s storeSigner) KID() string { return s.kid } + +func (s storeSigner) Sign(msg []byte) ([]byte, error) { + sig := signer.Sign(s.priv, msg) + return sig[:], nil +} + // headReader is satisfied by both *sql.DB and *sql.Conn, so readHead can // run either as a standalone query (Head) or inside an open transaction on // a pinned connection (Append). diff --git a/internal/receipt/receipt.go b/internal/receipt/receipt.go index 9992e34..9e2f74c 100644 --- a/internal/receipt/receipt.go +++ b/internal/receipt/receipt.go @@ -3,109 +3,20 @@ Copyright 2026 Clawdlinux. Licensed under the Apache License, Version 2.0. */ -// Package receipt defines AgentGate's deterministic action receipt protocol. +// Package receipt is AgentGate's gateway-side receipt plumbing: the SQLite +// ledger, the export handler, and params digesting. The protocol itself +// lives in pkg/receiptspec; the names below forward to it. package receipt -import ( - "errors" - "slices" - "strings" - "unicode/utf8" -) +import "github.com/Clawdlinux/agentgate/pkg/receiptspec" + +// Receipt is receiptspec.Receipt. +type Receipt = receiptspec.Receipt var ( - ErrInvalidReceipt = errors.New("receipt: invalid receipt") - ErrInvalidField = errors.New("receipt: invalid field") + ErrInvalidReceipt = receiptspec.ErrInvalidReceipt + ErrInvalidField = receiptspec.ErrInvalidField ) -// Receipt is the semantic action receipt in canonical protocol order. -type Receipt struct { - Seq uint64 - TimestampUnixNS uint64 - HumanPrincipal string - AgentKeyID string - DelegationChain []string - Service string - Action string - ParamsSHA256 [32]byte - PolicyDecision string - StatusCode int - LatencyMS int64 - Error string - PrevHash [32]byte - EntryHash [32]byte - SignerKID string - Signature [64]byte -} - -// Validate checks that receipt satisfies the v1 semantic contract. -func Validate(receipt Receipt) error { - receipt = snapshot(receipt) - - if receipt.Seq == 0 || receipt.TimestampUnixNS == 0 { - return ErrInvalidReceipt - } - if receipt.StatusCode < 100 || receipt.StatusCode > 599 || receipt.LatencyMS < 0 { - return ErrInvalidReceipt - } - - if !validRequiredUTF8(receipt.HumanPrincipal, 256) || - !validRequiredUTF8(receipt.AgentKeyID, 128) || - !validRequiredUTF8(receipt.Service, 64) || - !validRequiredUTF8(receipt.Action, 128) || - !validRequiredUTF8(receipt.SignerKID, 128) { - return ErrInvalidField - } - - switch receipt.PolicyDecision { - case "allow", "deny", "rate_limited": - default: - return ErrInvalidField - } - - if len(receipt.DelegationChain) > 32 { - return ErrInvalidField - } - for _, element := range receipt.DelegationChain { - if len(element) > 64 || !isASCII(element) || strings.IndexByte(element, 0) >= 0 { - return ErrInvalidField - } - } - - if receipt.Error != "" && !validErrorCode(receipt.Error) { - return ErrInvalidField - } - - return nil -} - -func snapshot(receipt Receipt) Receipt { - receipt.DelegationChain = slices.Clone(receipt.DelegationChain) - return receipt -} - -func validRequiredUTF8(value string, maximumBytes int) bool { - return value != "" && len(value) <= maximumBytes && utf8.ValidString(value) && strings.IndexByte(value, 0) < 0 -} - -func validErrorCode(value string) bool { - if len(value) == 0 || len(value) > 64 { - return false - } - for index := 0; index < len(value); index++ { - character := value[index] - if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '_' { - return false - } - } - return true -} - -func isASCII(value string) bool { - for index := 0; index < len(value); index++ { - if value[index] > 0x7f { - return false - } - } - return true -} +// Validate forwards to receiptspec.Validate. +var Validate = receiptspec.Validate diff --git a/internal/receipt/record_jcs_test.go b/internal/receipt/record_jcs_test.go new file mode 100644 index 0000000..7a8190f --- /dev/null +++ b/internal/receipt/record_jcs_test.go @@ -0,0 +1,35 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipt + +import ( + "encoding/json" + "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +// HashRecord must agree with the JCS-based DigestParams on the subset it accepts. +func TestHashRecordMatchesDigestParams(t *testing.T) { + for _, in := range []string{ + `{"b":1,"a":2}`, + `{"z":{"y":[1,-2,"x",true,null]},"a":"caf\u00e9"}`, + `{"k":"\u0001\b\t\n\f\r\"\\/<>&"}`, + `{"😀":2,"fi":1,"a":9007199254740991}`, + } { + want, err := DigestParams([]byte(in)) + if err != nil { + t.Fatal(err) + } + got, err := receiptspec.HashRecord(json.RawMessage(in)) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("%s: HashRecord differs from DigestParams", in) + } + } +} diff --git a/internal/receipt/verifier.go b/internal/receipt/verifier.go index d46503b..daf2b24 100644 --- a/internal/receipt/verifier.go +++ b/internal/receipt/verifier.go @@ -5,257 +5,33 @@ Licensed under the Apache License, Version 2.0. package receipt -import ( - "crypto/ed25519" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "strconv" - "strings" +import "github.com/Clawdlinux/agentgate/pkg/receiptspec" - "github.com/Clawdlinux/agentgate/internal/signer" -) - -// Reason values are stable, ASCII, and never derived from a receipt's own -// fields — they are safe to print without leaking human_principal, -// agent_key_id, or any other receipted field (VER-10). const ( - ReasonSequenceGap = "sequence_gap_or_not_genesis_anchored" - ReasonPrevHashMismatch = "prev_hash_mismatch" - ReasonInvalidReceiptFields = "invalid_receipt_fields" - ReasonEntryHashMismatch = "entry_hash_mismatch" - ReasonSignatureInvalid = "signature_invalid" - ReasonSignerInactiveAtSeq = "signer_kid_inactive_at_seq" - ReasonExpectedHeadMismatch = "expected_head_mismatch" + ReasonSequenceGap = receiptspec.ReasonSequenceGap + ReasonPrevHashMismatch = receiptspec.ReasonPrevHashMismatch + ReasonInvalidReceiptFields = receiptspec.ReasonInvalidReceiptFields + ReasonEntryHashMismatch = receiptspec.ReasonEntryHashMismatch + ReasonSignatureInvalid = receiptspec.ReasonSignatureInvalid + ReasonSignerInactiveAtSeq = receiptspec.ReasonSignerInactiveAtSeq + ReasonExpectedHeadMismatch = receiptspec.ReasonExpectedHeadMismatch ) var ( - // ErrNoTrustedKeys means the trust file supplied no keys at all — a - // configuration error, not a tamper finding (VER-09). - ErrNoTrustedKeys = errors.New("receipt: no trusted keys supplied") - // ErrEmptyChain means the source supplied zero receipts (VER-09). - ErrEmptyChain = errors.New("receipt: empty receipt chain") - // ErrUnknownSignerKID means a receipt names a signer_kid absent from - // the trust file. This is a configuration/input error, not a tamper - // finding, because a verifier holding the wrong or incomplete trust - // file cannot distinguish "attacker's key" from "my trust file is - // stale" (VER-09). - ErrUnknownSignerKID = errors.New("receipt: unknown signer_kid") + ErrNoTrustedKeys = receiptspec.ErrNoTrustedKeys + ErrEmptyChain = receiptspec.ErrEmptyChain + ErrUnknownSignerKID = receiptspec.ErrUnknownSignerKID ) -// TrustedKey is one entry in an auditor's offline trust file: the public -// half of a signer key plus its validity interval, no private material. -// Its shape mirrors internal/signer.KeyRecord's public fields, so a trust -// file can be built directly from GET /v1/receipts/pubkey's response. -// -// Trust model note: internal/signer (Phase 3) does not sign a rotation -// transition between keys — every KEY-0X requirement is satisfied without -// one. VerifyChain therefore cannot cryptographically chain trust from one -// pinned root key to later rotated keys. Instead, the auditor's trust file -// must contain every key that has ever signed a receipt in the range being -// checked, obtained once through a trusted channel. "Validates historical -// key transitions" (VER-03) is satisfied by binding each receipt's -// signer_kid to that key's declared [ValidFromSeq, ValidUntilSeq] sequence -// interval (inclusive; a nil ValidUntilSeq means still active) — a -// receipt signed by a key outside its own validity window fails -// verification — rather than by verifying a signed transition payload -// that this milestone's signer never produces. -type TrustedKey struct { - KID string - PublicKey ed25519.PublicKey - ValidFromSeq uint64 - ValidUntilSeq *uint64 // nil means open-ended (still active when the trust file was saved) -} - -type trustedKeyJSON struct { - KID string `json:"kid"` - PublicKeyHex string `json:"public_key_hex"` - ValidFromSeq uint64 `json:"valid_from_seq"` - ValidUntilSeq *uint64 `json:"valid_until_seq"` -} - -type trustFileJSON struct { - Keys []trustedKeyJSON `json:"keys"` -} - -// LoadTrustedKeys parses a trust file with the exact shape -// GET /v1/receipts/pubkey serves (`{"keys": [...]}`, `public_key_hex`) so -// an operator can save that endpoint's response directly as the trust -// file, through a trusted channel, with no field translation. The offline -// verifier never fetches this over the network itself. -func LoadTrustedKeys(data []byte) ([]TrustedKey, error) { - var file trustFileJSON - if err := json.Unmarshal(data, &file); err != nil { - return nil, fmt.Errorf("receipt: parse trust file: %w", err) - } - if len(file.Keys) == 0 { - return nil, ErrNoTrustedKeys - } - out := make([]TrustedKey, 0, len(file.Keys)) - for _, k := range file.Keys { - pub, err := hex.DecodeString(k.PublicKeyHex) - if err != nil || len(pub) != ed25519.PublicKeySize { - return nil, fmt.Errorf("receipt: trust file key %s: public_key must be %d bytes hex", k.KID, ed25519.PublicKeySize) - } - out = append(out, TrustedKey{ - KID: k.KID, - PublicKey: ed25519.PublicKey(pub), - ValidFromSeq: k.ValidFromSeq, - ValidUntilSeq: k.ValidUntilSeq, - }) - } - return out, nil -} - -// ExpectedHead pins a separately trusted (seq, entry_hash) pair. Supplying -// one lets VerifyChain claim the checked range is complete rather than -// merely internally consistent: an internally consistent chain cannot by -// itself detect a deleted, unsigned suffix — a shorter chain that simply -// stops early looks identical to a complete one without an independently -// known head to compare against. -type ExpectedHead struct { - Seq uint64 - EntryHash [32]byte -} - -// Anchor is the committed (seq, entry_hash) immediately before the first -// receipt VerifyChain checks. The zero value, Anchor{}, is genesis (Seq 0, -// a zero EntryHash) — whole-ledger verification's anchor. A bounded export -// starting at seq N uses the real (N-1)th receipt's own (seq, entry_hash) -// as its anchor, which is exactly what an export manifest signs. -type Anchor struct { - Seq uint64 - EntryHash [32]byte -} - -// ParseExpectedHead parses the --expected-head flag value "SEQ:HEXHASH". -func ParseExpectedHead(s string) (ExpectedHead, error) { - seqStr, hashStr, ok := strings.Cut(s, ":") - if !ok { - return ExpectedHead{}, errors.New("receipt: --expected-head must be SEQ:HEXHASH") - } - seq, err := strconv.ParseUint(seqStr, 10, 64) - if err != nil { - return ExpectedHead{}, fmt.Errorf("receipt: --expected-head seq: %w", err) - } - decoded, err := hex.DecodeString(hashStr) - if err != nil || len(decoded) != 32 { - return ExpectedHead{}, errors.New("receipt: --expected-head hash must be 32 bytes hex") - } - var out ExpectedHead - out.Seq = seq - copy(out.EntryHash[:], decoded) - return out, nil -} - -// VerifyResult reports the outcome of one VerifyChain call. It is safe to -// print in full: every field is either a count, a sequence number, or one -// of the fixed Reason* constants (VER-10). -type VerifyResult struct { - OK bool - TotalReceipts int - VerifiedCount int - FailedAtSeq uint64 // 0 only when OK and no failure occurred - Reason string // one of the Reason* constants; empty when OK - HeadSeq uint64 - HeadEntryHash [32]byte - // Complete is true only when an ExpectedHead was supplied and matched - // the final receipt exactly (VER-11). False does not mean the chain is - // tampered — it means completeness was never claimed. - Complete bool -} - -// VerifyChain checks receipts — already ordered by ascending Seq — against -// trustedKeys, starting from anchor (the committed state immediately -// before the first receipt). A returned error means the input or -// configuration itself could not be checked at all (VER-09: empty -// sources, unknown key IDs, unsupported versions) — the caller should -// treat that as an I/O/config failure, not a tamper finding. A returned -// VerifyResult with OK == false means verification ran and found a -// mismatch (VER-05 through VER-08). -// -// Pass Anchor{} for genesis (a whole local ledger or a full JSONL dump of -// one) — this reproduces Phase 5's original always-genesis behavior -// exactly. A bounded export passes the real (seq, entry_hash) of the -// receipt immediately preceding the exported range, from its signed -// manifest, so a partial range verifies without requiring the whole -// ledger. -// -// If expectedHead is non-nil, VerifyChain additionally requires the final -// receipt to match it exactly before claiming completeness (VER-11). -func VerifyChain(receipts []Receipt, trustedKeys []TrustedKey, anchor Anchor, expectedHead *ExpectedHead) (VerifyResult, error) { - if len(trustedKeys) == 0 { - return VerifyResult{}, ErrNoTrustedKeys - } - if len(receipts) == 0 { - return VerifyResult{}, ErrEmptyChain - } - - byKID := make(map[string]TrustedKey, len(trustedKeys)) - for _, k := range trustedKeys { - byKID[k.KID] = k - } - - result := VerifyResult{TotalReceipts: len(receipts)} - wantSeq := anchor.Seq + 1 - prevHash := anchor.EntryHash - for i := range receipts { - r := receipts[i] - - if r.Seq != wantSeq+uint64(i) { - result.FailedAtSeq = r.Seq - result.Reason = ReasonSequenceGap - return result, nil - } - if r.PrevHash != prevHash { - result.FailedAtSeq = r.Seq - result.Reason = ReasonPrevHashMismatch - return result, nil - } - - key, ok := byKID[r.SignerKID] - if !ok { - return VerifyResult{}, fmt.Errorf("%w: %s", ErrUnknownSignerKID, r.SignerKID) - } - if r.Seq < key.ValidFromSeq || (key.ValidUntilSeq != nil && r.Seq > *key.ValidUntilSeq) { - result.FailedAtSeq = r.Seq - result.Reason = ReasonSignerInactiveAtSeq - return result, nil - } - - entryHash, err := ComputeEntryHash(r) - if err != nil { - result.FailedAtSeq = r.Seq - result.Reason = ReasonInvalidReceiptFields - return result, nil - } - if entryHash != r.EntryHash { - result.FailedAtSeq = r.Seq - result.Reason = ReasonEntryHashMismatch - return result, nil - } - if !signer.Verify(key.PublicKey, entryHash[:], r.Signature) { - result.FailedAtSeq = r.Seq - result.Reason = ReasonSignatureInvalid - return result, nil - } - - prevHash = entryHash - result.VerifiedCount++ - result.HeadSeq = r.Seq - result.HeadEntryHash = entryHash - } +type ( + TrustedKey = receiptspec.TrustedKey + ExpectedHead = receiptspec.ExpectedHead + Anchor = receiptspec.Anchor + VerifyResult = receiptspec.VerifyResult +) - result.OK = true - if expectedHead != nil { - if result.HeadSeq == expectedHead.Seq && result.HeadEntryHash == expectedHead.EntryHash { - result.Complete = true - } else { - result.OK = false - result.FailedAtSeq = expectedHead.Seq - result.Reason = ReasonExpectedHeadMismatch - } - } - return result, nil -} +var ( + LoadTrustedKeys = receiptspec.LoadTrustedKeys + ParseExpectedHead = receiptspec.ParseExpectedHead + VerifyChain = receiptspec.VerifyChain +) diff --git a/internal/signer/signer.go b/internal/signer/signer.go index 081fa89..2b84924 100644 --- a/internal/signer/signer.go +++ b/internal/signer/signer.go @@ -10,10 +10,10 @@ package signer import ( "crypto/ed25519" - "crypto/sha256" - "encoding/hex" "errors" "time" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" ) // ErrKeyUnreadable is returned when persisted key material exists but @@ -30,6 +30,7 @@ var ErrNoActiveKey = errors.New("signer: no active key") // not strictly exceed the current active key's starting sequence. var ErrRotationSequence = errors.New("signer: rotation sequence must exceed the active key's valid_from_seq") +// kidPrefix is the prefix receiptspec.ComputeKID puts on every key ID. const kidPrefix = "ed25519:" // KeyRecord describes one signing key's public identity and validity @@ -47,8 +48,7 @@ type KeyRecord struct { // to the public key bytes, so any holder of the public key can reproduce it // without gateway state. func ComputeKID(pub ed25519.PublicKey) string { - digest := sha256.Sum256(pub) - return kidPrefix + hex.EncodeToString(digest[:8]) + return receiptspec.ComputeKID(pub) } // Sign returns the Ed25519 signature of message under priv. diff --git a/pkg/receiptspec/DECISION.md b/pkg/receiptspec/DECISION.md new file mode 100644 index 0000000..2b295da --- /dev/null +++ b/pkg/receiptspec/DECISION.md @@ -0,0 +1,69 @@ +# Decision records from non-gateway producers + +A producer that is not the AgentGate gateway (for example the Clawdlinux +operator) can record an arbitrary decision as a normal v1 receipt. The wire +format does not change. Any existing `agentgate-verify` checks the chain. + +## What goes in the signed receipt + +| Field | Value | +|---|---| +| `Service` | The producer, for example `operator`. Max 64 bytes. | +| `Action` | `decision:`, for example `decision:scale`. Max 128 bytes. | +| `PolicyDecision` | One of the existing values: `allow`, `deny`, `rate_limited`. | +| `ParamsSHA256` | `HashRecord(record)`, the SHA-256 of the canonical decision record. | +| `StatusCode` | Any HTTP-style code 100 to 599 that fits, for example 200 or 202. | + +Build receipts with `receiptspec.NewChain(signer, prevSeq, prevHash).Next(fields)`. +Store each receipt durably before acting on it. + +## The decision record + +The record is a JSON object stored beside the receipt (same row, same bucket, +same JSONL bundle). The receipt only carries its hash. A verifier holding the +record calls `VerifyRecordBinding(receipt, recordJSON)` after the chain verifies. + +Keep the full outcome in the record. `PolicyDecision` is a coarse, fixed enum +that older verifiers enforce. Adding values to it would make those verifiers +reject new receipts, so it stays as is. Map outcomes like this: + +| Operator outcome | `PolicyDecision` | Record `outcome` | +|---|---|---| +| allow | `allow` | `allow` | +| approved | `allow` | `approved` | +| edited | `allow` | `edited` | +| deny | `deny` | `deny` | +| rejected | `deny` | `rejected` | +| require_approval | `deny` | `require_approval` | + +Rule: `allow` means the action went ahead. `deny` means it did not, yet. +An approval flow writes two receipts: `require_approval` (deny), then +`approved`, `edited`, or `rejected`. Link them with a field in the record, +for example `"approval_of_seq": 41`. + +Example record: + +```json +{"record_version":1,"outcome":"edited","approval_of_seq":41,"confidence":"0.875","replicas":3} +``` + +## Canonical JSON + +`CanonicalRecord` and `HashRecord` follow RFC 8785 (JCS) for the subset they +accept: + +- Object keys sorted by UTF-16 code units. No duplicate keys. +- No insignificant whitespace. +- Strings as literal UTF-8. Only `"`, `\`, and control characters are escaped. + Lone surrogate escapes and invalid UTF-8 are rejected. +- Numbers must be integers in `[-(2^53-1), 2^53-1]`. Floats, exponents, and + `-0` are rejected with `ErrUnsupportedNumber`. +- Max nesting depth 32. Max canonical size 1 MiB. + +Encode fractions as decimal strings (`"0.875"`) or integer micro-units +(`875000`). Never as floats. Go `float64` fields that hold whole numbers pass; +fractional ones fail, so do not rely on that. + +Go structs, maps, and `json.RawMessage` that carry the same content hash the +same. A test checks the output against the JCS library the gateway uses for +params. diff --git a/pkg/receiptspec/chain.go b/pkg/receiptspec/chain.go new file mode 100644 index 0000000..9e568dc --- /dev/null +++ b/pkg/receiptspec/chain.go @@ -0,0 +1,74 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receiptspec + +import "time" + +// Fields are the producer-supplied parts of a receipt. Chain fills in Seq, +// PrevHash, EntryHash, SignerKID, and Signature. +type Fields struct { + TimestampUnixNS uint64 // 0 means time.Now() + HumanPrincipal string + AgentKeyID string + DelegationChain []string + Service string + Action string + ParamsSHA256 [32]byte + PolicyDecision string + StatusCode int + LatencyMS int64 + Error string +} + +// Chain is an in-memory producer of consecutive signed receipts. It is not +// safe for concurrent use and does not persist anything: store each returned +// receipt durably before acting on it, and recreate the Chain from the last +// stored (seq, entry_hash) after a restart. +type Chain struct { + signer Signer + headSeq uint64 + headHash [32]byte +} + +// NewChain returns a Chain whose next receipt follows (prevSeq, prevHash). +// Use 0 and the zero hash to start at genesis. +func NewChain(s Signer, prevSeq uint64, prevHash [32]byte) *Chain { + return &Chain{signer: s, headSeq: prevSeq, headHash: prevHash} +} + +// Head returns the last appended (seq, entry_hash). +func (c *Chain) Head() (uint64, [32]byte) { + return c.headSeq, c.headHash +} + +// Next builds, validates, and signs the receipt after Head. The head only +// advances on success. +func (c *Chain) Next(f Fields) (Receipt, error) { + ts := f.TimestampUnixNS + if ts == 0 { + ts = uint64(time.Now().UnixNano()) + } + r, err := Seal(Receipt{ + Seq: c.headSeq + 1, + TimestampUnixNS: ts, + HumanPrincipal: f.HumanPrincipal, + AgentKeyID: f.AgentKeyID, + DelegationChain: f.DelegationChain, + Service: f.Service, + Action: f.Action, + ParamsSHA256: f.ParamsSHA256, + PolicyDecision: f.PolicyDecision, + StatusCode: f.StatusCode, + LatencyMS: f.LatencyMS, + Error: f.Error, + PrevHash: c.headHash, + }, c.signer) + if err != nil { + return Receipt{}, err + } + c.headSeq, c.headHash = r.Seq, r.EntryHash + return r, nil +} diff --git a/pkg/receiptspec/chain_test.go b/pkg/receiptspec/chain_test.go new file mode 100644 index 0000000..3200a13 --- /dev/null +++ b/pkg/receiptspec/chain_test.go @@ -0,0 +1,245 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receiptspec_test + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "strconv" + "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +func testSigner(t *testing.T) (receiptspec.Signer, ed25519.PublicKey) { + t.Helper() + priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize)) + return receiptspec.NewEd25519Signer(priv), priv.Public().(ed25519.PublicKey) +} + +func fields(i int) receiptspec.Fields { + return receiptspec.Fields{ + TimestampUnixNS: uint64(1_700_000_000_000_000_000 + i), + HumanPrincipal: "alice@example.com", + AgentKeyID: "agent-1", + Service: "operator", + Action: "decision:scale", + PolicyDecision: receiptspec.DecisionAllow, + StatusCode: 200, + LatencyMS: int64(i), + } +} + +func buildChain(t *testing.T, n int) ([]receiptspec.Receipt, receiptspec.Signer, ed25519.PublicKey) { + t.Helper() + s, pub := testSigner(t) + c := receiptspec.NewChain(s, 0, [32]byte{}) + out := make([]receiptspec.Receipt, 0, n) + for i := 0; i < n; i++ { + r, err := c.Next(fields(i)) + if err != nil { + t.Fatal(err) + } + out = append(out, r) + } + return out, s, pub +} + +func TestChainMatchesGoldenVector(t *testing.T) { + var m struct { + BinaryFile string `json:"binary_file"` + Receipt struct { + Seq string `json:"seq"` + TimestampUnixNS string `json:"timestamp_unix_ns"` + HumanPrincipal string `json:"human_principal"` + AgentKeyID string `json:"agent_key_id"` + DelegationChain []string `json:"delegation_chain"` + Service string `json:"service"` + Action string `json:"action"` + ParamsSHA256 string `json:"params_sha256"` + PolicyDecision string `json:"policy_decision"` + StatusCode int `json:"status_code"` + LatencyMS string `json:"latency_ms"` + Error string `json:"error"` + PrevHash string `json:"prev_hash"` + SignerKID string `json:"signer_kid"` + } `json:"receipt"` + } + raw, err := os.ReadFile("../../internal/receipt/testdata/v1/manifest.json") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + golden, err := os.ReadFile("../../internal/receipt/testdata/v1/" + m.BinaryFile) + if err != nil { + t.Fatal(err) + } + seq, _ := strconv.ParseUint(m.Receipt.Seq, 10, 64) + ts, _ := strconv.ParseUint(m.Receipt.TimestampUnixNS, 10, 64) + latency, _ := strconv.ParseInt(m.Receipt.LatencyMS, 10, 64) + var prev, params [32]byte + mustHex(t, m.Receipt.PrevHash, prev[:]) + mustHex(t, m.Receipt.ParamsSHA256, params[:]) + + _, pub := testSigner(t) + s := kidSigner{kid: m.Receipt.SignerKID, priv: ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize))} + c := receiptspec.NewChain(s, seq-1, prev) + r, err := c.Next(receiptspec.Fields{ + TimestampUnixNS: ts, HumanPrincipal: m.Receipt.HumanPrincipal, AgentKeyID: m.Receipt.AgentKeyID, + DelegationChain: m.Receipt.DelegationChain, Service: m.Receipt.Service, Action: m.Receipt.Action, + ParamsSHA256: params, PolicyDecision: m.Receipt.PolicyDecision, StatusCode: m.Receipt.StatusCode, + LatencyMS: latency, Error: m.Receipt.Error, + }) + if err != nil { + t.Fatal(err) + } + input, err := receiptspec.CanonicalHashInput(r) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(input, golden) { + t.Fatal("Chain receipt preimage differs from the golden vector") + } + if r.EntryHash != sha256.Sum256(golden) { + t.Fatal("entry hash differs from SHA-256 of the golden vector") + } + if err := receiptspec.VerifyReceipt(r, pub); err != nil { + t.Fatal(err) + } +} + +type kidSigner struct { + kid string + priv ed25519.PrivateKey +} + +func (s kidSigner) KID() string { return s.kid } +func (s kidSigner) Sign(msg []byte) ([]byte, error) { return ed25519.Sign(s.priv, msg), nil } + +func mustHex(t *testing.T, s string, dst []byte) { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil || len(b) != len(dst) { + t.Fatalf("bad hex %q", s) + } + copy(dst, b) +} + +func TestChainJSONLRoundTrip(t *testing.T) { + receipts, s, pub := buildChain(t, 5) + + var buf bytes.Buffer + if err := receiptspec.WriteJSONL(&buf, receipts); err != nil { + t.Fatal(err) + } + trustJSON, _ := json.Marshal(map[string]any{"keys": []map[string]any{{ + "kid": s.KID(), "public_key_hex": hex.EncodeToString(pub), "valid_from_seq": 1, "valid_until_seq": nil, + }}}) + trusted, err := receiptspec.LoadTrustedKeys(trustJSON) + if err != nil { + t.Fatal(err) + } + + bundle, err := receiptspec.ReadJSONL(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + head := receiptspec.ExpectedHead{Seq: 5, EntryHash: receipts[4].EntryHash} + result, err := receiptspec.VerifyBundle(bundle, trusted, &head) + if err != nil { + t.Fatal(err) + } + if !result.OK || !result.Complete || result.VerifiedCount != 5 { + t.Fatalf("result = %+v", result) + } + + bundle.Receipts[2].LatencyMS++ + result, err = receiptspec.VerifyBundle(bundle, trusted, nil) + if err != nil { + t.Fatal(err) + } + if result.OK || result.FailedAtSeq != 3 || result.Reason != receiptspec.ReasonEntryHashMismatch { + t.Fatalf("tampered result = %+v", result) + } +} + +func TestChainHeadAdvancesOnlyOnSuccess(t *testing.T) { + s, _ := testSigner(t) + c := receiptspec.NewChain(s, 0, [32]byte{}) + first, err := c.Next(fields(0)) + if err != nil { + t.Fatal(err) + } + bad := fields(1) + bad.PolicyDecision = "maybe" + if _, err := c.Next(bad); !errors.Is(err, receiptspec.ErrInvalidField) { + t.Fatalf("err = %v, want ErrInvalidField", err) + } + if seq, hash := c.Head(); seq != 1 || hash != first.EntryHash { + t.Fatalf("head moved after failed Next: %d", seq) + } + second, err := c.Next(fields(1)) + if err != nil || second.Seq != 2 || second.PrevHash != first.EntryHash { + t.Fatalf("second = %+v, err = %v", second, err) + } +} + +type shortSigner struct{} + +func (shortSigner) KID() string { return "ed25519:short" } +func (shortSigner) Sign([]byte) ([]byte, error) { return []byte{1, 2, 3}, nil } + +func TestSealRejectsMalformedSignature(t *testing.T) { + c := receiptspec.NewChain(shortSigner{}, 0, [32]byte{}) + if _, err := c.Next(fields(0)); !errors.Is(err, receiptspec.ErrSignature) { + t.Fatalf("err = %v, want ErrSignature", err) + } +} + +func TestVerifyReceipt(t *testing.T) { + receipts, _, pub := buildChain(t, 1) + r := receipts[0] + if err := receiptspec.VerifyReceipt(r, pub); err != nil { + t.Fatal(err) + } + other := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{9}, ed25519.SeedSize)).Public().(ed25519.PublicKey) + if err := receiptspec.VerifyReceipt(r, other); !errors.Is(err, receiptspec.ErrSignature) { + t.Fatalf("wrong key err = %v", err) + } + r.Service = "other" + if err := receiptspec.VerifyReceipt(r, pub); !errors.Is(err, receiptspec.ErrEntryHashMismatch) { + t.Fatalf("tampered err = %v", err) + } +} + +func TestComputeKIDMatchesSigner(t *testing.T) { + s, pub := testSigner(t) + if s.KID() != receiptspec.ComputeKID(pub) { + t.Fatal("NewEd25519Signer KID differs from ComputeKID") + } +} + +func TestVerifyBundleManifestError(t *testing.T) { + receipts, s, pub := buildChain(t, 2) + key := receiptspec.TrustedKey{KID: s.KID(), PublicKey: pub, ValidFromSeq: 1} + m := receiptspec.ExportManifest{FormatVersion: 1, ResolvedTo: 2, Count: 2, LastEntryHash: receipts[1].EntryHash, + KeysetDigest: receiptspec.ComputeKeysetDigest([]receiptspec.TrustedKey{key})} + other := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{9}, ed25519.SeedSize)) + m = receiptspec.SignManifest(m, s.KID(), other) + + _, err := receiptspec.VerifyBundle(receiptspec.Bundle{Receipts: receipts, Keys: []receiptspec.TrustedKey{key}, Manifest: &m}, nil, nil) + var me *receiptspec.ManifestError + if !errors.As(err, &me) || !errors.Is(err, receiptspec.ErrManifestSignatureInvalid) { + t.Fatalf("err = %v, want ManifestError wrapping ErrManifestSignatureInvalid", err) + } +} diff --git a/pkg/receiptspec/encoding.go b/pkg/receiptspec/encoding.go new file mode 100644 index 0000000..0a66a94 --- /dev/null +++ b/pkg/receiptspec/encoding.go @@ -0,0 +1,59 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receiptspec + +import ( + "crypto/sha256" + "encoding/binary" +) + +// HashDomainV1 separates AgentGate receipt hashes from other protocols and versions. +const HashDomainV1 = "agentgate.receipt.hash.v1" + +// CanonicalHashInput returns the validated v1 entry-hash preimage. +// EntryHash and Signature are excluded; PrevHash and SignerKID are included. +func CanonicalHashInput(receipt Receipt) ([]byte, error) { + receipt = snapshot(receipt) + if err := Validate(receipt); err != nil { + return nil, err + } + + encoded := make([]byte, 0, 3110) + encoded = append(encoded, HashDomainV1...) + encoded = append(encoded, 0) + encoded = binary.LittleEndian.AppendUint64(encoded, receipt.Seq) + encoded = binary.LittleEndian.AppendUint64(encoded, receipt.TimestampUnixNS) + encoded = appendLP(encoded, receipt.HumanPrincipal) + encoded = appendLP(encoded, receipt.AgentKeyID) + encoded = binary.LittleEndian.AppendUint32(encoded, uint32(len(receipt.DelegationChain))) + for _, element := range receipt.DelegationChain { + encoded = appendLP(encoded, element) + } + encoded = appendLP(encoded, receipt.Service) + encoded = appendLP(encoded, receipt.Action) + encoded = append(encoded, receipt.ParamsSHA256[:]...) + encoded = appendLP(encoded, receipt.PolicyDecision) + encoded = binary.LittleEndian.AppendUint32(encoded, uint32(int32(receipt.StatusCode))) + encoded = binary.LittleEndian.AppendUint64(encoded, uint64(receipt.LatencyMS)) + encoded = appendLP(encoded, receipt.Error) + encoded = append(encoded, receipt.PrevHash[:]...) + encoded = appendLP(encoded, receipt.SignerKID) + return encoded, nil +} + +// ComputeEntryHash returns SHA-256 over the complete canonical hash input. +func ComputeEntryHash(receipt Receipt) ([32]byte, error) { + encoded, err := CanonicalHashInput(receipt) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(encoded), nil +} + +func appendLP(destination []byte, value string) []byte { + destination = binary.LittleEndian.AppendUint32(destination, uint32(len(value))) + return append(destination, value...) +} diff --git a/pkg/receiptspec/jsonl.go b/pkg/receiptspec/jsonl.go new file mode 100644 index 0000000..81df496 --- /dev/null +++ b/pkg/receiptspec/jsonl.go @@ -0,0 +1,206 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receiptspec + +import ( + "bufio" + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" +) + +// JSONLFormatVersion is the only format_version the JSONL reader and writer accept. +const JSONLFormatVersion = 1 + +// ErrUnsupportedFormatVersion is returned when a JSONL line or stored row +// declares a format_version this build does not understand. +var ErrUnsupportedFormatVersion = errors.New("receipt: unsupported format_version") + +// jsonlReceiptV1 is the JSONL encoding of one Receipt. Fixed-length binary +// fields are lowercase hex. The canonical hash never depends on this encoding. +type jsonlReceiptV1 struct { + Type string `json:"type,omitempty"` + FormatVersion int `json:"format_version"` + Seq uint64 `json:"seq"` + TimestampUnixNS uint64 `json:"timestamp_unix_ns"` + HumanPrincipal string `json:"human_principal"` + AgentKeyID string `json:"agent_key_id"` + DelegationChain []string `json:"delegation_chain"` + Service string `json:"service"` + Action string `json:"action"` + ParamsSHA256 string `json:"params_sha256"` + PolicyDecision string `json:"policy_decision"` + StatusCode int `json:"status_code"` + LatencyMS int64 `json:"latency_ms"` + Error string `json:"error"` + PrevHash string `json:"prev_hash"` + EntryHash string `json:"entry_hash"` + SignerKID string `json:"signer_kid"` + Signature string `json:"signature"` +} + +// MarshalJSONLReceipt encodes r as one JSONL line (no trailing newline). +func MarshalJSONLReceipt(r Receipt) ([]byte, error) { + line := jsonlReceiptV1{ + Type: "receipt", + FormatVersion: JSONLFormatVersion, + Seq: r.Seq, + TimestampUnixNS: r.TimestampUnixNS, + HumanPrincipal: r.HumanPrincipal, + AgentKeyID: r.AgentKeyID, + DelegationChain: r.DelegationChain, + Service: r.Service, + Action: r.Action, + ParamsSHA256: hex.EncodeToString(r.ParamsSHA256[:]), + PolicyDecision: r.PolicyDecision, + StatusCode: r.StatusCode, + LatencyMS: r.LatencyMS, + Error: r.Error, + PrevHash: hex.EncodeToString(r.PrevHash[:]), + EntryHash: hex.EncodeToString(r.EntryHash[:]), + SignerKID: r.SignerKID, + Signature: hex.EncodeToString(r.Signature[:]), + } + return json.Marshal(line) +} + +// ParseJSONLReceipt decodes one JSONL line into a Receipt. It rejects any +// format_version other than JSONLFormatVersion and any fixed-length field +// whose decoded byte length does not match the protocol. +func ParseJSONLReceipt(line []byte) (Receipt, error) { + var jl jsonlReceiptV1 + if err := json.Unmarshal(line, &jl); err != nil { + return Receipt{}, fmt.Errorf("receipt: parse jsonl line: %w", err) + } + if jl.FormatVersion != JSONLFormatVersion { + return Receipt{}, fmt.Errorf("%w: got %d, want %d", ErrUnsupportedFormatVersion, jl.FormatVersion, JSONLFormatVersion) + } + + r := Receipt{ + Seq: jl.Seq, + TimestampUnixNS: jl.TimestampUnixNS, + HumanPrincipal: jl.HumanPrincipal, + AgentKeyID: jl.AgentKeyID, + DelegationChain: jl.DelegationChain, + Service: jl.Service, + Action: jl.Action, + PolicyDecision: jl.PolicyDecision, + StatusCode: jl.StatusCode, + LatencyMS: jl.LatencyMS, + Error: jl.Error, + SignerKID: jl.SignerKID, + } + + for _, f := range []struct { + name string + hex string + dst []byte + }{ + {"params_sha256", jl.ParamsSHA256, r.ParamsSHA256[:]}, + {"prev_hash", jl.PrevHash, r.PrevHash[:]}, + {"entry_hash", jl.EntryHash, r.EntryHash[:]}, + {"signature", jl.Signature, r.Signature[:]}, + } { + if err := decodeFixed(f.hex, f.dst); err != nil { + return Receipt{}, fmt.Errorf("receipt: %s: %w", f.name, err) + } + } + return r, nil +} + +func decodeFixed(hexStr string, dst []byte) error { + decoded, err := hex.DecodeString(hexStr) + if err != nil { + return err + } + if len(decoded) != len(dst) { + return fmt.Errorf("want %d bytes, got %d", len(dst), len(decoded)) + } + copy(dst, decoded) + return nil +} + +type lineTypeProbe struct { + Type string `json:"type"` +} + +// DetectJSONLLineType returns "manifest", "key", or "receipt" for one JSONL +// line. A missing or empty "type" field defaults to "receipt", so the +// original plain receipt-per-line format keeps parsing. +func DetectJSONLLineType(line []byte) string { + var probe lineTypeProbe + if err := json.Unmarshal(line, &probe); err != nil || probe.Type == "" { + return "receipt" + } + return probe.Type +} + +// Bundle is the parsed content of one JSONL source: receipts in file order, +// any embedded "key" lines, and the "manifest" line if present. +type Bundle struct { + Receipts []Receipt + Keys []TrustedKey + Manifest *ExportManifest +} + +// ReadJSONL parses every non-empty line of r. "key" lines go to Keys, a +// "manifest" line becomes Manifest (a later one overwrites an earlier one), +// and every other line is parsed as a receipt. +func ReadJSONL(r io.Reader) (Bundle, error) { + var b Bundle + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 1<<20), 1<<24) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + switch DetectJSONLLineType(line) { + case "manifest": + m, err := ParseManifestLine(line) + if err != nil { + return Bundle{}, fmt.Errorf("line %d: %w", lineNum, err) + } + b.Manifest = &m + case "key": + k, err := ParseKeyLine(line) + if err != nil { + return Bundle{}, fmt.Errorf("line %d: %w", lineNum, err) + } + b.Keys = append(b.Keys, k) + default: + rc, err := ParseJSONLReceipt(line) + if err != nil { + return Bundle{}, fmt.Errorf("line %d: %w", lineNum, err) + } + b.Receipts = append(b.Receipts, rc) + } + } + if err := scanner.Err(); err != nil { + return Bundle{}, err + } + return b, nil +} + +// WriteJSONL writes receipts to w, one MarshalJSONLReceipt line each, +// newline-terminated. +func WriteJSONL(w io.Writer, receipts []Receipt) error { + for _, r := range receipts { + line, err := MarshalJSONLReceipt(r) + if err != nil { + return err + } + if _, err := w.Write(append(line, '\n')); err != nil { + return err + } + } + return nil +} diff --git a/pkg/receiptspec/manifest.go b/pkg/receiptspec/manifest.go new file mode 100644 index 0000000..c919468 --- /dev/null +++ b/pkg/receiptspec/manifest.go @@ -0,0 +1,254 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receiptspec + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" +) + +// ExportManifestDomainV1 separates export-manifest hashes from receipt +// entry hashes and every other signed payload. +const ExportManifestDomainV1 = "agentgate.receipt.export-manifest.v1" + +// ExportFormatVersion is the only manifest format_version this build +// produces or accepts. +const ExportFormatVersion = 1 + +// ErrManifestSignatureInvalid means a manifest's signature did not verify +// under its claimed signer_kid. +var ErrManifestSignatureInvalid = errors.New("receipt: export manifest signature invalid") + +// ErrKeysetDigestMismatch means the embedded key lines do not match the +// manifest's signed keyset_digest. +var ErrKeysetDigestMismatch = errors.New("receipt: export manifest keyset digest mismatch") + +// ExportManifest is the first line of a signed bounded export. It binds the +// requested and resolved range, the anchor before the export, the first and +// last exported entry hashes, the producer's head at snapshot time, and a +// digest of every embedded key line. +type ExportManifest struct { + FormatVersion int + RequestedFrom uint64 + RequestedTo uint64 // 0 means the request left "to" unspecified + ResolvedTo uint64 + Count int + AnchorSeq uint64 + AnchorHash [32]byte + FirstEntryHash [32]byte + LastEntryHash [32]byte + HeadSeq uint64 + HeadHash [32]byte + KeysetDigest [32]byte + SignerKID string + Signature [64]byte +} + +// ComputeKeysetDigest returns a digest over keys that does not depend on +// input order: keys are sorted by kid before hashing. +func ComputeKeysetDigest(keys []TrustedKey) [32]byte { + sorted := make([]TrustedKey, len(keys)) + copy(sorted, keys) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].KID < sorted[j].KID }) + + var buf []byte + for _, k := range sorted { + buf = appendLP(buf, k.KID) + buf = append(buf, k.PublicKey...) + buf = binary.LittleEndian.AppendUint64(buf, k.ValidFromSeq) + if k.ValidUntilSeq != nil { + buf = append(buf, 1) + buf = binary.LittleEndian.AppendUint64(buf, *k.ValidUntilSeq) + } else { + buf = append(buf, 0) + } + } + return sha256.Sum256(buf) +} + +// canonicalManifestInput is the signed preimage: every field except Signature. +func canonicalManifestInput(m ExportManifest) []byte { + encoded := make([]byte, 0, 256) + encoded = append(encoded, ExportManifestDomainV1...) + encoded = append(encoded, 0) + encoded = binary.LittleEndian.AppendUint32(encoded, uint32(m.FormatVersion)) + encoded = binary.LittleEndian.AppendUint64(encoded, m.RequestedFrom) + encoded = binary.LittleEndian.AppendUint64(encoded, m.RequestedTo) + encoded = binary.LittleEndian.AppendUint64(encoded, m.ResolvedTo) + encoded = binary.LittleEndian.AppendUint64(encoded, uint64(m.Count)) + encoded = binary.LittleEndian.AppendUint64(encoded, m.AnchorSeq) + encoded = append(encoded, m.AnchorHash[:]...) + encoded = append(encoded, m.FirstEntryHash[:]...) + encoded = append(encoded, m.LastEntryHash[:]...) + encoded = binary.LittleEndian.AppendUint64(encoded, m.HeadSeq) + encoded = append(encoded, m.HeadHash[:]...) + encoded = append(encoded, m.KeysetDigest[:]...) + encoded = appendLP(encoded, m.SignerKID) + return encoded +} + +// ComputeManifestHash returns the SHA-256 hash a manifest signs. +func ComputeManifestHash(m ExportManifest) [32]byte { + return sha256.Sum256(canonicalManifestInput(m)) +} + +// SignManifest sets m's SignerKID and Signature, signing with priv. +func SignManifest(m ExportManifest, kid string, priv ed25519.PrivateKey) ExportManifest { + m.SignerKID = kid + hash := ComputeManifestHash(m) + copy(m.Signature[:], ed25519.Sign(priv, hash[:])) + return m +} + +// VerifyManifest checks m's signature against trustedKeys and that its +// KeysetDigest matches embeddedKeys. Every failure here is a tamper finding: +// the manifest is what carries trust for the export. +func VerifyManifest(m ExportManifest, trustedKeys []TrustedKey, embeddedKeys []TrustedKey) error { + var key TrustedKey + found := false + for _, k := range trustedKeys { + if k.KID == m.SignerKID { + key, found = k, true + break + } + } + if !found { + return fmt.Errorf("%w: %s", ErrUnknownSignerKID, m.SignerKID) + } + if len(key.PublicKey) != ed25519.PublicKeySize { + return ErrManifestSignatureInvalid + } + hash := ComputeManifestHash(m) + if !ed25519.Verify(key.PublicKey, hash[:], m.Signature[:]) { + return ErrManifestSignatureInvalid + } + if ComputeKeysetDigest(embeddedKeys) != m.KeysetDigest { + return ErrKeysetDigestMismatch + } + return nil +} + +type manifestLineJSON struct { + Type string `json:"type"` + FormatVersion int `json:"format_version"` + RequestedFrom uint64 `json:"requested_from"` + RequestedTo uint64 `json:"requested_to,omitempty"` + ResolvedTo uint64 `json:"resolved_to"` + Count int `json:"count"` + AnchorSeq uint64 `json:"anchor_seq"` + AnchorHash string `json:"anchor_hash"` + FirstEntryHash string `json:"first_entry_hash"` + LastEntryHash string `json:"last_entry_hash"` + HeadSeq uint64 `json:"head_seq"` + HeadHash string `json:"head_hash"` + KeysetDigest string `json:"keyset_digest"` + SignerKID string `json:"signer_kid"` + Signature string `json:"signature"` +} + +// MarshalManifestLine encodes m as one JSONL line. +func MarshalManifestLine(m ExportManifest) ([]byte, error) { + line := manifestLineJSON{ + Type: "manifest", + FormatVersion: m.FormatVersion, + RequestedFrom: m.RequestedFrom, + RequestedTo: m.RequestedTo, + ResolvedTo: m.ResolvedTo, + Count: m.Count, + AnchorSeq: m.AnchorSeq, + AnchorHash: hex.EncodeToString(m.AnchorHash[:]), + FirstEntryHash: hex.EncodeToString(m.FirstEntryHash[:]), + LastEntryHash: hex.EncodeToString(m.LastEntryHash[:]), + HeadSeq: m.HeadSeq, + HeadHash: hex.EncodeToString(m.HeadHash[:]), + KeysetDigest: hex.EncodeToString(m.KeysetDigest[:]), + SignerKID: m.SignerKID, + Signature: hex.EncodeToString(m.Signature[:]), + } + return json.Marshal(line) +} + +// ParseManifestLine decodes one JSONL manifest line, rejecting any +// format_version other than ExportFormatVersion. +func ParseManifestLine(line []byte) (ExportManifest, error) { + var jl manifestLineJSON + if err := json.Unmarshal(line, &jl); err != nil { + return ExportManifest{}, fmt.Errorf("receipt: parse manifest line: %w", err) + } + if jl.FormatVersion != ExportFormatVersion { + return ExportManifest{}, fmt.Errorf("%w: got %d, want %d", ErrUnsupportedFormatVersion, jl.FormatVersion, ExportFormatVersion) + } + m := ExportManifest{ + FormatVersion: jl.FormatVersion, + RequestedFrom: jl.RequestedFrom, + RequestedTo: jl.RequestedTo, + ResolvedTo: jl.ResolvedTo, + Count: jl.Count, + AnchorSeq: jl.AnchorSeq, + HeadSeq: jl.HeadSeq, + SignerKID: jl.SignerKID, + } + for _, f := range []struct { + name string + hex string + dst []byte + }{ + {"anchor_hash", jl.AnchorHash, m.AnchorHash[:]}, + {"first_entry_hash", jl.FirstEntryHash, m.FirstEntryHash[:]}, + {"last_entry_hash", jl.LastEntryHash, m.LastEntryHash[:]}, + {"head_hash", jl.HeadHash, m.HeadHash[:]}, + {"keyset_digest", jl.KeysetDigest, m.KeysetDigest[:]}, + {"signature", jl.Signature, m.Signature[:]}, + } { + if err := decodeFixed(f.hex, f.dst); err != nil { + return ExportManifest{}, fmt.Errorf("receipt: manifest %s: %w", f.name, err) + } + } + return m, nil +} + +type keyLineJSON struct { + Type string `json:"type"` + KID string `json:"kid"` + PublicKeyHex string `json:"public_key_hex"` + ValidFromSeq uint64 `json:"valid_from_seq"` + ValidUntilSeq *uint64 `json:"valid_until_seq"` +} + +// MarshalKeyLine encodes k as one JSONL "key" line. +func MarshalKeyLine(k TrustedKey) ([]byte, error) { + return json.Marshal(keyLineJSON{ + Type: "key", + KID: k.KID, + PublicKeyHex: hex.EncodeToString(k.PublicKey), + ValidFromSeq: k.ValidFromSeq, + ValidUntilSeq: k.ValidUntilSeq, + }) +} + +// ParseKeyLine decodes one JSONL "key" line into a TrustedKey. +func ParseKeyLine(line []byte) (TrustedKey, error) { + var jl keyLineJSON + if err := json.Unmarshal(line, &jl); err != nil { + return TrustedKey{}, fmt.Errorf("receipt: parse key line: %w", err) + } + pub, err := hex.DecodeString(jl.PublicKeyHex) + if err != nil || len(pub) != ed25519.PublicKeySize { + return TrustedKey{}, fmt.Errorf("receipt: key line %s: public_key_hex must be %d bytes hex", jl.KID, ed25519.PublicKeySize) + } + return TrustedKey{ + KID: jl.KID, + PublicKey: ed25519.PublicKey(pub), + ValidFromSeq: jl.ValidFromSeq, + ValidUntilSeq: jl.ValidUntilSeq, + }, nil +} diff --git a/pkg/receiptspec/receipt.go b/pkg/receiptspec/receipt.go new file mode 100644 index 0000000..be35e1e --- /dev/null +++ b/pkg/receiptspec/receipt.go @@ -0,0 +1,128 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +// Package receiptspec is the public, standard-library-only implementation of +// the AgentGate v1 action receipt protocol: the Receipt type, its validation, +// canonical hashing, Ed25519 signing, hash-chain verification, JSONL encoding, +// signed export manifests, and decision-record binding. +// +// Any producer (the AgentGate gateway, an operator, a batch job) can build a +// chain with Chain and any verifier can check it with VerifyChain or +// VerifyBundle. The on-the-wire format is the same for every producer. +package receiptspec + +import ( + "errors" + "slices" + "strings" + "unicode/utf8" +) + +var ( + // ErrInvalidReceipt means a numeric or structural receipt field is out of range. + ErrInvalidReceipt = errors.New("receipt: invalid receipt") + // ErrInvalidField means a string field breaks its length, charset, or enum rule. + ErrInvalidField = errors.New("receipt: invalid field") +) + +// PolicyDecision values accepted by Validate. The set is part of the v1 wire +// contract: adding a value would make older verifiers reject new receipts. +const ( + DecisionAllow = "allow" + DecisionDeny = "deny" + DecisionRateLimited = "rate_limited" +) + +// Receipt is the semantic action receipt in canonical protocol order. +type Receipt struct { + Seq uint64 + TimestampUnixNS uint64 + HumanPrincipal string + AgentKeyID string + DelegationChain []string + Service string + Action string + ParamsSHA256 [32]byte + PolicyDecision string + StatusCode int + LatencyMS int64 + Error string + PrevHash [32]byte + EntryHash [32]byte + SignerKID string + Signature [64]byte +} + +// Validate checks that receipt satisfies the v1 semantic contract. +func Validate(receipt Receipt) error { + receipt = snapshot(receipt) + + if receipt.Seq == 0 || receipt.TimestampUnixNS == 0 { + return ErrInvalidReceipt + } + if receipt.StatusCode < 100 || receipt.StatusCode > 599 || receipt.LatencyMS < 0 { + return ErrInvalidReceipt + } + + if !validRequiredUTF8(receipt.HumanPrincipal, 256) || + !validRequiredUTF8(receipt.AgentKeyID, 128) || + !validRequiredUTF8(receipt.Service, 64) || + !validRequiredUTF8(receipt.Action, 128) || + !validRequiredUTF8(receipt.SignerKID, 128) { + return ErrInvalidField + } + + switch receipt.PolicyDecision { + case DecisionAllow, DecisionDeny, DecisionRateLimited: + default: + return ErrInvalidField + } + + if len(receipt.DelegationChain) > 32 { + return ErrInvalidField + } + for _, element := range receipt.DelegationChain { + if len(element) > 64 || !isASCII(element) || strings.IndexByte(element, 0) >= 0 { + return ErrInvalidField + } + } + + if receipt.Error != "" && !validErrorCode(receipt.Error) { + return ErrInvalidField + } + + return nil +} + +func snapshot(receipt Receipt) Receipt { + receipt.DelegationChain = slices.Clone(receipt.DelegationChain) + return receipt +} + +func validRequiredUTF8(value string, maximumBytes int) bool { + return value != "" && len(value) <= maximumBytes && utf8.ValidString(value) && strings.IndexByte(value, 0) < 0 +} + +func validErrorCode(value string) bool { + if len(value) == 0 || len(value) > 64 { + return false + } + for index := 0; index < len(value); index++ { + character := value[index] + if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '_' { + return false + } + } + return true +} + +func isASCII(value string) bool { + for index := 0; index < len(value); index++ { + if value[index] > 0x7f { + return false + } + } + return true +} diff --git a/pkg/receiptspec/record.go b/pkg/receiptspec/record.go new file mode 100644 index 0000000..b24577b --- /dev/null +++ b/pkg/receiptspec/record.go @@ -0,0 +1,295 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receiptspec + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "sort" + "strconv" + "unicode/utf16" + "unicode/utf8" +) + +const ( + maxRecordDepth = 32 + maxRecordBytes = 1 << 20 + maxSafeInteger = 1<<53 - 1 +) + +var ( + // ErrInvalidRecord means a decision record is not valid JSON, has + // duplicate keys, lone surrogate escapes, or nests too deeply. + ErrInvalidRecord = errors.New("receipt: invalid decision record") + // ErrUnsupportedNumber means a record contains a number that is not an + // integer in [-(2^53-1), 2^53-1]. Encode fractions as decimal strings. + ErrUnsupportedNumber = errors.New("receipt: unsupported number in decision record") + // ErrRecordMismatch means a record's hash differs from the receipt's ParamsSHA256. + ErrRecordMismatch = errors.New("receipt: decision record does not match params_sha256") +) + +var integerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`) + +// CanonicalRecord returns the canonical JSON form of v used by HashRecord. +// It follows RFC 8785 (JCS) for the subset it accepts: object keys sorted by +// UTF-16 code units, no insignificant whitespace, minimal string escaping. +// Numbers must be integers within +/-(2^53-1); floats, exponents, and -0 are +// rejected. A json.RawMessage is canonicalized as-is; any other value is +// first encoded with encoding/json. +func CanonicalRecord(v any) ([]byte, error) { + raw, ok := v.(json.RawMessage) + if !ok { + var err error + if raw, err = json.Marshal(v); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidRecord, err) + } + } + // Canonical output is never longer than the raw input, so bounding the + // input bounds decode, copy, and sort work before any of it happens. + if len(raw) > maxRecordBytes { + return nil, ErrInvalidRecord + } + if !utf8.Valid(raw) || !validSurrogateEscapes(raw) { + return nil, ErrInvalidRecord + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + tok, err := dec.Token() + if err != nil { + return nil, ErrInvalidRecord + } + out, err := canonicalValue(dec, tok, 0) + if err != nil { + return nil, err + } + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return nil, ErrInvalidRecord + } + if len(out) > maxRecordBytes { + return nil, ErrInvalidRecord + } + return out, nil +} + +// HashRecord returns SHA-256 of CanonicalRecord(v). A non-gateway producer +// puts this value in Receipt.ParamsSHA256 to bind a decision record. +func HashRecord(v any) ([32]byte, error) { + canonical, err := CanonicalRecord(v) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(canonical), nil +} + +// VerifyRecordBinding recomputes HashRecord over recordJSON and compares it +// with r.ParamsSHA256. It does not verify r's signature or chain position. +func VerifyRecordBinding(r Receipt, recordJSON []byte) error { + hash, err := HashRecord(json.RawMessage(recordJSON)) + if err != nil { + return err + } + if hash != r.ParamsSHA256 { + return ErrRecordMismatch + } + return nil +} + +func canonicalValue(dec *json.Decoder, tok json.Token, depth int) ([]byte, error) { + switch value := tok.(type) { + case json.Delim: + if depth >= maxRecordDepth { + return nil, ErrInvalidRecord + } + switch value { + case '{': + return canonicalObject(dec, depth+1) + case '[': + return canonicalArray(dec, depth+1) + } + return nil, ErrInvalidRecord + case string: + return appendJCSString(nil, value), nil + case json.Number: + s := value.String() + if !integerPattern.MatchString(s) || s == "-0" { + return nil, ErrUnsupportedNumber + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil || n > maxSafeInteger || n < -maxSafeInteger { + return nil, ErrUnsupportedNumber + } + return []byte(s), nil + case bool: + return strconv.AppendBool(nil, value), nil + case nil: + return []byte("null"), nil + } + return nil, ErrInvalidRecord +} + +func canonicalObject(dec *json.Decoder, depth int) ([]byte, error) { + type member struct { + key string + value []byte + } + var members []member + seen := make(map[string]struct{}) + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, ErrInvalidRecord + } + key, ok := keyTok.(string) + if !ok { + return nil, ErrInvalidRecord + } + if _, dup := seen[key]; dup { + return nil, ErrInvalidRecord + } + seen[key] = struct{}{} + valTok, err := dec.Token() + if err != nil { + return nil, ErrInvalidRecord + } + value, err := canonicalValue(dec, valTok, depth) + if err != nil { + return nil, err + } + members = append(members, member{key, value}) + } + if end, err := dec.Token(); err != nil || end != json.Delim('}') { + return nil, ErrInvalidRecord + } + sort.Slice(members, func(i, j int) bool { return lessUTF16(members[i].key, members[j].key) }) + + out := []byte{'{'} + for i, m := range members { + if i > 0 { + out = append(out, ',') + } + out = appendJCSString(out, m.key) + out = append(out, ':') + out = append(out, m.value...) + } + return append(out, '}'), nil +} + +func canonicalArray(dec *json.Decoder, depth int) ([]byte, error) { + out := []byte{'['} + for first := true; dec.More(); first = false { + tok, err := dec.Token() + if err != nil { + return nil, ErrInvalidRecord + } + value, err := canonicalValue(dec, tok, depth) + if err != nil { + return nil, err + } + if !first { + out = append(out, ',') + } + out = append(out, value...) + } + if end, err := dec.Token(); err != nil || end != json.Delim(']') { + return nil, ErrInvalidRecord + } + return append(out, ']'), nil +} + +// lessUTF16 orders strings by UTF-16 code units, as RFC 8785 section 3.2.3 requires. +func lessUTF16(a, b string) bool { + ua, ub := utf16.Encode([]rune(a)), utf16.Encode([]rune(b)) + for i := 0; i < len(ua) && i < len(ub); i++ { + if ua[i] != ub[i] { + return ua[i] < ub[i] + } + } + return len(ua) < len(ub) +} + +// appendJCSString escapes only '"', '\\', and control characters, using the +// short forms where JSON defines them and lowercase \u00xx otherwise. +func appendJCSString(dst []byte, s string) []byte { + const hexDigits = "0123456789abcdef" + dst = append(dst, '"') + for i := 0; i < len(s); i++ { + c := s[i] + switch c { + case '"', '\\': + dst = append(dst, '\\', c) + case '\b': + dst = append(dst, '\\', 'b') + case '\t': + dst = append(dst, '\\', 't') + case '\n': + dst = append(dst, '\\', 'n') + case '\f': + dst = append(dst, '\\', 'f') + case '\r': + dst = append(dst, '\\', 'r') + default: + if c < 0x20 { + dst = append(dst, '\\', 'u', '0', '0', hexDigits[c>>4], hexDigits[c&0xf]) + } else { + dst = append(dst, c) + } + } + } + return append(dst, '"') +} + +// validSurrogateEscapes rejects lone \uD800-\uDFFF escapes, which +// encoding/json would otherwise silently decode to U+FFFD. +func validSurrogateEscapes(raw []byte) bool { + inString := false + for i := 0; i < len(raw); i++ { + switch raw[i] { + case '"': + inString = !inString + case '\\': + if !inString || i+1 >= len(raw) { + continue + } + if raw[i+1] != 'u' { + i++ + continue + } + first, ok := hexQuad(raw, i+2) + if !ok { + return false + } + switch { + case first >= 0xdc00 && first <= 0xdfff: + return false + case first >= 0xd800 && first <= 0xdbff: + if i+12 > len(raw) || raw[i+6] != '\\' || raw[i+7] != 'u' { + return false + } + second, ok := hexQuad(raw, i+8) + if !ok || second < 0xdc00 || second > 0xdfff { + return false + } + i += 11 + default: + i += 5 + } + } + } + return true +} + +func hexQuad(raw []byte, start int) (uint16, bool) { + if start+4 > len(raw) { + return 0, false + } + value, err := strconv.ParseUint(string(raw[start:start+4]), 16, 16) + return uint16(value), err == nil +} diff --git a/pkg/receiptspec/record_test.go b/pkg/receiptspec/record_test.go new file mode 100644 index 0000000..e0db4e8 --- /dev/null +++ b/pkg/receiptspec/record_test.go @@ -0,0 +1,126 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receiptspec_test + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +func TestCanonicalRecord(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"key order", `{"b":1,"a":2}`, `{"a":2,"b":1}`}, + {"whitespace", " { \"a\" : [ 1 , 2 ] } ", `{"a":[1,2]}`}, + {"nested", `{"z":{"y":{"x":true,"w":null}},"a":[]}`, `{"a":[],"z":{"y":{"w":null,"x":true}}}`}, + {"unicode literal", `{"k":"caf\u00e9 \u20ac"}`, `{"k":"café €"}`}, + {"html not escaped", `{"k":""}`, `{"k":""}`}, + {"controls", `{"k":"\u0001\b\t\n\f\r\"\\/"}`, `{"k":"\u0001\b\t\n\f\r\"\\/"}`}, + {"surrogate pair", `{"k":"\ud83d\ude00"}`, `{"k":"😀"}`}, + // RFC 8785 sorts by UTF-16 code units: U+1F600 (D83D) sorts before U+FB01 (FB01). + {"utf16 key order", `{"fi":1,"😀":2}`, `{"😀":2,"fi":1}`}, + {"max safe integers", `{"a":9007199254740991,"b":-9007199254740991,"c":0}`, `{"a":9007199254740991,"b":-9007199254740991,"c":0}`}, + {"scalar top level", `"x"`, `"x"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := receiptspec.CanonicalRecord(json.RawMessage(tc.in)) + if err != nil { + t.Fatal(err) + } + if string(got) != tc.want { + t.Fatalf("got %s, want %s", got, tc.want) + } + }) + } +} + +func TestCanonicalRecordRejects(t *testing.T) { + cases := []struct { + in string + want error + }{ + {`{"a":1.5}`, receiptspec.ErrUnsupportedNumber}, + {`{"a":1e3}`, receiptspec.ErrUnsupportedNumber}, + {`{"a":1.0}`, receiptspec.ErrUnsupportedNumber}, + {`{"a":-0}`, receiptspec.ErrUnsupportedNumber}, + {`{"a":9007199254740992}`, receiptspec.ErrUnsupportedNumber}, + {`{"a":1,"a":2}`, receiptspec.ErrInvalidRecord}, + {`{"a":"\ud800"}`, receiptspec.ErrInvalidRecord}, + {`{"a":"\udc00"}`, receiptspec.ErrInvalidRecord}, + {"{\"a\":\"\xff\"}", receiptspec.ErrInvalidRecord}, + {`{"a":1} {}`, receiptspec.ErrInvalidRecord}, + {`{"a":`, receiptspec.ErrInvalidRecord}, + {`[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]`, receiptspec.ErrInvalidRecord}, + } + for _, tc := range cases { + if _, err := receiptspec.CanonicalRecord(json.RawMessage(tc.in)); !errors.Is(err, tc.want) { + t.Errorf("%s: err = %v, want %v", tc.in, err, tc.want) + } + } +} + +func TestHashRecordStructAndMapAgree(t *testing.T) { + type record struct { + Outcome string `json:"outcome"` + Probability string `json:"probability"` + ScoreMicros int64 `json:"score_micros"` + } + a, err := receiptspec.HashRecord(record{Outcome: "approved", Probability: "0.875", ScoreMicros: 875000}) + if err != nil { + t.Fatal(err) + } + b, err := receiptspec.HashRecord(map[string]any{"score_micros": 875000, "probability": "0.875", "outcome": "approved"}) + if err != nil { + t.Fatal(err) + } + if a != b { + t.Fatal("struct and map with equal content hash differently") + } + if _, err := receiptspec.HashRecord(map[string]float64{"p": 0.5}); !errors.Is(err, receiptspec.ErrUnsupportedNumber) { + t.Fatalf("float err = %v", err) + } +} + +func TestVerifyRecordBinding(t *testing.T) { + record := []byte(`{"outcome":"edited","record_version":1}`) + hash, err := receiptspec.HashRecord(json.RawMessage(record)) + if err != nil { + t.Fatal(err) + } + r := receiptspec.Receipt{ParamsSHA256: hash} + if err := receiptspec.VerifyRecordBinding(r, []byte(" {\"record_version\": 1, \"outcome\": \"edited\"} ")); err != nil { + t.Fatal(err) + } + if err := receiptspec.VerifyRecordBinding(r, []byte(`{"outcome":"approved","record_version":1}`)); !errors.Is(err, receiptspec.ErrRecordMismatch) { + t.Fatalf("err = %v, want ErrRecordMismatch", err) + } +} + +func FuzzCanonicalRecord(f *testing.F) { + for _, seed := range []string{`{"b":1,"a":[true,null,"x"]}`, `{"😀":{"fi":-1}}`, `"\u0007"`, `[]`} { + f.Add([]byte(seed)) + } + f.Fuzz(func(t *testing.T, in []byte) { + out, err := receiptspec.CanonicalRecord(json.RawMessage(in)) + if err != nil { + return + } + again, err := receiptspec.CanonicalRecord(json.RawMessage(out)) + if err != nil || string(again) != string(out) { + t.Fatalf("not idempotent: %q -> %q (%v)", out, again, err) + } + if !json.Valid(out) { + t.Fatalf("invalid JSON output %q", out) + } + }) +} diff --git a/pkg/receiptspec/review_test.go b/pkg/receiptspec/review_test.go new file mode 100644 index 0000000..5f7fa48 --- /dev/null +++ b/pkg/receiptspec/review_test.go @@ -0,0 +1,82 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receiptspec_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +func TestChainNextDetachesDelegationChain(t *testing.T) { + signer, pub := testSigner(t) + chain := receiptspec.NewChain(signer, 0, [32]byte{}) + f := fields(1) + f.DelegationChain = []string{"a", "b"} + r, err := chain.Next(f) + if err != nil { + t.Fatal(err) + } + f.DelegationChain[0] = "mutated" + if err := receiptspec.VerifyReceipt(r, pub); err != nil { + t.Fatalf("receipt changed after caller mutated its slice: %v", err) + } +} + +func TestSealDetachesDelegationChain(t *testing.T) { + signer, pub := testSigner(t) + in := fields(1) + in.DelegationChain = []string{"a"} + chain := receiptspec.NewChain(signer, 0, [32]byte{}) + base, err := chain.Next(in) + if err != nil { + t.Fatal(err) + } + base.DelegationChain = []string{"x", "y"} + sealed, err := receiptspec.Seal(base, signer) + if err != nil { + t.Fatal(err) + } + base.DelegationChain[0] = "mutated" + if err := receiptspec.VerifyReceipt(sealed, pub); err != nil { + t.Fatalf("sealed receipt aliases input slice: %v", err) + } +} + +func TestVerifyChainRejectsMalformedPublicKey(t *testing.T) { + receipts, signer, _ := buildChain(t, 2) + for _, key := range [][]byte{nil, {1, 2, 3}} { + trusted := []receiptspec.TrustedKey{{KID: signer.KID(), PublicKey: key}} + res, err := receiptspec.VerifyChain(receipts, trusted, receiptspec.Anchor{}, nil) + if err != nil { + t.Fatal(err) + } + if res.OK { + t.Fatalf("key %v: chain verified with a malformed public key", key) + } + } +} + +func TestVerifyManifestRejectsMalformedPublicKey(t *testing.T) { + m := receiptspec.ExportManifest{SignerKID: "ed25519:bad"} + for _, key := range [][]byte{nil, {1, 2, 3}} { + trusted := []receiptspec.TrustedKey{{KID: "ed25519:bad", PublicKey: key}} + err := receiptspec.VerifyManifest(m, trusted, nil) + if !errors.Is(err, receiptspec.ErrManifestSignatureInvalid) { + t.Fatalf("key %v: got %v, want ErrManifestSignatureInvalid", key, err) + } + } +} + +func TestCanonicalRecordRejectsOversizedInput(t *testing.T) { + big := `{"a":"` + strings.Repeat("x", 1<<20) + `"}` + if _, err := receiptspec.CanonicalRecord(json.RawMessage(big)); !errors.Is(err, receiptspec.ErrInvalidRecord) { + t.Fatalf("got %v, want ErrInvalidRecord", err) + } +} diff --git a/pkg/receiptspec/sign.go b/pkg/receiptspec/sign.go new file mode 100644 index 0000000..9262546 --- /dev/null +++ b/pkg/receiptspec/sign.go @@ -0,0 +1,90 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receiptspec + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "slices" +) + +// ErrSignature means a signer returned a malformed signature or a receipt +// signature did not verify. +var ErrSignature = errors.New("receipt: signature invalid") + +// ErrEntryHashMismatch means a receipt's EntryHash does not match its fields. +var ErrEntryHashMismatch = errors.New("receipt: entry hash mismatch") + +// Signer signs receipt entry hashes. Sign must return a 64-byte Ed25519 +// signature over msg, and KID must name the key that produced it. +// Implementations can keep the private key anywhere (memory, HSM, KMS). +type Signer interface { + KID() string + Sign(msg []byte) ([]byte, error) +} + +// ComputeKID returns the deterministic key ID for an Ed25519 public key: +// "ed25519:" plus the first 8 bytes of SHA-256(pub) in lowercase hex. +func ComputeKID(pub ed25519.PublicKey) string { + digest := sha256.Sum256(pub) + return "ed25519:" + hex.EncodeToString(digest[:8]) +} + +type ed25519Signer struct { + kid string + priv ed25519.PrivateKey +} + +// NewEd25519Signer returns a Signer backed by an in-memory private key. +// Its KID is ComputeKID of the matching public key. +func NewEd25519Signer(priv ed25519.PrivateKey) Signer { + return ed25519Signer{kid: ComputeKID(priv.Public().(ed25519.PublicKey)), priv: priv} +} + +func (s ed25519Signer) KID() string { return s.kid } + +func (s ed25519Signer) Sign(msg []byte) ([]byte, error) { return ed25519.Sign(s.priv, msg), nil } + +// Seal sets SignerKID from s, computes EntryHash, and signs it. +// Seq, PrevHash, and every other field must already be set. +func Seal(r Receipt, s Signer) (Receipt, error) { + r.DelegationChain = slices.Clone(r.DelegationChain) + r.SignerKID = s.KID() + hash, err := ComputeEntryHash(r) + if err != nil { + return Receipt{}, err + } + sig, err := s.Sign(hash[:]) + if err != nil { + return Receipt{}, fmt.Errorf("receipt: sign: %w", err) + } + if len(sig) != ed25519.SignatureSize { + return Receipt{}, fmt.Errorf("%w: signer returned %d bytes", ErrSignature, len(sig)) + } + r.EntryHash = hash + copy(r.Signature[:], sig) + return r, nil +} + +// VerifyReceipt checks one receipt in isolation: fields are valid, EntryHash +// matches, and Signature verifies under pub. It does not check chain links, +// the signer's validity window, or that pub belongs to SignerKID. +func VerifyReceipt(r Receipt, pub ed25519.PublicKey) error { + hash, err := ComputeEntryHash(r) + if err != nil { + return err + } + if hash != r.EntryHash { + return ErrEntryHashMismatch + } + if len(pub) != ed25519.PublicKeySize || !ed25519.Verify(pub, hash[:], r.Signature[:]) { + return ErrSignature + } + return nil +} diff --git a/pkg/receiptspec/verifier.go b/pkg/receiptspec/verifier.go new file mode 100644 index 0000000..675d2fc --- /dev/null +++ b/pkg/receiptspec/verifier.go @@ -0,0 +1,255 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receiptspec + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" +) + +// Reason values are stable ASCII and never derived from a receipt's own +// fields, so they are safe to print without leaking receipted data. +const ( + ReasonSequenceGap = "sequence_gap_or_not_genesis_anchored" + ReasonPrevHashMismatch = "prev_hash_mismatch" + ReasonInvalidReceiptFields = "invalid_receipt_fields" + ReasonEntryHashMismatch = "entry_hash_mismatch" + ReasonSignatureInvalid = "signature_invalid" + ReasonSignerInactiveAtSeq = "signer_kid_inactive_at_seq" + ReasonExpectedHeadMismatch = "expected_head_mismatch" +) + +var ( + // ErrNoTrustedKeys means no trusted keys were supplied. This is a + // configuration error, not a tamper finding. + ErrNoTrustedKeys = errors.New("receipt: no trusted keys supplied") + // ErrEmptyChain means the source supplied zero receipts. + ErrEmptyChain = errors.New("receipt: empty receipt chain") + // ErrUnknownSignerKID means a receipt names a signer_kid absent from the + // trust set. A verifier with a stale trust file cannot tell this apart + // from an attacker's key, so it is reported as a configuration error. + ErrUnknownSignerKID = errors.New("receipt: unknown signer_kid") +) + +// TrustedKey is one entry in a verifier's trust set: a public key plus its +// inclusive [ValidFromSeq, ValidUntilSeq] sequence window. +// +// Key rotation is not signed by the previous key. A trust set must contain +// every key that signed a receipt in the checked range, obtained through a +// trusted channel. A receipt signed outside its key's window fails. +type TrustedKey struct { + KID string + PublicKey ed25519.PublicKey + ValidFromSeq uint64 + ValidUntilSeq *uint64 // nil means open-ended (still active when the trust file was saved) +} + +type trustedKeyJSON struct { + KID string `json:"kid"` + PublicKeyHex string `json:"public_key_hex"` + ValidFromSeq uint64 `json:"valid_from_seq"` + ValidUntilSeq *uint64 `json:"valid_until_seq"` +} + +type trustFileJSON struct { + Keys []trustedKeyJSON `json:"keys"` +} + +// LoadTrustedKeys parses a trust file shaped like the gateway's +// GET /v1/receipts/pubkey response: {"keys": [{"kid", "public_key_hex", +// "valid_from_seq", "valid_until_seq"}]}. It never fetches anything. +func LoadTrustedKeys(data []byte) ([]TrustedKey, error) { + var file trustFileJSON + if err := json.Unmarshal(data, &file); err != nil { + return nil, fmt.Errorf("receipt: parse trust file: %w", err) + } + if len(file.Keys) == 0 { + return nil, ErrNoTrustedKeys + } + out := make([]TrustedKey, 0, len(file.Keys)) + for _, k := range file.Keys { + pub, err := hex.DecodeString(k.PublicKeyHex) + if err != nil || len(pub) != ed25519.PublicKeySize { + return nil, fmt.Errorf("receipt: trust file key %s: public_key must be %d bytes hex", k.KID, ed25519.PublicKeySize) + } + out = append(out, TrustedKey{ + KID: k.KID, + PublicKey: ed25519.PublicKey(pub), + ValidFromSeq: k.ValidFromSeq, + ValidUntilSeq: k.ValidUntilSeq, + }) + } + return out, nil +} + +// ExpectedHead pins a separately trusted (seq, entry_hash). Without one, a +// verifier cannot detect a deleted suffix, only internal inconsistency. +type ExpectedHead struct { + Seq uint64 + EntryHash [32]byte +} + +// Anchor is the committed (seq, entry_hash) immediately before the first +// checked receipt. The zero value is genesis. +type Anchor struct { + Seq uint64 + EntryHash [32]byte +} + +// ParseExpectedHead parses "SEQ:HEXHASH". +func ParseExpectedHead(s string) (ExpectedHead, error) { + seqStr, hashStr, ok := strings.Cut(s, ":") + if !ok { + return ExpectedHead{}, errors.New("receipt: --expected-head must be SEQ:HEXHASH") + } + seq, err := strconv.ParseUint(seqStr, 10, 64) + if err != nil { + return ExpectedHead{}, fmt.Errorf("receipt: --expected-head seq: %w", err) + } + decoded, err := hex.DecodeString(hashStr) + if err != nil || len(decoded) != 32 { + return ExpectedHead{}, errors.New("receipt: --expected-head hash must be 32 bytes hex") + } + var out ExpectedHead + out.Seq = seq + copy(out.EntryHash[:], decoded) + return out, nil +} + +// VerifyResult reports one verification. Every field is a count, a sequence +// number, a hash, or a Reason constant, so it is safe to print in full. +type VerifyResult struct { + OK bool + TotalReceipts int + VerifiedCount int + FailedAtSeq uint64 // 0 only when OK and no failure occurred + Reason string // one of the Reason* constants; empty when OK + HeadSeq uint64 + HeadEntryHash [32]byte + // Complete is true only when an ExpectedHead was supplied and matched the + // final receipt. False means completeness was not claimed, not tampering. + Complete bool +} + +// VerifyChain checks receipts, ordered by ascending Seq, against trustedKeys +// starting from anchor. A returned error means the input could not be +// checked at all (no keys, no receipts, unknown signer_kid). A result with +// OK == false means verification ran and found a mismatch. +// +// If expectedHead is non-nil, the final receipt must match it exactly. +func VerifyChain(receipts []Receipt, trustedKeys []TrustedKey, anchor Anchor, expectedHead *ExpectedHead) (VerifyResult, error) { + if len(trustedKeys) == 0 { + return VerifyResult{}, ErrNoTrustedKeys + } + if len(receipts) == 0 { + return VerifyResult{}, ErrEmptyChain + } + + byKID := make(map[string]TrustedKey, len(trustedKeys)) + for _, k := range trustedKeys { + byKID[k.KID] = k + } + + result := VerifyResult{TotalReceipts: len(receipts)} + wantSeq := anchor.Seq + 1 + prevHash := anchor.EntryHash + for i := range receipts { + r := receipts[i] + + if r.Seq != wantSeq+uint64(i) { + result.FailedAtSeq = r.Seq + result.Reason = ReasonSequenceGap + return result, nil + } + if r.PrevHash != prevHash { + result.FailedAtSeq = r.Seq + result.Reason = ReasonPrevHashMismatch + return result, nil + } + + key, ok := byKID[r.SignerKID] + if !ok { + return VerifyResult{}, fmt.Errorf("%w: %s", ErrUnknownSignerKID, r.SignerKID) + } + if r.Seq < key.ValidFromSeq || (key.ValidUntilSeq != nil && r.Seq > *key.ValidUntilSeq) { + result.FailedAtSeq = r.Seq + result.Reason = ReasonSignerInactiveAtSeq + return result, nil + } + + entryHash, err := ComputeEntryHash(r) + if err != nil { + result.FailedAtSeq = r.Seq + result.Reason = ReasonInvalidReceiptFields + return result, nil + } + if entryHash != r.EntryHash { + result.FailedAtSeq = r.Seq + result.Reason = ReasonEntryHashMismatch + return result, nil + } + if len(key.PublicKey) != ed25519.PublicKeySize || !ed25519.Verify(key.PublicKey, entryHash[:], r.Signature[:]) { + result.FailedAtSeq = r.Seq + result.Reason = ReasonSignatureInvalid + return result, nil + } + + prevHash = entryHash + result.VerifiedCount++ + result.HeadSeq = r.Seq + result.HeadEntryHash = entryHash + } + + result.OK = true + if expectedHead != nil { + if result.HeadSeq == expectedHead.Seq && result.HeadEntryHash == expectedHead.EntryHash { + result.Complete = true + } else { + result.OK = false + result.FailedAtSeq = expectedHead.Seq + result.Reason = ReasonExpectedHeadMismatch + } + } + return result, nil +} + +// ManifestError wraps a manifest verification failure from VerifyBundle. +// Unlike VerifyChain's errors it is a tamper finding, not a config error. +type ManifestError struct { + Err error +} + +// Error returns the wrapped manifest error's message. +func (e *ManifestError) Error() string { return e.Err.Error() } + +// Unwrap returns the underlying manifest error. +func (e *ManifestError) Unwrap() error { return e.Err } + +// VerifyBundle verifies a parsed source the way agentgate-verify does. +// trusted overrides b.Keys when non-empty; otherwise the embedded keys are +// the trust set. A manifest, if present, must verify (else *ManifestError) +// and then supplies the anchor and, unless expected is set, the expected head. +func VerifyBundle(b Bundle, trusted []TrustedKey, expected *ExpectedHead) (VerifyResult, error) { + if len(trusted) == 0 { + trusted = b.Keys + } + anchor := Anchor{} + if b.Manifest != nil { + if err := VerifyManifest(*b.Manifest, trusted, b.Keys); err != nil { + return VerifyResult{}, &ManifestError{Err: err} + } + anchor = Anchor{Seq: b.Manifest.AnchorSeq, EntryHash: b.Manifest.AnchorHash} + if expected == nil { + expected = &ExpectedHead{Seq: b.Manifest.ResolvedTo, EntryHash: b.Manifest.LastEntryHash} + } + } + return VerifyChain(b.Receipts, trusted, anchor, expected) +}