Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 87 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<audience>\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
Expand Down Expand Up @@ -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)
```
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions cmd/correctful/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>
// correctful sign -receipt <json> -key <pem> [-audience a] [-out f]
// correctful verify -receipt <json> -pub <pem> -head <sha> [-audience a] [-gate]
// correctful render -receipt <json> [-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 (
Expand All @@ -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")
Expand Down
187 changes: 187 additions & 0 deletions cmd/correctful/subcommands.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading