diff --git a/README.md b/README.md index e2c9716..3357f6a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,29 @@ Draftcat runs YAML-defined pipelines that triage email, qualify leads, draft replies, extract data from PDFs, and govern self-hosted voice AI. Every outbound action passes an operator approval gate, every LLM call is budget-checked, and every fetched item is deduped against a SQLite state store. One business per instance, self-hosted, auditable. +## Prove approval without sharing the customer data + +Sometimes a customer, auditor, or partner needs evidence that a human approved an AI action — but should **not** receive the message, the reviewer's identity, or your internal workflow. Draftcat can turn a signed approval row into a zero-knowledge proof: + +| The verifier learns | What stays private | +| --- | --- | +| A direct human approval was recorded | Customer message and payload hash | +| The required reviewer quorum was met | Reviewer identity and exact vote counts | +| The proof came from the Draftcat instance key they pinned | Pipeline, step, time, nonce, and instance secret | + +```bash +# Operator: publish this commitment once through a trusted channel. +./draftcat zk-receipt key-id + +# Operator: create a shareable proof for the latest human approval. +./draftcat zk-receipt prove --out approval.proof.json invoice-due-diligence + +# Customer or auditor: verify it without DRAFTCAT_APPROVAL_SECRET or database access. +./draftcat zk-receipt verify --expect-key approval.proof.json +``` + +This is an **experimental cryptographic preview**, not a production compliance claim. It uses an embedded BN254/Groth16 circuit and a development single-party setup; the circuit has not received an independent audit. Use it to evaluate the disclosure model, then replace the setup through a ceremony before relying on it in production. See [zero-knowledge approval proofs](docs/zk-approval-proofs.md) for the trust model, exact statement, and limitations. + > **New in v0.6.0:** the gate holds under load. The [tool-call gate](docs/tool-gate.md) answers asynchronously (`mode: async`, `wait:`) so a harness with a short HTTP timeout never loses a decision, and a tool call waiting on a human is durable across a restart. Rules constrain arguments (`args:` — glob, regex, `one_of`, `min`/`max`) and never widen on a mismatch. A repeat guard stops an agent that loops on one call from paging you, the operator hears about denials the gate made on its own, `/pending` and `draftcat pending` list every open gate, `/status` shows spend against caps, cost caps enforce the provider's real charge, rate limits back off instead of failing the run — and one Telegram update pump fixes taps that were silently lost while two gates were open at once. > > **In v0.5.0:** approvals reach any operator surface via the [`hitl/v0` protocol](docs/hitl-protocol.md) — Microsoft Teams through a Power Automate flow in your own tenant, with no bot and no Azure app registration. Plus a tool-call gate for an agent's MCP/SDK calls (`POST /gate/tool-call`), risk tiers with pre-declared `approval_policy` exemptions, run-correlated audit rows, spend shown at the moment of decision, and `escalate_after` reminders before a gate times out. @@ -68,6 +91,7 @@ However your agent runs, draftcat sits between it and your customer systems as a - **Input sanitization** — operator input is scrubbed for prompt-injection patterns before the LLM. - **Output validation** — AI output is checked against the skill's `output_schema` (field types, numeric `min`/`max`, `enum` membership) and rejected if it doesn't conform. - **Checked action receipts** — approval decisions can be tied to a payload hash and verified later; see [`docs/action-receipts.md`](docs/action-receipts.md). +- **Private approval proofs** — share proof that a direct human approval met quorum without sharing the action, approver, or counts; see [`docs/zk-approval-proofs.md`](docs/zk-approval-proofs.md). - **Rate limiting** — per-user, per-minute caps on operator interactions. - **Channel security** — allowed-user lists + input-length limits enforced at startup; the engine refuses to start without them. - **Config validated on boot** — the engine runs the same checks as `draftcat validate` at startup and refuses to start on errors, so problems surface at boot rather than mid-run. `DRAFTCAT_SKIP_VALIDATE=1` overrides. diff --git a/cmd/zkreceipt-setup/main.go b/cmd/zkreceipt-setup/main.go new file mode 100644 index 0000000..2a03c75 --- /dev/null +++ b/cmd/zkreceipt-setup/main.go @@ -0,0 +1,21 @@ +// Command zkreceipt-setup regenerates the embedded Groth16 artifacts. +package main + +import ( + "fmt" + "os" + + "github.com/renezander030/draftcat/internal/zkreceipt" +) + +func main() { + directory := "internal/zkreceipt/artifacts" + if len(os.Args) == 2 { + directory = os.Args[1] + } + if err := zkreceipt.GenerateArtifacts(directory); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("generated Draftcat ZK approval artifacts in", directory) +} diff --git a/docs/zk-approval-proofs.md b/docs/zk-approval-proofs.md new file mode 100644 index 0000000..b2dbdda --- /dev/null +++ b/docs/zk-approval-proofs.md @@ -0,0 +1,101 @@ +# Zero-knowledge approval proofs + +Draftcat can prove a useful fact about an approval without exporting the approval row itself: + +> A hidden Draftcat approval record says `approve`, its required quorum is at least one, its observed approvals meet that quorum, and the record is bound to this pinned Draftcat instance key. + +The proof is a selective-disclosure receipt. It is useful when a customer or auditor needs evidence that a human gate ran but should not receive customer content, reviewer identities, or internal workflow details. + +## Use it when / skip it when + +Use it when: + +- the verifier already trusts a Draftcat instance key but should not see the underlying approval row; +- sharing the normal audit receipt would reveal customer or operator data; +- you want to evaluate a privacy-preserving audit handoff before designing a production ceremony. + +Skip it when: + +- the verifier needs the actual message, reviewer, timestamp, or vote count; +- the verifier does not have a trusted way to pin the instance-key commitment; +- a normal signed receipt already reveals nothing sensitive; +- you need an audited or production-ready compliance control today. + +## Quick walkthrough + +The approval row must already be signed with `DRAFTCAT_APPROVAL_SECRET`. The proof command first verifies that HMAC receipt and refuses an unsigned or altered row. Because the proof publishes a deterministic commitment that could be used to test guesses, this command requires at least 32 bytes and you should use a randomly generated secret, not a password. + +1. The operator publishes the instance-key commitment through a trusted channel: + + ```bash + # Existing instance: load the same secret that signed its approval rows. + # New instance only: generate this once and keep it in your secret manager. + export DRAFTCAT_APPROVAL_SECRET="$(openssl rand -hex 32)" + ./draftcat zk-receipt key-id + ``` + + Save the printed 64-character commitment somewhere the verifier trusts. Do not copy it from the proof bundle you are about to verify; that would prove only that an unknown key made the proof. + +2. The operator proves the latest direct human approval for a pipeline: + + ```bash + ./draftcat zk-receipt prove \ + --out approval.proof.json \ + invoice-due-diligence + ``` + + `DRAFTCAT_STATE_PATH` takes precedence over `state.path` in `config.yaml`, matching the engine. Use `--config another.yaml` if needed. The output file is mode `0600` and contains no approval fields. + +3. The verifier checks the bundle with the pinned commitment and a Draftcat binary built from the same circuit version: + + ```bash + ./draftcat zk-receipt verify \ + --expect-key \ + approval.proof.json + ``` + + A valid result means the circuit statement is true for some hidden witness bound to that key and receipt commitment. Verification needs neither the database nor `DRAFTCAT_APPROVAL_SECRET`. + +## Public and private data + +| Public in the proof bundle | Private witness | +| --- | --- | +| Schema, curve, proof type, and circuit ID | Pipeline and step | +| Pinned instance-key commitment | Decision time | +| Commitment to the complete receipt | Decision (`approve` is enforced in-circuit) | +| Groth16 proof and performance metadata | Operator ID and payload hash | +| | Required and observed quorum | +| | Nonce and instance secret | + +The circuit uses domain-separated MiMC commitments over BN254 field elements. Strings and the instance secret are first reduced to field elements with domain-separated SHA-256. The receipt commitment covers every private field listed above. + +## What the implementation refuses + +- `policy_approve`: an automated policy decision is not presented as a human approval. +- `quorum_n < 1` or `quorum_got < quorum_n`: an absent or incomplete quorum cannot produce a proof. +- unsigned or HMAC-invalid rows: the CLI checks the existing action receipt before creating the witness. +- a proof from a different instance key: verification requires `--expect-key`. +- a different circuit build: the bundle's circuit ID must match the embedded verifying key. + +Tests also change a public receipt commitment and confirm that verification fails. + +## Trust model and limitations + +This proof establishes a statement about data attested by the holder of the Draftcat instance secret. It does **not** independently establish that a real person clicked a button, that the person was authorized outside Draftcat, or that the approved action later executed. A compromised or dishonest key holder can attest false input, just as they can create an ordinary signed receipt. Protect the secret, pin the key commitment out of band, and rotate both after compromise. + +The checked-in proving and verifying keys were produced with gnark's one-time `groth16.Setup` for this fixed circuit. This makes the preview work out of the box, but it is a single-party development setup. Before production, run a multiparty ceremony (or adopt a suitable transparent proof system), publish the resulting circuit and verifying-key hashes, and obtain an independent circuit and integration audit. gnark also notes that its implementations are provided without side-channel guarantees. + +The proof intentionally discloses only the predicate. If a verifier needs to match the proof to a known action, extend the circuit with an agreed public action commitment rather than revealing the entire private row. + +## Rebuild the development artifacts + +Circuit changes require new artifacts and therefore a new circuit ID: + +```bash +go run ./cmd/zkreceipt-setup +go test ./... +``` + +Commit all three files in `internal/zkreceipt/artifacts/` together. Never mix a constraint system, proving key, and verifying key from different setup runs. + +The implementation uses [gnark](https://github.com/Consensys-Incorporated/gnark). Its documentation describes the one-time setup, proof, and verification flow in [Create and verify proofs](https://docs.gnark.consensys.io/HowTo/prove). diff --git a/go.mod b/go.mod index cdd2eed..8f6ce47 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,34 @@ module github.com/renezander030/draftcat -go 1.25.0 +go 1.25.7 require ( + github.com/consensys/gnark v0.16.3 + github.com/consensys/gnark-crypto v0.21.0 github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.50.1 ) require ( + github.com/bits-and-blooms/bitset v1.24.6 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.42.0 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect + github.com/ronanh/intcomp v1.1.1 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 608111d..8114b73 100644 --- a/go.sum +++ b/go.sum @@ -1,30 +1,69 @@ +github.com/bits-and-blooms/bitset v1.24.6 h1:qcrftZUVBIwfs+m+nhoCBAPT+ZPZZjti8SbHbDQQkZ4= +github.com/bits-and-blooms/bitset v1.24.6/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/consensys/gnark v0.16.3 h1:S7BtIQSX2WLHV2857HrLmrQ5xIl0ZRL8kT6rcLn8gow= +github.com/consensys/gnark v0.16.3/go.mod h1:ChMGCGi8KztMtuQXgxprorLVJY29FPnKkjN19RXB/KU= +github.com/consensys/gnark-crypto v0.21.0 h1:FDHibVIk4T5LkOKAkiN38g8gEvOxNcM10mLHOqvFTD0= +github.com/consensys/gnark-crypto v0.21.0/go.mod h1:hdTjDNjdkYJ1oVuc8emh9XEhfM1SbyZhJigFqItiOLk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8= github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/ronanh/intcomp v1.1.1 h1:+1bGV/wEBiHI0FvzS7RHgzqOpfbBJzLIxkqMJ9e6yxY= +github.com/ronanh/intcomp v1.1.1/go.mod h1:7FOLy3P3Zj3er/kVrU/pl+Ql7JFZj7bwliMGketo0IU= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/internal/zkreceipt/artifacts/approval.pk b/internal/zkreceipt/artifacts/approval.pk new file mode 100644 index 0000000..63619c2 Binary files /dev/null and b/internal/zkreceipt/artifacts/approval.pk differ diff --git a/internal/zkreceipt/artifacts/approval.r1cs b/internal/zkreceipt/artifacts/approval.r1cs new file mode 100644 index 0000000..9b35d51 Binary files /dev/null and b/internal/zkreceipt/artifacts/approval.r1cs differ diff --git a/internal/zkreceipt/artifacts/approval.vk b/internal/zkreceipt/artifacts/approval.vk new file mode 100644 index 0000000..c243127 Binary files /dev/null and b/internal/zkreceipt/artifacts/approval.vk differ diff --git a/internal/zkreceipt/circuit.go b/internal/zkreceipt/circuit.go new file mode 100644 index 0000000..e46df1d --- /dev/null +++ b/internal/zkreceipt/circuit.go @@ -0,0 +1,198 @@ +// Package zkreceipt creates selective-disclosure proofs for Draftcat approval +// receipts. The public sees only an instance-key commitment and a commitment to +// the approved action; the approval row itself remains private. +package zkreceipt + +import ( + "crypto/sha256" + "fmt" + "math/big" + "strconv" + + "github.com/consensys/gnark-crypto/ecc/bn254/fr" + nativemimc "github.com/consensys/gnark-crypto/ecc/bn254/fr/mimc" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/std/hash/mimc" +) + +const ( + Schema = "draftcat.zk-approval.v1" + Curve = "bn254" + ProofType = "groth16" +) + +// Record is the private approval witness. Only the two commitments derived +// from it are included in the proof's public inputs. +type Record struct { + Pipeline string + Step string + DecidedAt int64 + Decision string + OperatorID int64 + PayloadHash string + QuorumN int + QuorumGot int + Nonce string +} + +// Circuit proves that a committed approval was a human approval and that its +// declared quorum was met. All fields except the two commitments are private. +type Circuit struct { + KeyCommitment frontend.Variable `gnark:",public"` + ReceiptCommitment frontend.Variable `gnark:",public"` + + Secret frontend.Variable + Pipeline frontend.Variable + Step frontend.Variable + DecidedAt frontend.Variable + Decision frontend.Variable + OperatorID frontend.Variable + PayloadHash frontend.Variable + QuorumN frontend.Variable + QuorumGot frontend.Variable + Nonce frontend.Variable +} + +func (c *Circuit) Define(api frontend.API) error { + // Bound the integers before comparing them in the scalar field. + api.ToBinary(c.DecidedAt, 64) + api.ToBinary(c.OperatorID, 64) + api.ToBinary(c.QuorumN, 16) + api.ToBinary(c.QuorumGot, 16) + + // Code 1 means a direct human "approve". Automated policy approvals use a + // different decision in Draftcat and cannot satisfy this circuit. + api.AssertIsEqual(c.Decision, 1) + api.AssertIsLessOrEqual(1, c.QuorumN) + api.AssertIsLessOrEqual(c.QuorumN, c.QuorumGot) + + keyHash, err := mimc.NewMiMC(api) + if err != nil { + return err + } + keyHash.Write(domainField("draftcat-instance-key-v1"), c.Secret) + api.AssertIsEqual(c.KeyCommitment, keyHash.Sum()) + + receiptHash, err := mimc.NewMiMC(api) + if err != nil { + return err + } + receiptHash.Write( + domainField("draftcat-zk-approval-v1"), + c.Secret, + c.Pipeline, + c.Step, + c.DecidedAt, + c.Decision, + c.OperatorID, + c.PayloadHash, + c.QuorumN, + c.QuorumGot, + c.Nonce, + ) + api.AssertIsEqual(c.ReceiptCommitment, receiptHash.Sum()) + return nil +} + +func assignment(record Record, secret []byte) (*Circuit, *big.Int, *big.Int, error) { + if len(secret) < 32 { + return nil, nil, nil, fmt.Errorf("approval secret must be at least 32 bytes") + } + if record.Decision != "approve" { + return nil, nil, nil, fmt.Errorf("decision must be a direct human approve, got %q", record.Decision) + } + if record.DecidedAt < 0 || record.OperatorID < 0 { + return nil, nil, nil, fmt.Errorf("negative timestamp or operator ID is unsupported") + } + if record.QuorumN < 1 || record.QuorumN > 65535 || record.QuorumGot < record.QuorumN || record.QuorumGot > 65535 { + return nil, nil, nil, fmt.Errorf("approval quorum is not satisfied or exceeds 16-bit bounds") + } + + secretField := bytesField("secret", secret) + values := []*big.Int{ + secretField, + textField("pipeline", record.Pipeline), + textField("step", record.Step), + big.NewInt(record.DecidedAt), + big.NewInt(1), + big.NewInt(record.OperatorID), + textField("payload-hash", record.PayloadHash), + big.NewInt(int64(record.QuorumN)), + big.NewInt(int64(record.QuorumGot)), + textField("nonce", record.Nonce), + } + keyCommitment := nativeHash(domainField("draftcat-instance-key-v1"), secretField) + receiptCommitment := nativeHash(append([]*big.Int{domainField("draftcat-zk-approval-v1")}, values...)...) + + return &Circuit{ + KeyCommitment: keyCommitment, + ReceiptCommitment: receiptCommitment, + Secret: values[0], + Pipeline: values[1], + Step: values[2], + DecidedAt: values[3], + Decision: values[4], + OperatorID: values[5], + PayloadHash: values[6], + QuorumN: values[7], + QuorumGot: values[8], + Nonce: values[9], + }, keyCommitment, receiptCommitment, nil +} + +func publicAssignment(keyCommitment, receiptCommitment *big.Int) *Circuit { + return &Circuit{KeyCommitment: keyCommitment, ReceiptCommitment: receiptCommitment} +} + +func domainField(value string) *big.Int { return textField("domain", value) } + +func textField(label, value string) *big.Int { + return bytesField(label, []byte(value)) +} + +func bytesField(label string, value []byte) *big.Int { + h := sha256.New() + _, _ = h.Write([]byte(label)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write(value) + var element fr.Element + element.SetBytes(h.Sum(nil)) + return element.BigInt(new(big.Int)) +} + +func nativeHash(values ...*big.Int) *big.Int { + h := nativemimc.NewMiMC() + for _, value := range values { + var element fr.Element + element.SetBigInt(value) + encoded := element.Bytes() + _, _ = h.Write(encoded[:]) + } + var result fr.Element + result.SetBytes(h.Sum(nil)) + return result.BigInt(new(big.Int)) +} + +func encodeField(value *big.Int) string { + var element fr.Element + element.SetBigInt(value) + encoded := element.Bytes() + return fmt.Sprintf("%x", encoded[:]) +} + +func decodeField(value string) (*big.Int, error) { + if len(value) != 64 { + return nil, fmt.Errorf("field commitment must be 64 hex characters") + } + n, ok := new(big.Int).SetString(value, 16) + if !ok || n.Sign() < 0 || n.Cmp(fr.Modulus()) >= 0 { + return nil, fmt.Errorf("invalid field commitment %q", value) + } + return n, nil +} + +// DescribeRecord is intentionally safe for errors: it identifies the row +// without printing its payload hash, operator ID, or nonce. +func DescribeRecord(record Record) string { + return record.Pipeline + "/" + record.Step + " at " + strconv.FormatInt(record.DecidedAt, 10) +} diff --git a/internal/zkreceipt/proof.go b/internal/zkreceipt/proof.go new file mode 100644 index 0000000..12c906f --- /dev/null +++ b/internal/zkreceipt/proof.go @@ -0,0 +1,222 @@ +package zkreceipt + +import ( + "bytes" + "crypto/sha256" + _ "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/backend/groth16" + groth16bn254 "github.com/consensys/gnark/backend/groth16/bn254" + "github.com/consensys/gnark/constraint" + constraintbn254 "github.com/consensys/gnark/constraint/bn254" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/r1cs" +) + +// Bundle is safe to give to a verifier. It contains no approval fields: only +// public commitments, the proof, and non-sensitive performance metadata. +type Bundle struct { + Schema string `json:"schema"` + Curve string `json:"curve"` + ProofType string `json:"proof_type"` + CircuitID string `json:"circuit_id"` + KeyCommitment string `json:"key_commitment"` + ReceiptCommitment string `json:"receipt_commitment"` + Proof string `json:"proof_base64"` + ProofBytes int `json:"proof_bytes"` + ProverMS int64 `json:"prover_ms"` +} + +//go:embed artifacts/approval.r1cs +var embeddedR1CS []byte + +//go:embed artifacts/approval.pk +var embeddedPK []byte + +//go:embed artifacts/approval.vk +var embeddedVK []byte + +type proofSystem struct { + ccs constraint.ConstraintSystem + pk groth16.ProvingKey + vk groth16.VerifyingKey + circuitID string +} + +var ( + embeddedSystem *proofSystem + embeddedSystemErr error + embeddedSystemOnce sync.Once +) + +func compileCircuit() (constraint.ConstraintSystem, error) { + return frontend.Compile(ecc.BN254.ScalarField(), r1cs.NewBuilder, &Circuit{}) +} + +func loadEmbeddedSystem() (*proofSystem, error) { + embeddedSystemOnce.Do(func() { + ccs := new(constraintbn254.R1CS) + if _, err := ccs.ReadFrom(bytes.NewReader(embeddedR1CS)); err != nil { + embeddedSystemErr = fmt.Errorf("read embedded constraint system: %w", err) + return + } + pk := new(groth16bn254.ProvingKey) + if _, err := pk.ReadFrom(bytes.NewReader(embeddedPK)); err != nil { + embeddedSystemErr = fmt.Errorf("read embedded proving key: %w", err) + return + } + vk := new(groth16bn254.VerifyingKey) + if _, err := vk.ReadFrom(bytes.NewReader(embeddedVK)); err != nil { + embeddedSystemErr = fmt.Errorf("read embedded verifying key: %w", err) + return + } + digest := sha256.Sum256(embeddedVK) + embeddedSystem = &proofSystem{ccs: ccs, pk: pk, vk: vk, circuitID: hex.EncodeToString(digest[:])} + }) + return embeddedSystem, embeddedSystemErr +} + +// GenerateArtifacts compiles the fixed circuit and creates a Groth16 proving +// and verifying key. The checked-in keys make the preview reproducible; they +// are a single-party setup and must be replaced by a ceremony before production. +func GenerateArtifacts(directory string) error { + ccs, err := compileCircuit() + if err != nil { + return fmt.Errorf("compile circuit: %w", err) + } + pk, vk, err := groth16.Setup(ccs) + if err != nil { + return fmt.Errorf("Groth16 setup: %w", err) + } + if err := os.MkdirAll(directory, 0o750); err != nil { + return err + } + artifacts := []struct { + name string + writer interface { + WriteTo(io.Writer) (int64, error) + } + }{ + {name: "approval.r1cs", writer: ccs}, + {name: "approval.pk", writer: pk}, + {name: "approval.vk", writer: vk}, + } + for _, artifact := range artifacts { + var buffer bytes.Buffer + if _, err := artifact.writer.WriteTo(&buffer); err != nil { + return fmt.Errorf("serialize %s: %w", artifact.name, err) + } + if err := os.WriteFile(filepath.Join(directory, artifact.name), buffer.Bytes(), 0o600); err != nil { + return fmt.Errorf("write %s: %w", artifact.name, err) + } + } + return nil +} + +// Prove creates a selective-disclosure proof for an already authenticated +// approval row. Callers are responsible for checking the row's HMAC first. +func Prove(record Record, secret []byte) (Bundle, error) { + system, err := loadEmbeddedSystem() + if err != nil { + return Bundle{}, err + } + privateAssignment, keyCommitment, receiptCommitment, err := assignment(record, secret) + if err != nil { + return Bundle{}, err + } + witness, err := frontend.NewWitness(privateAssignment, ecc.BN254.ScalarField()) + if err != nil { + return Bundle{}, fmt.Errorf("build private witness: %w", err) + } + started := time.Now() + proof, err := groth16.Prove(system.ccs, system.pk, witness) + if err != nil { + return Bundle{}, fmt.Errorf("prove approval: %w", err) + } + var encoded bytes.Buffer + if _, err := proof.WriteTo(&encoded); err != nil { + return Bundle{}, fmt.Errorf("serialize proof: %w", err) + } + return Bundle{ + Schema: Schema, + Curve: Curve, + ProofType: ProofType, + CircuitID: system.circuitID, + KeyCommitment: encodeField(keyCommitment), + ReceiptCommitment: encodeField(receiptCommitment), + Proof: base64.StdEncoding.EncodeToString(encoded.Bytes()), + ProofBytes: encoded.Len(), + ProverMS: time.Since(started).Milliseconds(), + }, nil +} + +// Verify checks the proof and, when expectedKey is non-empty, requires the +// proof to come from that pinned Draftcat instance key. +func Verify(bundle Bundle, expectedKey string) error { + if bundle.Schema != Schema || bundle.Curve != Curve || bundle.ProofType != ProofType { + return fmt.Errorf("unsupported proof bundle %q/%q/%q", bundle.Schema, bundle.Curve, bundle.ProofType) + } + system, err := loadEmbeddedSystem() + if err != nil { + return err + } + if bundle.CircuitID != system.circuitID { + return fmt.Errorf("circuit ID does not match this Draftcat build") + } + if expectedKey != "" && bundle.KeyCommitment != expectedKey { + return fmt.Errorf("instance key commitment mismatch") + } + keyCommitment, err := decodeField(bundle.KeyCommitment) + if err != nil { + return fmt.Errorf("key commitment: %w", err) + } + receiptCommitment, err := decodeField(bundle.ReceiptCommitment) + if err != nil { + return fmt.Errorf("receipt commitment: %w", err) + } + publicWitness, err := frontend.NewWitness(publicAssignment(keyCommitment, receiptCommitment), ecc.BN254.ScalarField(), frontend.PublicOnly()) + if err != nil { + return fmt.Errorf("build public witness: %w", err) + } + proofBytes, err := base64.StdEncoding.DecodeString(bundle.Proof) + if err != nil { + return fmt.Errorf("decode proof: %w", err) + } + proof := new(groth16bn254.Proof) + if _, err := proof.ReadFrom(bytes.NewReader(proofBytes)); err != nil { + return fmt.Errorf("read proof: %w", err) + } + if err := groth16.Verify(proof, system.vk, publicWitness); err != nil { + return fmt.Errorf("invalid zero-knowledge approval proof: %w", err) + } + return nil +} + +func KeyCommitment(secret []byte) (string, error) { + if len(secret) < 32 { + return "", fmt.Errorf("approval secret must be at least 32 bytes") + } + return encodeField(nativeHash(domainField("draftcat-instance-key-v1"), bytesField("secret", secret))), nil +} + +func MarshalBundle(bundle Bundle) ([]byte, error) { + return json.MarshalIndent(bundle, "", " ") +} + +func UnmarshalBundle(data []byte) (Bundle, error) { + var bundle Bundle + if err := json.Unmarshal(data, &bundle); err != nil { + return Bundle{}, fmt.Errorf("parse proof bundle: %w", err) + } + return bundle, nil +} diff --git a/internal/zkreceipt/proof_test.go b/internal/zkreceipt/proof_test.go new file mode 100644 index 0000000..97c29b4 --- /dev/null +++ b/internal/zkreceipt/proof_test.go @@ -0,0 +1,98 @@ +package zkreceipt + +import ( + "strings" + "testing" +) + +var validRecord = Record{ + Pipeline: "customer-refund", + Step: "send-refund", + DecidedAt: 1788580800, + Decision: "approve", + OperatorID: 4815162342, + PayloadHash: "1b4a1f4e20d8228d0df23d2b4b2ac302adca14c311f5210c1092cc11890a4f8e", + QuorumN: 2, + QuorumGot: 3, + Nonce: "61f6ba5968994b80a1fa9e360fb173f1", +} + +var testSecret = []byte("correct horse battery staple plus entropy") + +func TestProveAndVerify(t *testing.T) { + bundle, err := Prove(validRecord, testSecret) + if err != nil { + t.Fatal(err) + } + if bundle.ProofBytes == 0 || bundle.KeyCommitment == "" || bundle.ReceiptCommitment == "" { + t.Fatalf("incomplete bundle: %+v", bundle) + } + expectedKey, err := KeyCommitment(testSecret) + if err != nil { + t.Fatal(err) + } + if err := Verify(bundle, expectedKey); err != nil { + t.Fatalf("valid proof rejected: %v", err) + } + encoded, err := MarshalBundle(bundle) + if err != nil { + t.Fatal(err) + } + for _, privateValue := range []string{validRecord.Pipeline, validRecord.Step, validRecord.PayloadHash, validRecord.Nonce} { + if strings.Contains(string(encoded), privateValue) { + t.Fatalf("proof bundle disclosed private value %q", privateValue) + } + } +} + +func TestVerifyRejectsTamperedCommitment(t *testing.T) { + bundle, err := Prove(validRecord, testSecret) + if err != nil { + t.Fatal(err) + } + bundle.ReceiptCommitment = strings.Repeat("0", 64) + if err := Verify(bundle, bundle.KeyCommitment); err == nil { + t.Fatal("tampered public commitment was accepted") + } +} + +func TestVerifyRejectsWrongPinnedKey(t *testing.T) { + bundle, err := Prove(validRecord, testSecret) + if err != nil { + t.Fatal(err) + } + wrongKey, err := KeyCommitment([]byte("different secret with thirty two plus bytes")) + if err != nil { + t.Fatal(err) + } + if err := Verify(bundle, wrongKey); err == nil { + t.Fatal("proof was accepted under the wrong pinned key") + } +} + +func TestProveRejectsNonHumanAndUnderQuorum(t *testing.T) { + policy := validRecord + policy.Decision = "policy_approve" + if _, err := Prove(policy, testSecret); err == nil { + t.Fatal("automated policy approval was accepted") + } + underQuorum := validRecord + underQuorum.QuorumGot = 1 + if _, err := Prove(underQuorum, testSecret); err == nil { + t.Fatal("under-quorum approval was accepted") + } +} + +func TestKeyCommitmentChangesWithSecret(t *testing.T) { + one, err := KeyCommitment(testSecret) + if err != nil { + t.Fatal(err) + } + two, err := KeyCommitment([]byte("another secret with more than enough random bytes")) + if err != nil { + t.Fatal(err) + } + if one == two { + t.Fatal("different instance secrets produced the same commitment") + } +} diff --git a/main.go b/main.go index 51c13bb..d857000 100644 --- a/main.go +++ b/main.go @@ -2658,6 +2658,8 @@ func main() { os.Exit(runTestCmd(os.Args[2:])) case "audit-verify": os.Exit(runAuditVerify(os.Args[2:])) + case "zk-receipt": + os.Exit(runZKReceiptCmd(os.Args[2:])) case "runs": os.Exit(runRunsCmd(os.Args[2:])) case "pending": @@ -2674,6 +2676,7 @@ func main() { fmt.Println(" draftcat runs [pipeline] [--json] recent runs + the approval decisions in each") fmt.Println(" draftcat pending [--json] approval gates waiting on a human right now") fmt.Println(" draftcat audit-verify check approval-receipt signatures (needs DRAFTCAT_APPROVAL_SECRET)") + fmt.Println(" draftcat zk-receipt prove an approval without revealing its private fields") fmt.Println(" draftcat hitl verify run the hitl/v0 conformance suite against a relay") return } diff --git a/zk_receipt_cmd.go b/zk_receipt_cmd.go new file mode 100644 index 0000000..f5d25fa --- /dev/null +++ b/zk_receipt_cmd.go @@ -0,0 +1,194 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/renezander030/draftcat/internal/approval" + "github.com/renezander030/draftcat/internal/config" + statestore "github.com/renezander030/draftcat/internal/state" + "github.com/renezander030/draftcat/internal/zkreceipt" +) + +func runZKReceiptCmd(args []string) int { + if len(args) == 0 { + printZKReceiptUsage() + return 2 + } + switch args[0] { + case "key-id": + return runZKReceiptKeyID(args[1:]) + case "prove": + return runZKReceiptProve(args[1:]) + case "verify": + return runZKReceiptVerify(args[1:]) + case "-h", "--help", "help": + printZKReceiptUsage() + return 0 + default: + fmt.Fprintf(os.Stderr, "unknown zk-receipt command %q\n", args[0]) + printZKReceiptUsage() + return 2 + } +} + +func printZKReceiptUsage() { + fmt.Fprintln(os.Stderr, "Usage:") + fmt.Fprintln(os.Stderr, " draftcat zk-receipt key-id") + fmt.Fprintln(os.Stderr, " draftcat zk-receipt prove [--config config.yaml] [--out proof.json] ") + fmt.Fprintln(os.Stderr, " draftcat zk-receipt verify --expect-key ") +} + +func approvalSecret() ([]byte, error) { + secret := []byte(os.Getenv("DRAFTCAT_APPROVAL_SECRET")) + if len(secret) < 32 { + return nil, fmt.Errorf("DRAFTCAT_APPROVAL_SECRET must be set and at least 32 bytes") + } + return secret, nil +} + +func runZKReceiptKeyID(args []string) int { + if len(args) != 0 { + fmt.Fprintln(os.Stderr, "usage: draftcat zk-receipt key-id") + return 2 + } + secret, err := approvalSecret() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + commitment, err := zkreceipt.KeyCommitment(secret) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + fmt.Println(commitment) + return 0 +} + +func runZKReceiptProve(args []string) int { + flags := flag.NewFlagSet("zk-receipt prove", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + configPath := flags.String("config", "config.yaml", "Draftcat config path") + outputPath := flags.String("out", "", "proof bundle path; defaults to stdout") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 1 { + fmt.Fprintln(os.Stderr, "usage: draftcat zk-receipt prove [--config config.yaml] [--out proof.json] ") + return 2 + } + secret, err := approvalSecret() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + pipeline := flags.Arg(0) + storePath := resolveZKStatePath(*configPath) + store, err := statestore.OpenStateStore(storePath) + if err != nil { + fmt.Fprintf(os.Stderr, "open state store %s: %v\n", storePath, err) + return 1 + } + defer func() { _ = store.Close() }() + records, err := store.ApprovalsForPipeline(pipeline, 1000) + if err != nil { + fmt.Fprintf(os.Stderr, "read approvals: %v\n", err) + return 1 + } + record, ok := latestHumanApproval(records) + if !ok { + fmt.Fprintf(os.Stderr, "no direct human approval found for pipeline %q\n", pipeline) + return 1 + } + fields := approval.Fields{ + Pipeline: record.Pipeline, Step: record.Step, DecidedAt: record.DecidedAt.Unix(), + Decision: record.Decision, OperatorID: record.OperatorID, PayloadHash: record.PayloadHash, + QuorumN: record.QuorumN, QuorumGot: record.QuorumGot, + } + if record.Signature == "" || !approval.Verify(secret, fields, record.Nonce, record.Signature) { + fmt.Fprintln(os.Stderr, "latest human approval is unsigned or its receipt was altered; refusing to prove it") + return 1 + } + privateRecord := zkreceipt.Record{ + Pipeline: record.Pipeline, Step: record.Step, DecidedAt: record.DecidedAt.Unix(), + Decision: record.Decision, OperatorID: record.OperatorID, PayloadHash: record.PayloadHash, + QuorumN: record.QuorumN, QuorumGot: record.QuorumGot, Nonce: record.Nonce, + } + bundle, err := zkreceipt.Prove(privateRecord, secret) + if err != nil { + fmt.Fprintf(os.Stderr, "create proof: %v\n", err) + return 1 + } + encoded, err := zkreceipt.MarshalBundle(bundle) + if err != nil { + fmt.Fprintf(os.Stderr, "encode proof: %v\n", err) + return 1 + } + encoded = append(encoded, '\n') + if *outputPath == "" { + _, _ = os.Stdout.Write(encoded) + } else if err := os.WriteFile(*outputPath, encoded, 0o600); err != nil { + fmt.Fprintf(os.Stderr, "write proof: %v\n", err) + return 1 + } + fmt.Fprintf(os.Stderr, "created %d-byte proof in %d ms; approval fields stayed private\n", bundle.ProofBytes, bundle.ProverMS) + return 0 +} + +func latestHumanApproval(records []statestore.ApprovalRecord) (statestore.ApprovalRecord, bool) { + for _, record := range records { + if record.Decision == "approve" { + return record, true + } + } + return statestore.ApprovalRecord{}, false +} + +func resolveZKStatePath(configPath string) string { + if value := strings.TrimSpace(os.Getenv("DRAFTCAT_STATE_PATH")); value != "" { + return value + } + // #nosec G304 -- the operator explicitly chooses which local Draftcat config to read. + if data, err := os.ReadFile(configPath); err == nil { + var cfg config.Config + if yaml.Unmarshal(data, &cfg) == nil && strings.TrimSpace(cfg.State.Path) != "" { + return cfg.State.Path + } + } + return "./state.db" +} + +func runZKReceiptVerify(args []string) int { + flags := flag.NewFlagSet("zk-receipt verify", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + expectedKey := flags.String("expect-key", "", "pinned Draftcat instance-key commitment") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 1 || *expectedKey == "" { + fmt.Fprintln(os.Stderr, "usage: draftcat zk-receipt verify --expect-key ") + return 2 + } + // #nosec G304 -- the proof bundle path is the explicit CLI input to verify. + data, err := os.ReadFile(flags.Arg(0)) + if err != nil { + fmt.Fprintf(os.Stderr, "read proof: %v\n", err) + return 1 + } + bundle, err := zkreceipt.UnmarshalBundle(data) + if err == nil { + err = zkreceipt.Verify(bundle, *expectedKey) + } + if err != nil { + fmt.Fprintf(os.Stderr, "INVALID: %v\n", err) + return 1 + } + fmt.Printf("VALID: a direct human approval met its quorum; private approval fields were not disclosed\n") + fmt.Printf("receipt commitment: %s\n", bundle.ReceiptCommitment) + return 0 +} diff --git a/zk_receipt_cmd_test.go b/zk_receipt_cmd_test.go new file mode 100644 index 0000000..9f9e276 --- /dev/null +++ b/zk_receipt_cmd_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/renezander030/draftcat/internal/approval" + statestore "github.com/renezander030/draftcat/internal/state" + "github.com/renezander030/draftcat/internal/zkreceipt" +) + +func TestLatestHumanApprovalDoesNotTreatPolicyAsHuman(t *testing.T) { + records := []statestore.ApprovalRecord{ + {Decision: "policy_approve", DecidedAt: time.Now()}, + {Decision: "approve", DecidedAt: time.Now().Add(-time.Minute)}, + } + record, ok := latestHumanApproval(records) + if !ok || record.Decision != "approve" { + t.Fatalf("wanted direct human approval, got %+v, %v", record, ok) + } +} + +func TestZKReceiptCommandEndToEnd(t *testing.T) { + directory := t.TempDir() + statePath := filepath.Join(directory, "state.db") + proofPath := filepath.Join(directory, "approval.proof.json") + secret := []byte("test approval secret is long enough") + t.Setenv("DRAFTCAT_STATE_PATH", statePath) + t.Setenv("DRAFTCAT_APPROVAL_SECRET", string(secret)) + + store, err := statestore.OpenStateStore(statePath) + if err != nil { + t.Fatal(err) + } + decidedAt := time.Unix(1788580800, 0) + fields := approval.Fields{ + Pipeline: "customer-refund", Step: "send-refund", DecidedAt: decidedAt.Unix(), + Decision: "approve", OperatorID: 42, PayloadHash: "payload-sha256", QuorumN: 2, QuorumGot: 2, + } + nonce := "61f6ba5968994b80a1fa9e360fb173f1" + if err := store.RecordApproval(fields.Pipeline, fields.Step, decidedAt, fields.Decision, fields.OperatorID, + fields.PayloadHash, fields.QuorumN, fields.QuorumGot, nonce, approval.Sign(secret, fields, nonce)); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + if code := runZKReceiptProve([]string{"--out", proofPath, fields.Pipeline}); code != 0 { + t.Fatalf("prove command exited %d", code) + } + data, err := os.ReadFile(proofPath) + if err != nil { + t.Fatal(err) + } + bundle, err := zkreceipt.UnmarshalBundle(data) + if err != nil { + t.Fatal(err) + } + expectedKey, err := zkreceipt.KeyCommitment(secret) + if err != nil { + t.Fatal(err) + } + if err := zkreceipt.Verify(bundle, expectedKey); err != nil { + t.Fatalf("command produced invalid proof: %v", err) + } +}