From 00ee7241118e9965af944ffc16bf28cb78e8c888 Mon Sep 17 00:00:00 2001 From: Josh Terry Date: Mon, 17 Aug 2026 20:12:40 -0700 Subject: [PATCH] Add receipt signing: Ed25519 over the domain-separated canonical payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adopted definition calls a receipt "an authenticated record"; until now nothing made it authentic. The design went through an adversarial external review BEFORE implementation, and the review's CRITICAL finding shaped the shipped shape: the probe-running command has NO signing flag, because it executes the change's own test code and any key it can read, reviewed code can read. Signing is a separate `sign` subcommand that runs no probes and reads no tree; CI mounts the key only for that step. - schema 0.0.14: SignatureBlock (alg/public_key/audience/sig) - internal/signing: sign, verify (canonical byte-identity, mandatory subject match, pinned-key-only trust, audience bound in the preimage), PEM keygen/load with atomic exclusive creation - internal/strictjson: the strict decode contract shared with intake — also fixes the Decoder.More EOF gap the review found in merged code (a stray closing delimiter passed as end-of-input) - receipt.Canonical + ValidateConsistency: one byte-form per receipt (golden-vector frozen) and re-derivation of every computable field on both sides, so a tampered-summary receipt cannot be signed or verified - gitdiff.InputDigest now pins file kind (regular/exec/symlink) and never follows symlinks: an exec-bit flip or file-to-symlink swap changed probe behavior under an unchanged digest - render subcommand: PR markdown from the signed JSON without a second probe run; renderings state their signature is UNVERIFIED HERE - deferred with stated rationale: freshness/replay policy (verifier's CI owns it), receipt chaining (own backlog item) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015GeUG1gboWiZSnFyzQghyp --- DESIGN.md | 89 ++++++- README.md | 57 +++++ cmd/correctful/main.go | 22 ++ cmd/correctful/subcommands.go | 187 +++++++++++++++ internal/gitdiff/gitdiff.go | 63 +++-- internal/gitdiff/gitdiff_test.go | 52 ++++ internal/intake/intake.go | 76 +----- internal/receipt/canonical.go | 28 +++ internal/receipt/markdown.go | 3 + internal/receipt/receipt.go | 38 ++- internal/receipt/validate.go | 122 ++++++++++ internal/receipt/validate_test.go | 108 +++++++++ internal/signing/keys.go | 136 +++++++++++ internal/signing/keys_test.go | 150 ++++++++++++ internal/signing/signing.go | 233 ++++++++++++++++++ internal/signing/signing_test.go | 318 +++++++++++++++++++++++++ internal/strictjson/strictjson.go | 100 ++++++++ internal/strictjson/strictjson_test.go | 51 ++++ schema/schema.go | 34 ++- 19 files changed, 1768 insertions(+), 99 deletions(-) create mode 100644 cmd/correctful/subcommands.go create mode 100644 internal/receipt/canonical.go create mode 100644 internal/receipt/validate.go create mode 100644 internal/receipt/validate_test.go create mode 100644 internal/signing/keys.go create mode 100644 internal/signing/keys_test.go create mode 100644 internal/signing/signing.go create mode 100644 internal/signing/signing_test.go create mode 100644 internal/strictjson/strictjson.go create mode 100644 internal/strictjson/strictjson_test.go diff --git a/DESIGN.md b/DESIGN.md index fe60c27..82101ef 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -549,6 +549,87 @@ typo cost fails closed as an unsatisfiable floor), and stderr diagnostics keep the invoker-supplied intake paths (the flag value already appears in the CI configuration; the RECEIPT never carries them). +## Receipt signing (schema 0.0.14): the word "authenticated" made true + +The adopted definition calls a receipt "an authenticated record". Through +schema 0.0.13 nothing made it authentic — anyone could write the JSON. +Signing closes that gap, and the design was reviewed adversarially BEFORE +implementation (same loop as the intake contract: design brief → external +review → build → verification round). The review's central finding +reshaped the design: + +**The signer must not be the probe runner.** The proposed design put a +`-sign` flag on the main command. The reviewer rated this CRITICAL: the +main command builds and executes the change's own test code as the same +user, so any key that process can read, reviewed code can read — and an +out-of-tree containment check on the key path is no defense, because the +threat is process-level, not path-level. The shipped design has no signing +flag on the main command at all. `correctful sign` is a separate +subcommand that runs no probes and reads no repository tree; CI mounts the +key only for that step. This is an enforcement-layer honesty point in the +PMB-020 sense: a flag-level separation inside one binary cannot stop a +malicious CI configuration — the docs say the topology (produce without +key, sign without probes, verify in a protected workflow) is what the +operator must hold, and the binary makes the safe topology the only +expressible one. + +The rest of the shipped design, each element traceable to a review finding: + +- **One algorithm.** Ed25519, named in the block, everything else + rejected. Agility is a vulnerability class opted out of. +- **Canonical byte-identity.** Exactly one byte form of a signed receipt + verifies: the one `receipt.Canonical` produces (the same encoder as + `WriteJSON`, frozen by a golden-vector test). The strict parser rejects + unknown fields, duplicate keys at any depth, trailing bytes — including + the stray-closing-delimiter case that slipped the old `Decoder.More` + EOF check, a live bug the review found in the merged intake code — and + invalid UTF-8. No normalization differential survives: a reformatted + copy of a valid receipt fails verification. +- **Domain-separated preimage.** `correctful-receipt-v1\0\0` + + canonical payload (signature block absent). The domain string versions + the canonicalization; the audience binds the signature to one + repository, so a shared CI key cannot confuse receipts across repos. + The audience is control-character-free by validation, so the preimage + boundaries cannot shift. +- **Subject matching is mandatory.** `verify` demands the expected head + SHA (optionally base SHA and input digest) and fails on mismatch — a + valid signature over SOME receipt is worthless to a gate. `-any-subject` + is the explicit, stated opt-out for archival authenticity checks. +- **The embedded key is a claim, not a root.** `verify` requires the + caller's pinned public key and rejects a receipt signed by any other + key. Verifying against the embedded key alone is the classic + self-certification hole and is structurally not offered. +- **Consistency validation on both sides.** A signature authenticates + bytes, not coherence. The reviewer's attack: sign one refuted result + with `Summary.Refuted` zeroed — `GateBlocked` reads the summary, so the + valid signature carries a refutation past the gate. `Sign` refuses an + inconsistent receipt, and `Verify` re-derives every computable field + (statuses, tiers, remainder, summary, coverage arithmetic) even for a + signature minted by a bypassing signer. Policy and intake results are + validated structurally only — their source documents are digest-pinned, + not embedded, and pretending to recompute them would be false assurance. +- **The input digest pins kind, not just content.** The digest formula + now hashes each file's kind (regular/exec/symlink/absent) and never + follows a symlink (the link's target string IS its content). Before + this, an execute-bit flip or a file-to-symlink swap changed probe + behavior under an unchanged digest — "one exact change" requires the + mode and type to be part of the identity. +- **Renderings are not signed and say so.** Only the JSON artifact + verifies. `correctful render` produces the PR comment from the signed + JSON without a second probe run, and the signature note in every + rendering states UNVERIFIED HERE — a pasted "signed by" line must never + read as authority. + +Two review recommendations are consciously deferred, stated so the +divergence is a decision: **freshness/replay policy** (all subject fields +match across two runs of the same change, so an old passing receipt can +be replayed over a newer failing one — the verifier's CI owns freshness, +the docs state the limitation, and a protected run identity can join the +signed payload when a consumer needs it) and **receipt chaining** (a +parent receipt digest is its own backlog item; until it ships, signed +receipts are authenticated individual records, and the docs do not use +the word "chain" for them). + ## Known limitations (found by dogfooding, stated honestly) correctful was run on itself and on a real 101-file production change on its @@ -633,6 +714,10 @@ internal/gitdiff/ resolve the change (diff vs base, or whole tree) internal/harvest/ diff → claims (test names, spec ids, Alloy, RFC MUSTs) internal/llmextract/ diff → PROPOSED claims (opt-in -llm; remainder-only) internal/probe/ claims → evidence (dispatcher + go-test runner) -internal/receipt/ assemble + render (JSON payload, text for humans) -cmd/correctful/ the CLI +internal/policy/ evidence floors per path (correctful.json) +internal/intake/ external supplier evidence (invoker-owned config) +internal/signing/ sign/verify receipts (Ed25519, canonical payload) +internal/strictjson/ the strict JSON contract shared by intake + signing +internal/receipt/ assemble + render + canonical form + consistency +cmd/correctful/ the CLI (main + keygen/sign/verify/render) ``` diff --git a/README.md b/README.md index fbf2204..01beb5a 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,63 @@ The rules: - Config and documents must be regular files outside the repository. The change under review must not supply its own evidence. +## Signed receipts (optional) + +A receipt can carry an Ed25519 signature. The signature proves one thing: +the holder of the private key produced exactly this canonical content. A +verifier with the pinned public key can then reject a forged, edited, or +substituted receipt. + +Make a keypair once: + +```sh +correctful keygen -out /ci/keys +``` + +Sign and verify in CI as three separate steps: + +```sh +# Step 1 — produce. The key is NOT present in this step: this step builds +# and executes the change's own test code. +correctful -base main -format json > receipt.json + +# Step 2 — sign. This step runs no probes and reads no repository tree. +# Only this step mounts the key. +correctful sign -receipt receipt.json -key /ci/keys/correctful.key \ + -audience github.com/org/repo -out receipt.signed.json + +# Step 3 — verify, in a protected workflow the change cannot edit. +correctful verify -receipt receipt.signed.json -pub /ci/keys/correctful.pub \ + -head "$GITHUB_SHA" -audience github.com/org/repo -gate +``` + +The rules: + +- The main command has no signing flag. A process that runs reviewed test + code must never hold the signing key. +- `verify` needs the expected head SHA. A signature alone proves that SOME + receipt is authentic. The subject match ties it to THIS change. Pass + `-any-subject` only when you check an archived receipt. +- The trusted key comes from your `-pub` file, never from the receipt. The + key inside the receipt is an identity claim, and `verify` requires it to + match your pinned key. +- The audience binds the signature to one repository. A receipt signed for + another repository fails, even under a shared CI key. +- Exactly one byte form of a signed receipt verifies: its canonical form. + A reformatted copy fails. This closes parser differentials. +- `verify` also re-derives every computable field. A signed receipt whose + summary contradicts its own results fails, so a tampered-then-signed + summary cannot slip a refutation past the gate. +- `correctful render -receipt receipt.signed.json -format md` renders the + signed JSON for a PR comment without a second probe run. The rendering + itself is not signed, and it says so. + +What the signature does NOT prove: that the runner was honest, that the +key was never stolen, or that this receipt is the newest run for its +subject. A verifier that must reject old runs for the same change needs +its own freshness rule. Trust in a signed receipt is trust in the key +holder. + ## The evidence tiers Each claim carries a tier. The tier tells you how strong the evidence is. diff --git a/cmd/correctful/main.go b/cmd/correctful/main.go index 533b3c9..53d0c6f 100644 --- a/cmd/correctful/main.go +++ b/cmd/correctful/main.go @@ -21,6 +21,19 @@ // required intake supplier with no admitted document (merge-gate semantics — // schema.Receipt.GateBlocked is the definition). The remainder never fails // the run — it is an honest report, not a defect. +// +// Subcommands (see internal/signing for the trust model): +// +// correctful keygen -out +// correctful sign -receipt -key [-audience a] [-out f] +// correctful verify -receipt -pub -head [-audience a] [-gate] +// correctful render -receipt [-format text|md] +// +// The main command has no signing flag ON PURPOSE: it executes the change's +// own test code, and a process that runs reviewed code must never hold the +// signing key. Produce the receipt first (no key present), sign it in a +// separate step (key present, no reviewed code runs), verify in a protected +// workflow against a pinned public key. package main import ( @@ -41,6 +54,15 @@ import ( ) func main() { + if len(os.Args) > 1 { + if cmd := subcommand(os.Args[1]); cmd != nil { + if err := cmd(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "correctful "+os.Args[1]+":", err) + os.Exit(1) + } + return + } + } base := flag.String("base", "", `diff against this ref; "auto" detects it; empty = whole working tree`) repo := flag.String("repo", ".", "repository directory to inspect") format := flag.String("format", "text", "receipt format: text, json, or md") diff --git a/cmd/correctful/subcommands.go b/cmd/correctful/subcommands.go new file mode 100644 index 0000000..3c9fd5c --- /dev/null +++ b/cmd/correctful/subcommands.go @@ -0,0 +1,187 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + + "github.com/joshft/correctful/internal/receipt" + "github.com/joshft/correctful/internal/signing" + "github.com/joshft/correctful/internal/strictjson" + "github.com/joshft/correctful/schema" +) + +// subcommand routes keygen/sign/verify/render. The main command — the one +// that builds and executes the change under review — deliberately has NO +// signing flag: any process that runs reviewed tests must never hold the +// private key, so signing is a separate invocation fed an already-produced +// receipt (see internal/signing). +func subcommand(name string) func([]string) error { + switch name { + case "keygen": + return cmdKeygen + case "sign": + return cmdSign + case "verify": + return cmdVerify + case "render": + return cmdRender + } + return nil +} + +func cmdKeygen(args []string) error { + fs := flag.NewFlagSet("keygen", flag.ExitOnError) + dir := fs.String("out", ".", "directory for the new keypair") + fs.Parse(args) + privPath, pubPath, err := signing.Keygen(*dir) + if err != nil { + return err + } + fmt.Printf("private key: %s (0600 — a CI secret; the probe step must never see it)\n", privPath) + fmt.Printf("public key: %s (pin this in the verify step)\n", pubPath) + return nil +} + +func cmdSign(args []string) error { + fs := flag.NewFlagSet("sign", flag.ExitOnError) + in := fs.String("receipt", "", `unsigned receipt JSON (path, or "-" for stdin)`) + keyPath := fs.String("key", "", "ed25519 private key (PKCS#8 PEM)") + audience := fs.String("audience", "", `stable repository identity to bind, e.g. "github.com/org/repo" (empty binds none — weaker, stated)`) + out := fs.String("out", "", "write the signed receipt here (default stdout)") + fs.Parse(args) + if *in == "" || *keyPath == "" { + return fmt.Errorf("need -receipt and -key") + } + + data, err := readArtifact(*in) + if err != nil { + return err + } + var r schema.Receipt + if err := strictjson.Decode(data, &r); err != nil { + return fmt.Errorf("parsing receipt: %w", err) + } + priv, err := signing.LoadPrivateKey(*keyPath) + if err != nil { + return err + } + signed, err := signing.Sign(r, priv, *audience) + if err != nil { + return err + } + w := io.Writer(os.Stdout) + if *out != "" { + fh, err := os.Create(*out) + if err != nil { + return err + } + defer fh.Close() + w = fh + } + return receipt.WriteJSON(w, signed) +} + +func cmdVerify(args []string) error { + fs := flag.NewFlagSet("verify", flag.ExitOnError) + in := fs.String("receipt", "", "signed receipt JSON (path)") + pubPath := fs.String("pub", "", "trusted ed25519 public key (PKIX PEM) — the trust root, pinned by the verifier, never taken from the receipt") + head := fs.String("head", "", "expected head SHA of the change under review") + base := fs.String("base", "", "expected base SHA (optional extra pin)") + inputDigest := fs.String("input-digest", "", "expected input digest (optional extra pin)") + audience := fs.String("audience", "", "expected audience the signature must be bound to") + anySubject := fs.Bool("any-subject", false, "skip subject matching — authenticity only; states so in the output") + gate := fs.Bool("gate", false, "after verifying, also exit 1 when the receipt's gate blocks") + fs.Parse(args) + if *in == "" || *pubPath == "" { + return fmt.Errorf("need -receipt and -pub") + } + + data, err := readArtifact(*in) + if err != nil { + return err + } + trusted, err := signing.LoadPublicKey(*pubPath) + if err != nil { + return err + } + r, err := signing.Verify(data, trusted, signing.Expect{ + Audience: *audience, + HeadSHA: *head, + BaseSHA: *base, + InputDigest: *inputDigest, + AnySubject: *anySubject, + }) + if err != nil { + return err + } + + fmt.Printf("verified: ed25519 signature over canonical receipt content (schema %s)\n", r.SchemaVersion) + if *anySubject { + fmt.Println("subject: NOT CHECKED (-any-subject) — this proves authenticity, not relevance to any change") + } else { + fmt.Printf("subject: head %.12s matches\n", r.Change.HeadSHA) + } + if a := r.Signature.Audience; a != "" { + fmt.Printf("audience: %q\n", a) + } + if r.GateBlocked() { + fmt.Println("gate: blocked") + if *gate { + os.Exit(1) + } + } else { + fmt.Println("gate: pass") + } + return nil +} + +func cmdRender(args []string) error { + fs := flag.NewFlagSet("render", flag.ExitOnError) + in := fs.String("receipt", "", `receipt JSON (path, or "-" for stdin)`) + format := fs.String("format", "md", "rendering: text or md") + fs.Parse(args) + if *in == "" { + return fmt.Errorf("need -receipt") + } + data, err := readArtifact(*in) + if err != nil { + return err + } + var r schema.Receipt + if err := strictjson.Decode(data, &r); err != nil { + return fmt.Errorf("parsing receipt: %w", err) + } + switch *format { + case "md": + receipt.WriteMarkdown(os.Stdout, r) + case "text": + receipt.WriteText(os.Stdout, r) + default: + return fmt.Errorf("unknown -format %q (want text or md)", *format) + } + return nil +} + +func readArtifact(path string) ([]byte, error) { + var rd io.Reader + if path == "-" { + rd = os.Stdin + } else { + fh, err := os.Open(path) + if err != nil { + return nil, err + } + defer fh.Close() + rd = fh + } + data, err := io.ReadAll(io.LimitReader(rd, signing.MaxArtifactBytes+1)) + if err != nil { + return nil, err + } + if len(data) > signing.MaxArtifactBytes { + return nil, fmt.Errorf("receipt exceeds %d bytes", signing.MaxArtifactBytes) + } + return data, nil +} diff --git a/internal/gitdiff/gitdiff.go b/internal/gitdiff/gitdiff.go index 090dee4..b263886 100644 --- a/internal/gitdiff/gitdiff.go +++ b/internal/gitdiff/gitdiff.go @@ -13,6 +13,7 @@ import ( "path/filepath" "sort" "strings" + "syscall" ) // Change is the resolved file set of a diff. BaseSHA/HeadSHA pin the receipt @@ -236,11 +237,16 @@ func TrackedByPattern(ctx context.Context, dir string, patterns ...string) ([]st // bytes, same digest. // // The formula, so anyone can recompute it: for each file of the resolved set -// in ascending path order, feed the outer SHA-256 the path, a NUL, then the -// 32 raw bytes of the file content's own SHA-256 (the string "absent" instead -// when the path is not a readable regular file — a deletion is part of the -// change's identity too), then a newline. Per-file inner hashing makes file -// boundaries unambiguous regardless of content bytes. +// in ascending path order, feed the outer SHA-256 the path, a NUL, the +// file's KIND tag ("file", "exec" when any execute bit is set, "symlink", +// or "absent"), a NUL, then the 32 raw bytes of the content's own SHA-256 +// (for a symlink, the SHA-256 of its target STRING — the link itself is the +// content, never followed; nothing for "absent" — a deletion is part of the +// change's identity too), then a newline. The kind tag is load-bearing: an +// execute-bit flip or a file-to-symlink swap changes probe behavior with +// identical content bytes, so it must change the digest — a signed receipt +// pins "one exact change", not "one content set". Per-file inner hashing +// makes file boundaries unambiguous regardless of content bytes. func InputDigest(dir string, files []string) string { sorted := append([]string(nil), files...) sort.Strings(sorted) @@ -248,33 +254,50 @@ func InputDigest(dir string, files []string) string { for _, f := range sorted { io.WriteString(outer, f) outer.Write([]byte{0}) - if sum, ok := fileSHA256(filepath.Join(dir, f)); ok { - outer.Write(sum) - } else { - io.WriteString(outer, "absent") - } + kind, sum := inputEntry(filepath.Join(dir, f)) + io.WriteString(outer, kind) + outer.Write([]byte{0}) + outer.Write(sum) outer.Write([]byte{'\n'}) } return hex.EncodeToString(outer.Sum(nil)) } -// fileSHA256 streams a regular file into a SHA-256, reporting !ok for -// anything unreadable or non-regular (deleted files, directories, symlink -// targets outside the tree). -func fileSHA256(abs string) ([]byte, bool) { - fh, err := os.Open(abs) +// inputEntry classifies one path and hashes its content without ever +// following a symlink (O_NOFOLLOW so the classification and the open cannot +// be raced apart). Directories, devices, and anything unreadable are +// "absent" — not harvestable content. +func inputEntry(abs string) (kind string, sum []byte) { + fi, err := os.Lstat(abs) if err != nil { - return nil, false + return "absent", nil + } + if fi.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(abs) + if err != nil { + return "absent", nil + } + h := sha256.Sum256([]byte(target)) + return "symlink", h[:] + } + fh, err := os.OpenFile(abs, os.O_RDONLY|syscall.O_NOFOLLOW, 0) + if err != nil { + return "absent", nil } defer fh.Close() - if fi, err := fh.Stat(); err != nil || !fi.Mode().IsRegular() { - return nil, false + st, err := fh.Stat() + if err != nil || !st.Mode().IsRegular() { + return "absent", nil } h := sha256.New() if _, err := io.Copy(h, fh); err != nil { - return nil, false + return "absent", nil + } + kind = "file" + if st.Mode().Perm()&0o111 != 0 { + kind = "exec" } - return h.Sum(nil), true + return kind, h.Sum(nil) } func run(ctx context.Context, dir string, args ...string) (string, error) { diff --git a/internal/gitdiff/gitdiff_test.go b/internal/gitdiff/gitdiff_test.go index dd03062..148a2ea 100644 --- a/internal/gitdiff/gitdiff_test.go +++ b/internal/gitdiff/gitdiff_test.go @@ -253,3 +253,55 @@ func TestInputDigestPinsWorkingTreeContent(t *testing.T) { t.Errorf("deleted file invisible to the digest") } } + +// TestInputDigestPinsKindNotJustContent: the digest must change when probe- +// visible file identity changes even though content bytes do not — an +// execute-bit flip, or a regular file swapped for a symlink whose target has +// identical bytes. A signed receipt pins "one exact change"; content-only +// hashing would let these mutations ride under an already-signed digest. +func TestInputDigestPinsKindNotJustContent(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "run.sh") + if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + plain := InputDigest(dir, []string{"run.sh"}) + + if err := os.Chmod(p, 0o755); err != nil { + t.Fatal(err) + } + if exec := InputDigest(dir, []string{"run.sh"}); exec == plain { + t.Errorf("execute-bit flip invisible to the digest") + } + + // Same content reachable through a symlink must digest differently: the + // link ITSELF is the content (its target string), never followed. + if err := os.WriteFile(filepath.Join(dir, "real.sh"), []byte("#!/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(p); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real.sh", p); err != nil { + t.Fatal(err) + } + link := InputDigest(dir, []string{"run.sh"}) + if link == plain { + t.Errorf("file-to-symlink swap invisible to the digest") + } + + // Retargeting the link changes the digest even when both targets hold + // identical bytes — the target STRING is what the link contributes. + if err := os.WriteFile(filepath.Join(dir, "other.sh"), []byte("#!/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(p); err != nil { + t.Fatal(err) + } + if err := os.Symlink("other.sh", p); err != nil { + t.Fatal(err) + } + if retargeted := InputDigest(dir, []string{"run.sh"}); retargeted == link { + t.Errorf("symlink retarget invisible to the digest") + } +} diff --git a/internal/intake/intake.go b/internal/intake/intake.go index e40a232..6be5a50 100644 --- a/internal/intake/intake.go +++ b/internal/intake/intake.go @@ -30,9 +30,7 @@ package intake import ( - "bytes" "crypto/sha256" - "encoding/json" "errors" "fmt" "io" @@ -42,6 +40,7 @@ import ( "strings" "syscall" + "github.com/joshft/correctful/internal/strictjson" "github.com/joshft/correctful/schema" ) @@ -386,77 +385,10 @@ func readOutsideTree(path, repoRoot string, maxBytes int64) ([]byte, error) { } // strictDecode parses JSON with unknown fields rejected, trailing content -// refused, and DUPLICATE KEYS refused. The stdlib decoder silently keeps a -// duplicate's last value — demonstrated to smuggle a second "outcome": -// "verified" behind a "counterexample" — and last-wins parsing would also -// make any future signature ambiguous across JSON parsers. +// refused, and DUPLICATE KEYS refused — see internal/strictjson for why +// each leg is load-bearing at this boundary. func strictDecode(data []byte, v any) error { - if err := rejectDupKeys(json.NewDecoder(bytes.NewReader(data))); err != nil { - return err - } - dec := json.NewDecoder(bytes.NewReader(data)) - dec.DisallowUnknownFields() - if err := dec.Decode(v); err != nil { - return err - } - if dec.More() { - return fmt.Errorf("trailing content after the JSON document") - } - return nil -} - -// rejectDupKeys walks the token stream and fails on a repeated object key -// at any depth. -func rejectDupKeys(dec *json.Decoder) error { - t, err := dec.Token() - if err != nil { - return err - } - return rejectDupKeysIn(dec, t) -} - -func rejectDupKeysIn(dec *json.Decoder, t json.Token) error { - d, ok := t.(json.Delim) - if !ok { - return nil - } - switch d { - case '{': - seen := map[string]bool{} - for dec.More() { - kt, err := dec.Token() - if err != nil { - return err - } - k, _ := kt.(string) - if seen[k] { - return fmt.Errorf("duplicate key %q", k) - } - seen[k] = true - vt, err := dec.Token() - if err != nil { - return err - } - if err := rejectDupKeysIn(dec, vt); err != nil { - return err - } - } - _, err := dec.Token() // consume '}' - return err - case '[': - for dec.More() { - vt, err := dec.Token() - if err != nil { - return err - } - if err := rejectDupKeysIn(dec, vt); err != nil { - return err - } - } - _, err := dec.Token() // consume ']' - return err - } - return nil + return strictjson.Decode(data, v) } // scrub strips control characters — C0 (except newline and tab), DEL, and diff --git a/internal/receipt/canonical.go b/internal/receipt/canonical.go new file mode 100644 index 0000000..3155321 --- /dev/null +++ b/internal/receipt/canonical.go @@ -0,0 +1,28 @@ +package receipt + +import ( + "bytes" + "encoding/json" + + "github.com/joshft/correctful/schema" +) + +// Canonical serializes a receipt into its one canonical byte form: the +// two-space-indented encoding WriteJSON has always emitted, trailing +// newline included. The signature layer signs and verifies exactly these +// bytes (with the signature block absent), and the verifier additionally +// requires a signed artifact to BE its own canonical form byte-for-byte — +// so there is no room for a normalization differential where one parser +// reads a receipt one way and this tool another. Every encoder property is +// therefore load-bearing and frozen by golden-vector test: struct-order +// keys, sorted map keys (encoding/json sorts them), HTML-escaped <>&, and +// the indent. +func Canonical(r schema.Receipt) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + if err := enc.Encode(r); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/receipt/markdown.go b/internal/receipt/markdown.go index 6cfcbad..9a6cc78 100644 --- a/internal/receipt/markdown.go +++ b/internal/receipt/markdown.go @@ -112,6 +112,9 @@ func WriteMarkdown(w io.Writer, r schema.Receipt) { if cov.SuppressedMentions > 0 { fmt.Fprintf(w, "%s\n", mentionNote(cov.SuppressedMentions)) } + if note := signatureNote(r); note != "" { + fmt.Fprintf(w, "%s\n", note) + } fmt.Fprintf(w, "\nschema %s%s · exit gate: %s; the remainder informs, never fails\n", r.SchemaVersion, toolNote(r), gateLegs(r)) } diff --git a/internal/receipt/receipt.go b/internal/receipt/receipt.go index a560d75..c8bcba6 100644 --- a/internal/receipt/receipt.go +++ b/internal/receipt/receipt.go @@ -4,7 +4,6 @@ package receipt import ( - "encoding/json" "fmt" "io" "os" @@ -443,9 +442,12 @@ func weigh(c schema.Claim, evs []schema.Evidence) (schema.Status, schema.Tier) { // WriteJSON emits the receipt as indented JSON — the payload other tools read. func WriteJSON(w io.Writer, r schema.Receipt) error { - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(r) + b, err := Canonical(r) + if err != nil { + return err + } + _, err = w.Write(b) + return err } // WriteText renders the receipt for a human at a terminal. The remainder gets @@ -462,6 +464,9 @@ func WriteText(w io.Writer, r schema.Receipt) { if note := exclusionNote(r.Change.Excluded); note != "" { fmt.Fprintf(w, " %s\n", note) } + if note := signatureNote(r); note != "" { + fmt.Fprintf(w, "%s\n", note) + } if p := r.Policy; p != nil { fmt.Fprintf(w, "policy: %s · %s · %d rule(s)%s\n", p.Path, short(p.Digest), p.Rules, exemptNote(p)) } @@ -558,6 +563,31 @@ func mentionNote(n int) string { return fmt.Sprintf("%d spec-id mention(s) not minted as claims — the repo defines no spec-id corpus, so a reference has no possible referent", n) } +// signatureNote states that a signature block is PRESENT without claiming +// it is valid: a rendering is not a signed artifact, and only `correctful +// verify` against the JSON can authenticate anything. Saying less would +// hide the block; saying more would lend a pasted rendering an authority +// no reader can check. +func signatureNote(r schema.Receipt) string { + b := r.Signature + if b == nil { + return "" + } + aud := "" + if b.Audience != "" { + aud = fmt.Sprintf(" · audience %q", b.Audience) + } + return fmt.Sprintf("signature: ed25519 by %s%s — UNVERIFIED HERE; this rendering is not signed, authenticate the JSON artifact with `correctful verify`", shortB64(b.PublicKey), aud) +} + +// shortB64 abbreviates a base64 key for display. +func shortB64(s string) string { + if len(s) > 12 { + return s[:12] + "…" + } + return s +} + // toolNote renders the producing build beside the schema version — shared by // both renderers so the chain field is visible wherever the receipt is read. func toolNote(r schema.Receipt) string { diff --git a/internal/receipt/validate.go b/internal/receipt/validate.go new file mode 100644 index 0000000..b7c7934 --- /dev/null +++ b/internal/receipt/validate.go @@ -0,0 +1,122 @@ +package receipt + +import ( + "fmt" + "reflect" + "regexp" + + "github.com/joshft/correctful/schema" +) + +var hexDigestRe = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// ValidateConsistency recomputes every locally derivable field of a receipt +// and rejects any mismatch. The signer runs it before signing and the +// verifier after signature checking, because a signature authenticates +// bytes, not coherence: a wrapper that signs one refuted result with +// Summary.Refuted set to zero would otherwise carry a valid signature past +// GateBlocked, which reads the summary. This catches bugs and inconsistent +// fraud; it cannot catch a key holder that fabricates a CONSISTENT false +// receipt — that trust lives with the key, and the docs say so. +// +// Policy and intake are validated structurally only: their source +// documents are digest-pinned, not embedded, so their findings cannot be +// recomputed here — pretending otherwise would be false assurance. +func ValidateConsistency(r schema.Receipt) error { + if r.SchemaVersion != schema.SchemaVersion { + return fmt.Errorf("schema %q is not %q: this build validates only the schema it ships", r.SchemaVersion, schema.SchemaVersion) + } + + // Every weighed field re-derives from the claim and its evidence rows. + var verified, refuted, unverified int + tierCounts := map[string]int{} + claims := make([]schema.Claim, 0, len(r.Results)) + var remainder []schema.ClaimResult + for i, res := range r.Results { + status, tier := weigh(res.Claim, res.Evidence) + if res.Status != status || res.EffectiveTier != tier { + return fmt.Errorf("result %d (%s): stated %s/%s, evidence weighs to %s/%s", i, res.Claim.ID, res.Status, res.EffectiveTier, status, tier) + } + claims = append(claims, res.Claim) + tierCounts[tier.String()]++ + switch status { + case schema.StatusVerified: + verified++ + case schema.StatusRefuted: + refuted++ + default: + unverified++ + remainder = append(remainder, res) + } + } + + if !reflect.DeepEqual(r.Remainder, remainder) { + return fmt.Errorf("remainder does not equal the unverified subset of results (%d stated, %d derived)", len(r.Remainder), len(remainder)) + } + + s := r.Summary + if s.TotalClaims != len(r.Results) || s.Verified != verified || s.Refuted != refuted || s.Unverified != unverified { + return fmt.Errorf("summary arithmetic (%d/%d/%d/%d) does not match results (%d/%d/%d/%d)", + s.TotalClaims, s.Verified, s.Refuted, s.Unverified, len(r.Results), verified, refuted, unverified) + } + if len(s.TierCounts) != len(tierCounts) { + return fmt.Errorf("tier counts carry %d labels, results derive %d", len(s.TierCounts), len(tierCounts)) + } + for label, n := range tierCounts { + if s.TierCounts[label] != n { + return fmt.Errorf("tier count %q is %d, results derive %d", label, s.TierCounts[label], n) + } + } + if !reflect.DeepEqual(s.Anchoring, anchoringSummary(claims)) { + return fmt.Errorf("anchoring summary does not match the claims it summarizes") + } + + if err := validateCoverage(r.Coverage); err != nil { + return err + } + + if p := r.Policy; p != nil { + if !hexDigestRe.MatchString(p.Digest) { + return fmt.Errorf("policy digest %q is not a sha256 hex digest", p.Digest) + } + for _, m := range p.Misses { + if m.File == "" || m.Rule == "" { + return fmt.Errorf("policy miss with empty file or rule") + } + } + } + + for _, rec := range r.Intake { + if rec.MaxTier < schema.T1Assertion || rec.MaxTier > schema.T4Mechanical { + return fmt.Errorf("intake %q states max tier %d outside 1..4", rec.Supplier, rec.MaxTier) + } + if !rec.Admitted && rec.Accepted != 0 { + return fmt.Errorf("intake %q accepted %d rows from a document it did not admit", rec.Supplier, rec.Accepted) + } + } + return nil +} + +// validateCoverage re-derives the coverage arithmetic from its own file +// rows. SuppressedMentions is set outside the tally and is not derivable. +func validateCoverage(c schema.Coverage) error { + var claimed, scanned, unread, unreadPolicy int + for _, fc := range c.Files { + switch { + case fc.Claims > 0: + claimed++ + case len(fc.ReadBy) > 0: + scanned++ + default: + unread++ + if fc.SkipReason == "hidden-path" { + unreadPolicy++ + } + } + } + if c.Claimed != claimed || c.Scanned != scanned || c.Unread != unread || c.UnreadPolicy != unreadPolicy { + return fmt.Errorf("coverage arithmetic (%d/%d/%d/%d) does not match its file rows (%d/%d/%d/%d)", + c.Claimed, c.Scanned, c.Unread, c.UnreadPolicy, claimed, scanned, unread, unreadPolicy) + } + return nil +} diff --git a/internal/receipt/validate_test.go b/internal/receipt/validate_test.go new file mode 100644 index 0000000..f2eb0c9 --- /dev/null +++ b/internal/receipt/validate_test.go @@ -0,0 +1,108 @@ +package receipt + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "github.com/joshft/correctful/internal/gitdiff" + "github.com/joshft/correctful/schema" +) + +func consistentReceipt(t *testing.T) schema.Receipt { + t.Helper() + claims, evidence := sampleClaims() + cov := schema.Coverage{ + Files: []schema.FileCoverage{ + {File: "x.go", ReadBy: []string{"gotest"}, Claims: 2}, + {File: "y.go", ReadBy: []string{"spec-ref"}, Claims: 1}, + {File: "z.bin", SkipReason: "no-harvester"}, + {File: ".ci/tool.cfg", SkipReason: "hidden-path"}, + }, + Claimed: 2, + Unread: 2, + UnreadPolicy: 1, + } + r := Assemble(gitdiff.Change{Repo: "repo", BaseRef: "main", HeadRef: "wip"}, claims, evidence, cov) + r.ToolVersion = "test" + return r +} + +// TestValidateConsistencyAcceptsAssembledReceipt: whatever Assemble +// produces must validate — the validator re-derives with the same rules +// the assembler derives with, or every honest receipt would fail. +func TestValidateConsistencyAcceptsAssembledReceipt(t *testing.T) { + if err := ValidateConsistency(consistentReceipt(t)); err != nil { + t.Fatalf("assembled receipt rejected: %v", err) + } +} + +// TestValidateConsistencyRejectsTampering: each mutation below is a field +// a wrapper could edit after assembly and before signing; every one must +// be named and refused. The zeroed-refuted case is the codex-review attack +// — GateBlocked reads Summary.Refuted, so a signature over the tampered +// summary would otherwise carry a refutation past the gate. +func TestValidateConsistencyRejectsTampering(t *testing.T) { + cases := []struct { + name string + mutate func(*schema.Receipt) + want string + }{ + {"zeroed refuted count", func(r *schema.Receipt) { r.Summary.Refuted = 0; r.Summary.Verified = 2 }, "summary arithmetic"}, + {"status flip", func(r *schema.Receipt) { r.Results[1].Status = schema.StatusVerified }, "evidence weighs to"}, + {"tier inflation", func(r *schema.Receipt) { r.Results[0].EffectiveTier = schema.T4Mechanical }, "evidence weighs to"}, + {"remainder dropped", func(r *schema.Receipt) { r.Remainder = nil }, "remainder"}, + {"tier counts", func(r *schema.Receipt) { r.Summary.TierCounts["T4-mechanical"] = 3 }, "tier count"}, + {"coverage arithmetic", func(r *schema.Receipt) { r.Coverage.Unread = 0 }, "coverage arithmetic"}, + {"alien schema", func(r *schema.Receipt) { r.SchemaVersion = "9.9.9" }, "schema"}, + {"policy digest shape", func(r *schema.Receipt) { + r.Policy = &schema.PolicyResult{Path: "correctful.json", Digest: "not-hex", Rules: 1} + }, "sha256"}, + {"intake accepted without admission", func(r *schema.Receipt) { + r.Intake = []schema.IntakeRecord{{Supplier: "s", MaxTier: schema.T3Property, Accepted: 2}} + }, "did not admit"}, + } + for _, c := range cases { + r := consistentReceipt(t) + c.mutate(&r) + err := ValidateConsistency(r) + if err == nil { + t.Fatalf("%s: accepted", c.name) + } + if !strings.Contains(err.Error(), c.want) { + t.Fatalf("%s: error %q does not name %q", c.name, err, c.want) + } + } +} + +// TestCanonicalGoldenVector freezes the canonical encoding: struct-order +// keys, sorted map keys, HTML escaping, two-space indent, trailing +// newline. If this digest moves and you did not deliberately change the +// schema or the encoder, you have changed what existing signatures verify +// over — stop. On a deliberate schema change, re-pin and say so in the +// commit message. +func TestCanonicalGoldenVector(t *testing.T) { + b, err := Canonical(consistentReceipt(t)) + if err != nil { + t.Fatal(err) + } + const want = "f52a70f30f50c45bb5d51401e2d5866d34287600d8a5024aaf42a53976b43297" + if got := hex.EncodeToString(sha256sum(b)); got != want { + t.Fatalf("canonical encoding drifted:\n got sha256 %s\nwant sha256 %s\nfirst 200 bytes:\n%s", got, want, b[:200]) + } + // WriteJSON must emit exactly the canonical bytes — one encoder, one + // byte-form. + var sb strings.Builder + if err := WriteJSON(&sb, consistentReceipt(t)); err != nil { + t.Fatal(err) + } + if sb.String() != string(b) { + t.Fatalf("WriteJSON diverges from Canonical") + } +} + +func sha256sum(b []byte) []byte { + h := sha256.Sum256(b) + return h[:] +} diff --git a/internal/signing/keys.go b/internal/signing/keys.go new file mode 100644 index 0000000..f5c3364 --- /dev/null +++ b/internal/signing/keys.go @@ -0,0 +1,136 @@ +package signing + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "os" + "path/filepath" + "syscall" +) + +// maxKeyFileBytes bounds a key file read — a PEM Ed25519 key is a few +// hundred bytes; anything near the cap is not a key. +const maxKeyFileBytes = 16 << 10 + +// Keygen mints an Ed25519 keypair into dir as correctful.key (PKCS#8 PEM, +// 0600) and correctful.pub (PKIX PEM, 0644). Both files are created +// exclusively — an existing file or a pre-planted symlink at either path +// fails the whole operation, and a failed public write removes the private +// file so no partial pair survives. +func Keygen(dir string) (privPath, pubPath string, err error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", "", err + } + privDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return "", "", err + } + pubDER, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return "", "", err + } + privPath = filepath.Join(dir, "correctful.key") + pubPath = filepath.Join(dir, "correctful.pub") + + if err := writeExclusive(privPath, pemBytes("PRIVATE KEY", privDER), 0o600); err != nil { + return "", "", err + } + if err := writeExclusive(pubPath, pemBytes("PUBLIC KEY", pubDER), 0o644); err != nil { + os.Remove(privPath) + return "", "", err + } + return privPath, pubPath, nil +} + +func pemBytes(blockType string, der []byte) []byte { + return pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der}) +} + +func writeExclusive(path string, data []byte, mode os.FileMode) error { + fh, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, mode) + if err != nil { + return fmt.Errorf("creating %s: %w", path, err) + } + _, werr := fh.Write(data) + cerr := fh.Close() + if werr != nil { + os.Remove(path) + return werr + } + if cerr != nil { + os.Remove(path) + return cerr + } + return nil +} + +// LoadPrivateKey reads exactly one PKCS#8 PEM block holding an Ed25519 +// private key. Trailing data, a second block, or any other key type fails — +// a key file that is not precisely what it claims is an operator error +// worth stopping on. The path may be a symlink on purpose: CI secret +// mounts are symlink farms, and the key file is operator-owned trusted +// input, not attacker-controlled data. +func LoadPrivateKey(path string) (ed25519.PrivateKey, error) { + der, err := readSinglePEM(path, "PRIVATE KEY") + if err != nil { + return nil, err + } + k, err := x509.ParsePKCS8PrivateKey(der) + if err != nil { + return nil, fmt.Errorf("%s: %v", path, err) + } + priv, ok := k.(ed25519.PrivateKey) + if !ok { + return nil, fmt.Errorf("%s holds a %T, not an ed25519 private key", path, k) + } + return priv, nil +} + +// LoadPublicKey reads exactly one PKIX PEM block holding an Ed25519 public +// key, under the same single-block strictness as LoadPrivateKey. +func LoadPublicKey(path string) (ed25519.PublicKey, error) { + der, err := readSinglePEM(path, "PUBLIC KEY") + if err != nil { + return nil, err + } + k, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil, fmt.Errorf("%s: %v", path, err) + } + pub, ok := k.(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("%s holds a %T, not an ed25519 public key", path, k) + } + return pub, nil +} + +func readSinglePEM(path, wantType string) ([]byte, error) { + fh, err := os.Open(path) + if err != nil { + return nil, err + } + defer fh.Close() + data, err := io.ReadAll(io.LimitReader(fh, maxKeyFileBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxKeyFileBytes { + return nil, fmt.Errorf("%s exceeds %d bytes — not a key file", path, maxKeyFileBytes) + } + block, rest := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("%s holds no PEM block", path) + } + if block.Type != wantType { + return nil, fmt.Errorf("%s holds a %q block, want %q", path, block.Type, wantType) + } + if len(rest) != 0 { + return nil, fmt.Errorf("%s carries %d bytes after the PEM block — one key per file", path, len(rest)) + } + return block.Bytes, nil +} diff --git a/internal/signing/keys_test.go b/internal/signing/keys_test.go new file mode 100644 index 0000000..77e00b1 --- /dev/null +++ b/internal/signing/keys_test.go @@ -0,0 +1,150 @@ +package signing + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/joshft/correctful/internal/receipt" +) + +// TestKeygenAndLoadRoundTrip: mint, load both halves, and prove they are +// one pair by signing and verifying through the real API. The private file +// must be 0600. +func TestKeygenAndLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + privPath, pubPath, err := Keygen(dir) + if err != nil { + t.Fatalf("Keygen: %v", err) + } + fi, err := os.Stat(privPath) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("private key mode %v, want 0600", fi.Mode().Perm()) + } + priv, err := LoadPrivateKey(privPath) + if err != nil { + t.Fatalf("LoadPrivateKey: %v", err) + } + pub, err := LoadPublicKey(pubPath) + if err != nil { + t.Fatalf("LoadPublicKey: %v", err) + } + signed, err := Sign(fixtureReceipt(t), priv, "") + if err != nil { + t.Fatal(err) + } + artifact, err := receipt.Canonical(signed) + if err != nil { + t.Fatal(err) + } + if _, err := Verify(artifact, pub, Expect{AnySubject: true}); err != nil { + t.Fatalf("minted pair does not verify its own signature: %v", err) + } +} + +// TestKeygenRefusesExistingAndLeavesNoPartialPair: an existing file at +// either path fails the whole operation, and a public-half failure removes +// the already-written private half. +func TestKeygenRefusesExistingAndLeavesNoPartialPair(t *testing.T) { + dir := t.TempDir() + if _, _, err := Keygen(dir); err != nil { + t.Fatal(err) + } + if _, _, err := Keygen(dir); err == nil { + t.Fatalf("second Keygen over an existing pair succeeded") + } + + dir2 := t.TempDir() + // Pre-plant the PUBLIC path so the private write succeeds and the + // public write fails: no partial pair may survive. + if err := os.WriteFile(filepath.Join(dir2, "correctful.pub"), []byte("occupied"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := Keygen(dir2); err == nil { + t.Fatalf("Keygen succeeded over an occupied public path") + } + if _, err := os.Lstat(filepath.Join(dir2, "correctful.key")); !os.IsNotExist(err) { + t.Errorf("private key left behind after failed pair: %v", err) + } +} + +// TestKeygenRefusesPlantedSymlink: O_EXCL|O_NOFOLLOW means a pre-created +// symlink cannot redirect either output. +func TestKeygenRefusesPlantedSymlink(t *testing.T) { + dir := t.TempDir() + if err := os.Symlink(filepath.Join(dir, "elsewhere"), filepath.Join(dir, "correctful.key")); err != nil { + t.Fatal(err) + } + if _, _, err := Keygen(dir); err == nil { + t.Fatalf("Keygen followed a planted symlink") + } + if _, err := os.Lstat(filepath.Join(dir, "elsewhere")); !os.IsNotExist(err) { + t.Errorf("symlink target was created: %v", err) + } +} + +// TestLoadKeyRejections: a key file must be exactly one PEM block of +// exactly the right type holding exactly an Ed25519 key — RSA keys, +// trailing bytes, second blocks, and non-PEM garbage all fail loudly. +func TestLoadKeyRejections(t *testing.T) { + dir := t.TempDir() + write := func(name string, data []byte) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, data, 0o600); err != nil { + t.Fatal(err) + } + return p + } + + rsaKey, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatal(err) + } + rsaDER, err := x509.MarshalPKCS8PrivateKey(rsaKey) + if err != nil { + t.Fatal(err) + } + rsaPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: rsaDER}) + + _, edPriv, _ := ed25519.GenerateKey(rand.Reader) + edDER, _ := x509.MarshalPKCS8PrivateKey(edPriv) + edPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: edDER}) + + cases := []struct { + name string + data []byte + want string + }{ + {"rsa key", rsaPEM, "not an ed25519"}, + {"trailing data", append(append([]byte{}, edPEM...), "extra\n"...), "after the PEM block"}, + {"two blocks", append(append([]byte{}, edPEM...), edPEM...), "after the PEM block"}, + {"wrong block type", pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: edDER}), `want "PRIVATE KEY"`}, + {"garbage", []byte("not a pem at all"), "no PEM block"}, + } + for _, c := range cases { + p := write(strings.ReplaceAll(c.name, " ", "-"), c.data) + _, err := LoadPrivateKey(p) + if err == nil { + t.Fatalf("%s: accepted", c.name) + } + if !strings.Contains(err.Error(), c.want) { + t.Fatalf("%s: error %q does not name %q", c.name, err, c.want) + } + } + + // A private key where a public one is expected fails on block type. + privPath := write("priv-as-pub", edPEM) + if _, err := LoadPublicKey(privPath); err == nil || !strings.Contains(err.Error(), `want "PUBLIC KEY"`) { + t.Fatalf("private key accepted as public: %v", err) + } +} diff --git a/internal/signing/signing.go b/internal/signing/signing.go new file mode 100644 index 0000000..662f5dc --- /dev/null +++ b/internal/signing/signing.go @@ -0,0 +1,233 @@ +// Package signing authenticates receipts: an Ed25519 signature over the +// domain-separated canonical payload, minted by a process that runs no +// probes and reads no repository tree. +// +// The separation is the security design, not a convenience. The receipt +// producer builds and executes the change under review — its tests run as +// the same user, so any key that process can read, reviewed code can read. +// Signing therefore lives in its own subcommand fed an already-produced +// receipt, and the probe-running command has no signing flag to misuse. In +// CI that means three steps: produce (no key present), sign (key present, +// no reviewed code runs), verify (protected workflow, pinned public key). +// +// What a valid signature proves: the holder of the private key produced +// exactly this canonical content. What it does not prove: that the runner +// ran honestly, that the key was never stolen, or that this receipt is the +// freshest run for its subject — replay among same-subject runs is the +// verifier's freshness policy, stated in the docs, not solved here. +package signing + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "fmt" + "strings" + + "github.com/joshft/correctful/internal/receipt" + "github.com/joshft/correctful/internal/strictjson" + "github.com/joshft/correctful/schema" +) + +// domain separates receipt signatures from any other use of the same key, +// and versions the canonicalization: bytes signed under a future v2 can +// never verify as v1. +const domain = "correctful-receipt-v1\x00" + +// MaxArtifactBytes bounds every receipt artifact this package will parse — +// unbounded input must never transit into memory unchecked. +const MaxArtifactBytes = 16 << 20 + +// Expect pins what the verifier requires beyond a valid signature. A +// signature alone authenticates SOME receipt by SOME run — subject matching +// is what ties it to the change the caller is gating, so HeadSHA is +// mandatory unless AnySubject explicitly waives it (authenticity-only +// checks of archived receipts). +type Expect struct { + Audience string + HeadSHA string + BaseSHA string // optional extra pin + InputDigest string // optional extra pin + AnySubject bool +} + +// Sign validates the receipt's internal consistency, signs its canonical +// payload, and returns the receipt with the signature block set. It refuses +// an already-signed receipt — re-signing must be an explicit decision made +// on the unsigned artifact, never a silent overwrite. +func Sign(r schema.Receipt, priv ed25519.PrivateKey, audience string) (schema.Receipt, error) { + if r.Signature != nil { + return r, fmt.Errorf("receipt is already signed (by key %s); sign the unsigned artifact", shortKey(r.Signature.PublicKey)) + } + if err := checkAudience(audience); err != nil { + return r, err + } + if err := receipt.ValidateConsistency(r); err != nil { + return r, fmt.Errorf("refusing to sign an inconsistent receipt: %w", err) + } + payload, err := receipt.Canonical(r) + if err != nil { + return r, err + } + pub, ok := priv.Public().(ed25519.PublicKey) + if !ok || len(priv) != ed25519.PrivateKeySize { + return r, fmt.Errorf("private key is not a usable ed25519 key") + } + sig := ed25519.Sign(priv, preimage(audience, payload)) + r.Signature = &schema.SignatureBlock{ + Alg: "ed25519", + PublicKey: base64.StdEncoding.EncodeToString(pub), + Audience: audience, + Sig: base64.StdEncoding.EncodeToString(sig), + } + return r, nil +} + +// Verify authenticates a signed receipt artifact against a trusted public +// key and the caller's expectations, returning the parsed receipt on +// success. Every check fails loudly with its own reason: +// +// 1. strict parse — unknown fields, duplicate keys, trailing bytes, and +// invalid UTF-8 are all differentials, not formatting; +// 2. canonical byte-identity — the artifact must BE its own canonical +// re-encoding, so no two byte-forms of one receipt both verify; +// 3. signature — over the domain-separated payload with the block absent, +// using the CALLER's key (the embedded key is an identity claim and +// must match it; trusting the embedded key alone verifies nothing); +// 4. consistency — a signature authenticates bytes, not coherence; +// 5. subject — the receipt must be about the change the caller is gating. +func Verify(artifact []byte, trusted ed25519.PublicKey, exp Expect) (schema.Receipt, error) { + var r schema.Receipt + if len(artifact) > MaxArtifactBytes { + return r, fmt.Errorf("artifact exceeds %d bytes", MaxArtifactBytes) + } + if err := strictjson.Decode(artifact, &r); err != nil { + return r, fmt.Errorf("parsing receipt: %w", err) + } + canon, err := receipt.Canonical(r) + if err != nil { + return r, err + } + if !bytes.Equal(canon, artifact) { + return r, fmt.Errorf("artifact is not in canonical form — the signature covers canonical receipt content, and a non-canonical byte-form is a parser differential, not formatting") + } + + b := r.Signature + if b == nil { + return r, fmt.Errorf("receipt carries no signature") + } + if b.Alg != "ed25519" { + return r, fmt.Errorf("unsupported signature alg %q (only ed25519)", b.Alg) + } + if err := checkAudience(b.Audience); err != nil { + return r, err + } + pub, err := decodeB64(b.PublicKey, ed25519.PublicKeySize, "public key") + if err != nil { + return r, err + } + sig, err := decodeB64(b.Sig, ed25519.SignatureSize, "signature") + if err != nil { + return r, err + } + if len(trusted) != ed25519.PublicKeySize { + return r, fmt.Errorf("trusted key is not a usable ed25519 public key") + } + if !bytes.Equal(pub, trusted) { + return r, fmt.Errorf("receipt is signed by %s, not the trusted key %s", shortKey(b.PublicKey), shortKey(base64.StdEncoding.EncodeToString(trusted))) + } + if b.Audience != exp.Audience { + return r, fmt.Errorf("audience mismatch: receipt is bound to %q, verifier expects %q", b.Audience, exp.Audience) + } + + unsigned := r + unsigned.Signature = nil + payload, err := receipt.Canonical(unsigned) + if err != nil { + return r, err + } + if !ed25519.Verify(trusted, preimage(b.Audience, payload), sig) { + return r, fmt.Errorf("signature is invalid for this content") + } + + if err := receipt.ValidateConsistency(r); err != nil { + return r, fmt.Errorf("authenticated receipt is internally inconsistent: %w", err) + } + + if !exp.AnySubject { + if exp.HeadSHA == "" { + return r, fmt.Errorf("no expected head SHA: a signature authenticates SOME receipt — pass the change under review, or -any-subject to explicitly skip subject matching") + } + if r.Change.HeadSHA != exp.HeadSHA { + return r, fmt.Errorf("subject mismatch: receipt is for head %s, expected %s", short(r.Change.HeadSHA), short(exp.HeadSHA)) + } + if exp.BaseSHA != "" && r.Change.BaseSHA != exp.BaseSHA { + return r, fmt.Errorf("subject mismatch: receipt is for base %s, expected %s", short(r.Change.BaseSHA), short(exp.BaseSHA)) + } + if exp.InputDigest != "" && r.Change.InputDigest != exp.InputDigest { + return r, fmt.Errorf("subject mismatch: receipt pins input digest %s, expected %s", short(r.Change.InputDigest), short(exp.InputDigest)) + } + } + return r, nil +} + +func preimage(audience string, payload []byte) []byte { + out := make([]byte, 0, len(domain)+len(audience)+1+len(payload)) + out = append(out, domain...) + out = append(out, audience...) + out = append(out, 0) + out = append(out, payload...) + return out +} + +// checkAudience keeps the preimage unambiguous: a NUL or control byte in +// the audience could shift the boundary between audience and payload. +func checkAudience(a string) error { + if len(a) > 200 { + return fmt.Errorf("audience exceeds 200 bytes") + } + for _, c := range a { + if c < 0x20 || c == 0x7f { + return fmt.Errorf("audience contains a control character") + } + } + return nil +} + +// decodeB64 accepts exactly one byte-form per value: strict standard +// base64, no whitespace, and a re-encode that reproduces the input — so a +// padding-bit or inserted-newline variant of the same raw bytes cannot +// yield a second verifying artifact. +func decodeB64(s string, wantLen int, what string) ([]byte, error) { + if strings.ContainsAny(s, " \t\r\n") { + return nil, fmt.Errorf("%s base64 contains whitespace", what) + } + raw, err := base64.StdEncoding.Strict().DecodeString(s) + if err != nil { + return nil, fmt.Errorf("%s base64: %v", what, err) + } + if len(raw) != wantLen { + return nil, fmt.Errorf("%s is %d bytes, want %d", what, len(raw), wantLen) + } + if base64.StdEncoding.EncodeToString(raw) != s { + return nil, fmt.Errorf("%s base64 is not canonical", what) + } + return raw, nil +} + +func shortKey(b64 string) string { + if len(b64) > 12 { + return b64[:12] + "…" + } + return b64 +} + +func short(s string) string { + if s == "" { + return "(empty)" + } + if len(s) > 12 { + return s[:12] + } + return s +} diff --git a/internal/signing/signing_test.go b/internal/signing/signing_test.go new file mode 100644 index 0000000..9d1326f --- /dev/null +++ b/internal/signing/signing_test.go @@ -0,0 +1,318 @@ +package signing + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "strings" + "testing" + + "github.com/joshft/correctful/internal/gitdiff" + "github.com/joshft/correctful/internal/receipt" + "github.com/joshft/correctful/schema" +) + +// fixtureReceipt assembles a small internally consistent receipt: one +// verified claim, one refuted claim. ToolVersion is pinned so the fixture +// is byte-deterministic across build environments (the golden tests +// depend on that). +func fixtureReceipt(t *testing.T) schema.Receipt { + t.Helper() + change := gitdiff.Change{ + Repo: "repo", + BaseRef: "main", + HeadRef: "feat", + BaseSHA: strings.Repeat("b", 40), + HeadSHA: strings.Repeat("h", 40), + Files: []string{"a.go", "a_test.go"}, + InputDigest: strings.Repeat("d", 64), + } + claims := []schema.Claim{ + {ID: "TestA", Text: "TestA passes", ProbeIDs: []string{"go-test/TestA"}}, + {ID: "TestB", Text: "TestB passes", ProbeIDs: []string{"go-test/TestB"}}, + } + evidence := [][]schema.Evidence{ + {{ClaimID: "TestA", ProbeID: "go-test/TestA", Tier: schema.T1Assertion, Ran: true, Passed: true}}, + {{ClaimID: "TestB", ProbeID: "go-test/TestB", Tier: schema.T1Assertion, Ran: true, Passed: false}}, + } + cov := schema.Coverage{ + Files: []schema.FileCoverage{ + {File: "a.go", ReadBy: []string{"gotest"}, Claims: 2}, + {File: "a_test.go", ReadBy: []string{"gotest"}}, + }, + Claimed: 1, + Scanned: 1, + } + r := receipt.Assemble(change, claims, evidence, cov) + r.ToolVersion = "test" + return r +} + +func testKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x42}, ed25519.SeedSize)) + return priv.Public().(ed25519.PublicKey), priv +} + +func signedArtifact(t *testing.T, audience string) ([]byte, ed25519.PublicKey) { + t.Helper() + pub, priv := testKey(t) + signed, err := Sign(fixtureReceipt(t), priv, audience) + if err != nil { + t.Fatalf("Sign: %v", err) + } + artifact, err := receipt.Canonical(signed) + if err != nil { + t.Fatal(err) + } + return artifact, pub +} + +func expectFor(r schema.Receipt, audience string) Expect { + return Expect{Audience: audience, HeadSHA: r.Change.HeadSHA} +} + +// TestSignVerifyRoundTrip: the whole path — sign a consistent receipt, +// verify the canonical artifact against the trusted key and the expected +// subject. Ed25519 is deterministic, so signing twice must agree — a +// divergence would mean nondeterministic canonicalization. +func TestSignVerifyRoundTrip(t *testing.T) { + artifact, pub := signedArtifact(t, "github.com/x/y") + r := fixtureReceipt(t) + got, err := Verify(artifact, pub, expectFor(r, "github.com/x/y")) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if got.Signature == nil || got.Signature.Alg != "ed25519" { + t.Fatalf("verified receipt lost its signature block: %+v", got.Signature) + } + if !got.GateBlocked() { + t.Errorf("fixture has a refuted claim; gate must report blocked") + } + + again, _ := signedArtifact(t, "github.com/x/y") + if !bytes.Equal(artifact, again) { + t.Errorf("signing the same receipt twice produced different artifacts") + } +} + +// TestVerifyWrongKeyFails: a valid signature by SOME key is worthless — +// only the caller's pinned key authenticates. +func TestVerifyWrongKeyFails(t *testing.T) { + artifact, _ := signedArtifact(t, "") + otherPub, _, _ := ed25519.GenerateKey(nil) + _, err := Verify(artifact, otherPub, expectFor(fixtureReceipt(t), "")) + if err == nil || !strings.Contains(err.Error(), "not the trusted key") { + t.Fatalf("wrong key accepted or wrong error: %v", err) + } +} + +// TestVerifyAudienceBinding: the audience is part of the signed preimage +// AND compared explicitly, so a receipt signed for one repository fails +// verification configured for another — with a named reason, not a bare +// "signature invalid". +func TestVerifyAudienceBinding(t *testing.T) { + artifact, pub := signedArtifact(t, "github.com/x/y") + _, err := Verify(artifact, pub, expectFor(fixtureReceipt(t), "github.com/x/OTHER")) + if err == nil || !strings.Contains(err.Error(), "audience mismatch") { + t.Fatalf("audience mismatch accepted or wrong error: %v", err) + } + + // A forged block audience cannot help: rewriting it breaks the + // signature because the audience is inside the preimage. + var r schema.Receipt + if err := json.Unmarshal(artifact, &r); err != nil { + t.Fatal(err) + } + r.Signature.Audience = "github.com/x/OTHER" + forged, err := receipt.Canonical(r) + if err != nil { + t.Fatal(err) + } + _, err = Verify(forged, pub, expectFor(r, "github.com/x/OTHER")) + if err == nil || !strings.Contains(err.Error(), "signature is invalid") { + t.Fatalf("audience rewrite survived: %v", err) + } +} + +// TestVerifySubjectMatching: a signature authenticates SOME receipt; the +// subject match ties it to THE change. Head is mandatory unless the caller +// explicitly opts out. +func TestVerifySubjectMatching(t *testing.T) { + artifact, pub := signedArtifact(t, "") + + if _, err := Verify(artifact, pub, Expect{HeadSHA: strings.Repeat("e", 40)}); err == nil || !strings.Contains(err.Error(), "subject mismatch") { + t.Fatalf("wrong head accepted: %v", err) + } + if _, err := Verify(artifact, pub, Expect{}); err == nil || !strings.Contains(err.Error(), "-any-subject") { + t.Fatalf("missing head must demand an explicit choice: %v", err) + } + if _, err := Verify(artifact, pub, Expect{AnySubject: true}); err != nil { + t.Fatalf("explicit any-subject failed: %v", err) + } + r := fixtureReceipt(t) + if _, err := Verify(artifact, pub, Expect{HeadSHA: r.Change.HeadSHA, InputDigest: strings.Repeat("f", 64)}); err == nil || !strings.Contains(err.Error(), "input digest") { + t.Fatalf("wrong input digest accepted: %v", err) + } +} + +// TestVerifyRejectsNonCanonicalArtifact: exactly one byte-form of a +// receipt verifies. A compact re-encoding of the SAME content carries the +// same valid signature bytes — and must still fail, or two parsers could +// read one signed receipt two ways. +func TestVerifyRejectsNonCanonicalArtifact(t *testing.T) { + artifact, pub := signedArtifact(t, "") + exp := expectFor(fixtureReceipt(t), "") + + if _, err := Verify(append(artifact, '\n'), pub, exp); err == nil { + t.Fatalf("trailing newline accepted") + } + + var v any + if err := json.Unmarshal(artifact, &v); err != nil { + t.Fatal(err) + } + compact, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + if _, err := Verify(compact, pub, exp); err == nil || !strings.Contains(err.Error(), "canonical") { + t.Fatalf("compact re-encoding accepted: %v", err) + } +} + +// TestVerifyRejectsEveryByteMutation: flip one bit in every byte of the +// artifact; every mutation must fail SOMEWHERE — parse, canonical +// identity, or signature. No mutation may verify. +func TestVerifyRejectsEveryByteMutation(t *testing.T) { + artifact, pub := signedArtifact(t, "github.com/x/y") + exp := expectFor(fixtureReceipt(t), "github.com/x/y") + for i := range artifact { + mut := append([]byte(nil), artifact...) + mut[i] ^= 0x01 + if _, err := Verify(mut, pub, exp); err == nil { + t.Fatalf("bit flip at byte %d (%q) verified", i, artifact[i]) + } + } +} + +// TestSignRefusesInconsistentReceipt and TestVerifyRejectsSignedInconsistency: +// the codex-review attack — a wrapper zeroes Summary.Refuted around one +// refuted result, so GateBlocked (which reads the summary) passes. Our +// signer refuses to mint it, and even a signature minted by a BYPASSING +// signer fails verification, because consistency is validated on both +// sides of the trust boundary. +func TestSignRefusesInconsistentReceipt(t *testing.T) { + _, priv := testKey(t) + r := fixtureReceipt(t) + r.Summary.Refuted = 0 + r.Summary.Verified = 2 + if _, err := Sign(r, priv, ""); err == nil || !strings.Contains(err.Error(), "inconsistent") { + t.Fatalf("inconsistent receipt signed: %v", err) + } +} + +func TestVerifyRejectsSignedInconsistency(t *testing.T) { + pub, priv := testKey(t) + r := fixtureReceipt(t) + r.Summary.Refuted = 0 + r.Summary.Verified = 2 + + // A bypassing signer: raw ed25519 over the canonical payload, no + // consistency check. + payload, err := receipt.Canonical(r) + if err != nil { + t.Fatal(err) + } + sig := ed25519.Sign(priv, preimage("", payload)) + r.Signature = &schema.SignatureBlock{ + Alg: "ed25519", + PublicKey: b64(pub), + Sig: b64(sig), + } + artifact, err := receipt.Canonical(r) + if err != nil { + t.Fatal(err) + } + _, err = Verify(artifact, pub, expectFor(r, "")) + if err == nil || !strings.Contains(err.Error(), "inconsistent") { + t.Fatalf("signed-but-inconsistent receipt verified: %v", err) + } +} + +// TestSignRefusesAlreadySigned: re-signing is an explicit decision made on +// the unsigned artifact, never a silent overwrite. +func TestSignRefusesAlreadySigned(t *testing.T) { + _, priv := testKey(t) + signed, err := Sign(fixtureReceipt(t), priv, "") + if err != nil { + t.Fatal(err) + } + if _, err := Sign(signed, priv, ""); err == nil || !strings.Contains(err.Error(), "already signed") { + t.Fatalf("double-sign allowed: %v", err) + } +} + +// TestSignRejectsControlAudience: a NUL or control byte in the audience +// could shift the preimage boundary between audience and payload. +func TestSignRejectsControlAudience(t *testing.T) { + _, priv := testKey(t) + for _, aud := range []string{"a\x00b", "a\nb", strings.Repeat("x", 201)} { + if _, err := Sign(fixtureReceipt(t), priv, aud); err == nil { + t.Fatalf("audience %q accepted", aud) + } + } +} + +// TestVerifyRejectsMalleableBase64: the signature bytes admit exactly one +// base64 spelling. A whitespace-bearing or padding-bit variant decodes to +// the same raw bytes under a lenient decoder — a second verifying +// byte-form of the same receipt, which is exactly the differential the +// canonical contract exists to kill. (The canonical-identity check already +// rejects these; this pins the independent second leg in decodeB64.) +func TestVerifyRejectsMalleableBase64(t *testing.T) { + if _, err := decodeB64("QUJ\nDRA==", 4, "test"); err == nil { + t.Fatalf("whitespace base64 accepted") + } + // "QUJDRB==" decodes to the same 4 bytes as "QUJDRA==" under a lenient + // decoder (nonzero trailing bits); strict must refuse. + if _, err := decodeB64("QUJDRB==", 4, "test"); err == nil { + t.Fatalf("noncanonical trailing bits accepted") + } +} + +// TestGoldenSignature freezes the whole pipeline — canonicalization, the +// domain separator, the preimage layout, and raw (non-prehashed) Ed25519 — +// as one pinned signature over the deterministic fixture. If this test +// breaks and you did not deliberately change the schema or the preimage, +// you have changed what old receipts verify as — stop. +func TestGoldenSignature(t *testing.T) { + _, priv := testKey(t) + signed, err := Sign(fixtureReceipt(t), priv, "github.com/x/y") + if err != nil { + t.Fatal(err) + } + const want = "0+lGRwafD0o8wR+4YUo4BU1Dg1McLAeyTum2h62ie1dKBYorzcNj244JvyGv/3zJzG+AnuBTjrQlxgTzW9hiAQ==" + if got := signed.Signature.Sig; got != want { + t.Fatalf("golden signature drifted:\n got %s\nwant %s", got, want) + } +} + +// TestRFC8032Vector pins the stdlib against RFC 8032 §7.1 TEST 3 — a +// sanity check that ed25519.Sign is the pure (non-prehashed) variant our +// preimage design assumes. +func TestRFC8032Vector(t *testing.T) { + seed, _ := hex.DecodeString("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7") + msg, _ := hex.DecodeString("af82") + wantSig, _ := hex.DecodeString("6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a") + priv := ed25519.NewKeyFromSeed(seed) + if got := ed25519.Sign(priv, msg); !bytes.Equal(got, wantSig) { + t.Fatalf("stdlib ed25519 does not match RFC 8032 TEST 3") + } +} + +func b64(raw []byte) string { + return base64.StdEncoding.EncodeToString(raw) +} diff --git a/internal/strictjson/strictjson.go b/internal/strictjson/strictjson.go new file mode 100644 index 0000000..75efd4e --- /dev/null +++ b/internal/strictjson/strictjson.go @@ -0,0 +1,100 @@ +// Package strictjson decodes JSON under the strictest contract the stdlib +// can be driven to: unknown fields rejected, duplicate keys rejected at any +// depth, trailing content rejected down to the last byte, and invalid UTF-8 +// rejected instead of silently normalized. +// +// Two callers depend on this being airtight. The intake boundary parses +// supplier documents whose duplicate keys were demonstrated to smuggle a +// second "outcome": "verified" behind a "counterexample". The signature +// verifier parses receipts where ANY parser leniency is a differential: a +// document this package accepts but re-serializes differently than it +// arrived is a document two consumers can read two ways. +package strictjson + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "unicode/utf8" +) + +// Decode parses data into v under the strict contract. The stdlib decoder +// alone keeps a duplicate key's last value, matches struct fields +// case-insensitively (accepted: canonical producers emit exact names, and +// the signature layer separately requires byte-identical re-encoding), and +// — the trailing-content gap — Decoder.More reports false at a stray +// closing delimiter, so "{...}]" passes a More-based check. Decode demands +// io.EOF from the token stream instead. +func Decode(data []byte, v any) error { + if !utf8.Valid(data) { + return errors.New("document is not valid UTF-8") + } + if err := rejectDupKeys(json.NewDecoder(bytes.NewReader(data))); err != nil { + return err + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return err + } + if _, err := dec.Token(); err != io.EOF { + return errors.New("trailing content after the JSON document") + } + return nil +} + +// rejectDupKeys walks the token stream and fails on a repeated object key +// at any depth. +func rejectDupKeys(dec *json.Decoder) error { + t, err := dec.Token() + if err != nil { + return err + } + return rejectDupKeysIn(dec, t) +} + +func rejectDupKeysIn(dec *json.Decoder, t json.Token) error { + d, ok := t.(json.Delim) + if !ok { + return nil + } + switch d { + case '{': + seen := map[string]bool{} + for dec.More() { + kt, err := dec.Token() + if err != nil { + return err + } + k, _ := kt.(string) + if seen[k] { + return fmt.Errorf("duplicate key %q", k) + } + seen[k] = true + vt, err := dec.Token() + if err != nil { + return err + } + if err := rejectDupKeysIn(dec, vt); err != nil { + return err + } + } + _, err := dec.Token() // consume '}' + return err + case '[': + for dec.More() { + vt, err := dec.Token() + if err != nil { + return err + } + if err := rejectDupKeysIn(dec, vt); err != nil { + return err + } + } + _, err := dec.Token() // consume ']' + return err + } + return nil +} diff --git a/internal/strictjson/strictjson_test.go b/internal/strictjson/strictjson_test.go new file mode 100644 index 0000000..913592a --- /dev/null +++ b/internal/strictjson/strictjson_test.go @@ -0,0 +1,51 @@ +package strictjson + +import ( + "strings" + "testing" +) + +type doc struct { + A string `json:"a"` + B []int `json:"b,omitempty"` +} + +// TestCleanDocumentDecodes: the strict contract still admits an ordinary +// document. +func TestCleanDocumentDecodes(t *testing.T) { + var d doc + if err := Decode([]byte(`{"a":"x","b":[1,2]}`), &d); err != nil { + t.Fatalf("clean document rejected: %v", err) + } + if d.A != "x" || len(d.B) != 2 { + t.Fatalf("decoded wrong values: %+v", d) + } +} + +// TestStrictRejections: every leniency the stdlib decoder would allow is +// refused. The stray-closing-delimiter case is the regression pin for the +// Decoder.More gap: More() reports false at "]", so "{...}]" passed the +// old intake check. +func TestStrictRejections(t *testing.T) { + cases := []struct { + name, in, want string + }{ + {"duplicate key", `{"a":"x","a":"y"}`, "duplicate key"}, + {"duplicate key nested", `{"a":"x","b":[{"c":1,"c":2}]}`, "duplicate key"}, + {"unknown field", `{"a":"x","zz":1}`, "unknown field"}, + {"trailing value", `{"a":"x"} true`, "trailing content"}, + {"stray closing delimiter", `{"a":"x"}]`, "trailing content"}, + {"stray closing brace", `{"a":"x"}}`, "trailing content"}, + {"invalid utf-8", "{\"a\":\"\xff\"}", "not valid UTF-8"}, + } + for _, c := range cases { + var d doc + err := Decode([]byte(c.in), &d) + if err == nil { + t.Fatalf("%s: accepted %q", c.name, c.in) + } + if !strings.Contains(err.Error(), c.want) { + t.Fatalf("%s: error %q does not name %q", c.name, err, c.want) + } + } +} diff --git a/schema/schema.go b/schema/schema.go index 57954a3..fadeeb7 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -421,6 +421,11 @@ type Receipt struct { Remainder []ClaimResult `json:"remainder"` Coverage Coverage `json:"coverage"` Summary Summary `json:"summary"` + // Signature authenticates the receipt when present — set only by the + // sign subcommand, which runs no probes and reads no tree, so the + // reviewed change can never reach the signing key. Nil means unsigned: + // the receipt is a report whose authenticity rests on transport alone. + Signature *SignatureBlock `json:"signature,omitempty"` } // GateBlocked reports whether the merge gate blocks on this receipt: a @@ -524,5 +529,32 @@ type IntakeRejection struct { Reason string `json:"reason"` } +// SignatureBlock authenticates a receipt: an Ed25519 signature by the +// runner's key over the domain-separated canonical payload (the receipt's +// canonical JSON with this block absent — see internal/signing for the +// exact preimage). One algorithm on purpose: agility is a vulnerability +// class this schema opts out of. +// +// What the signature proves, exactly: the holder of this private key +// produced this canonical content. It does NOT prove the runner ran +// honestly, was not compromised, or that the key was not stolen — trust in +// a receipt is trust in the key holder, normally a CI system whose signing +// step runs no reviewed code. PublicKey here is an identity CLAIM the +// verifier must match against a trusted key it already holds; a verifier +// that trusts the embedded key alone has verified nothing. +type SignatureBlock struct { + // Alg is always "ed25519"; a verifier rejects anything else. + Alg string `json:"alg"` + // PublicKey is the standard-base64 32-byte Ed25519 public key. + PublicKey string `json:"public_key"` + // Audience is the stable repository identity the signature binds (part + // of the signed preimage), so a receipt signed for one repository + // cannot pass verification configured for another even under a shared + // CI key. Empty means the signer bound no audience — weaker, stated. + Audience string `json:"audience,omitempty"` + // Sig is the standard-base64 64-byte Ed25519 signature. + Sig string `json:"sig"` +} + // SchemaVersion is the current version of the receipt schema (the payload). -const SchemaVersion = "0.0.13" +const SchemaVersion = "0.0.14"