diff --git a/.gitignore b/.gitignore index 39fa0f7..0efa3c5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ draftcat *.env *.key *.secret +fhe-secret.json +*.fhe-secret.json secrets.yaml state.db state.db-shm diff --git a/README.md b/README.md index 3357f6a..79933df 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,32 @@ 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. +## Let a server count votes it cannot read + +A normal approval server sees how every person voted. Draftcat's experimental **FHE encrypted tally** lets three or more reviewers turn `approve` or `reject` into unreadable ciphertext on their own machines. A collector combines those files without opening them; only the key owner can reveal the final count and learn whether quorum was met. + +```text +reviewers encrypt votes → collector adds unreadable ballots → key owner opens one total + collector never sees yes or no +``` + +```bash +# Once per vote: create the private key and the public key reviewers receive. +./draftcat fhe-vote keygen + +# Each reviewer encrypts locally. The readable vote is never sent. +./draftcat fhe-vote encrypt --public fhe-public.json --context invoice-4821 \ + --ballot --vote approve --out reviewer.vote.json + +# The collector combines 3+ encrypted ballots; the owner alone opens the result. +./draftcat fhe-vote tally --public fhe-public.json --context invoice-4821 \ + --out tally.json alice.vote.json bob.vote.json carol.vote.json +./draftcat fhe-vote decrypt --secret fhe-secret.json --context invoice-4821 \ + --expected 3 --quorum 2 tally.json +``` + +**Use it when** separate teams, companies, or committee members need a shared approval but the tally host must not know individual votes. Keep the collector separate from the key owner and give the key owner only the final tally. **Skip it when** the same trusted Draftcat owner may see the votes, fewer than three people vote, or you need a public audit receipt—the zero-knowledge feature below is for that. Ciphertext files are still sent; the plaintext votes are not. Read the [encrypted vote walkthrough and threat model](docs/fhe-vote-tally.md) before evaluating it. + ## 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: @@ -92,6 +118,7 @@ However your agent runs, draftcat sits between it and your customer systems as a - **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). +- **Encrypted approval tally** — combine three or more encrypted votes without letting the collector read any individual vote; see [`docs/fhe-vote-tally.md`](docs/fhe-vote-tally.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/docs/fhe-vote-tally.md b/docs/fhe-vote-tally.md new file mode 100644 index 0000000..a7f4bda --- /dev/null +++ b/docs/fhe-vote-tally.md @@ -0,0 +1,118 @@ +# Encrypted approval-vote tally + +> Reviewers send encrypted ballots. The collector adds them without learning how anyone voted. Only the designated key owner decrypts the final total. + +This preview adds the private-computation job that a zero-knowledge receipt does not do. The existing ZK feature hides an approval record while proving a claim about it to someone else. This feature instead lets an untrusted machine **operate on hidden inputs**. + +An encrypted ballot file still travels to the collector. The promise is that the readable `approve` or `reject` value does not. + +## Practical uses + +- A buyer, supplier, and auditor must jointly release a payment, but none wants the shared workflow host to see its individual vote. +- An internal review panel votes on a sensitive customer escalation while a separate operations team runs the tally service. +- Several organizations approve a cross-company AI action and reveal only the final count to the person authorized to release it. + +## Use it when / skip it when + +Use it when: + +- at least three eligible reviewers participate; +- the machine collecting the ballots is not allowed to read individual votes; +- one designated owner is allowed to learn the final count; +- Draftcat's existing authenticated channel controls which ballot invitations are eligible. + +Skip it when: + +- the Draftcat owner and tally collector are the same trusted party; +- one or two people vote, because the aggregate is too easy to attribute; +- the collector must learn the result directly; +- you need a compact proof for a customer or auditor—use [`zk-receipt`](zk-approval-proofs.md); +- you need a production-audited cryptographic control today. + +## Walkthrough + +### 1. The tally owner creates one campaign key + +```bash +./draftcat fhe-vote keygen \ + --secret invoice-4821.fhe-secret.json \ + --public invoice-4821.fhe-public.json +``` + +The public file goes to eligible reviewers and the collector. The secret file is mode `0600`; keep it off the collector and out of source control. Use a fresh key for each sensitive campaign so the owner cannot compare overlapping partial tallies to infer a person's vote. + +### 2. Each reviewer encrypts locally + +```bash +./draftcat fhe-vote encrypt \ + --public invoice-4821.fhe-public.json \ + --context invoice-4821 \ + --ballot random-one-use-invitation-from-draftcat \ + --vote approve \ + --out alice.vote.json +``` + +`--context` binds all ballots to one action; only a domain-separated SHA-256 commitment is stored. `--ballot` must be a different, unpredictable invitation for each eligible reviewer. Its commitment lets the collector reject an exact replay without publishing a reviewer identity. + +The encrypted file contains neither the context text, the invitation, nor an `approve`/`reject` field. + +### 3. The collector combines ciphertexts + +```bash +./draftcat fhe-vote tally \ + --public invoice-4821.fhe-public.json \ + --context invoice-4821 \ + --out invoice-4821.tally.json \ + alice.vote.json bob.vote.json carol.vote.json +``` + +The collector sees that three ballots were supplied and sees their opaque commitments. It homomorphically adds the encrypted vote and an encrypted `1` per submitted ballot; it has no decryption key. Commitments remain on the final tally for duplicate detection and operational reconciliation, but FHE does not authenticate that metadata. + +### 4. The owner should open only the intended full-group total + +```bash +./draftcat fhe-vote decrypt \ + --secret invoice-4821.fhe-secret.json \ + --context invoice-4821 \ + --expected 3 \ + --quorum 2 \ + invoice-4821.tally.json +``` + +Success prints, for example: + +```text +QUORUM MET: 2 of 3 encrypted votes approved (required 2) +``` + +Exit code `0` means quorum was met. Exit code `3` means a structurally valid tally of the expected declared size did not meet quorum. Malformed, count-mismatched, wrong-key, or wrong-context input fails closed with exit code `1` or `2`. + +## What each party learns + +| Party | Learns | Does not learn | +| --- | --- | --- | +| Reviewer | Their own vote, campaign context, public key | Other votes, final result unless the owner shares it | +| Collector | Public-key ID, context commitment, opaque ballot commitments, number and size of ballots | Individual approve/reject values, secret key, final total | +| Key owner | Final approval count, submitted count, quorum result | Which encrypted ballot contained which vote, provided the owner never receives individual ballot files | + +## Security boundary + +Homomorphic encryption provides **confidentiality, not voter authentication or a proof of correct behavior**: + +- The supplied CLI encrypts only `0` or `1`, but encryption alone does not prove a malicious voter used the CLI or chose an allowed value. The final check rejects an impossible total, not every possible compensating cheat. Accept ballots only through Draftcat's authenticated approval channel; a production protocol also needs a range proof for every encrypted vote. +- The ciphertext carries an encrypted contribution count, so editing only the visible count is detected and `--expected` catches an accidental partial tally. This is not proof of voter identity: anyone with the public key can fabricate a padding ballot. Authenticate every submission and reconcile invitation commitments outside this preview. +- The collector can modify ciphertexts because homomorphic encryption is intentionally malleable. The decrypt command rejects malformed shapes and impossible totals, but transport signatures are still required for production. +- Decrypting overlapping subsets can expose individual votes. Generate a fresh key per campaign and decrypt only the complete expected cohort. +- The secret key can decrypt an individual ballot too. Never give the key owner the ballot files; keep collection and decryption as separate roles. +- Context and ballot commitments can be guessed if their input values are predictable. Use a random campaign nonce and random one-use ballot invitations. +- File size, timing, public-key ID, and submitted ballot count remain visible metadata. + +The current `zk-receipt` does not yet attest that an FHE tally drove a normal Draftcat action. Treat the two previews as separate until that binding is implemented and audited. + +## Cryptographic implementation + +The preview uses the BGV scheme in [Lattigo v6](https://github.com/tuneinsight/lattigo) with its small-depth example parameter set (`LogN=12`, `LogQP=109`, plaintext modulus `65537`), estimated by Lattigo at 128-bit security when published. Draftcat uses only encrypted addition: `approve` is encoded as `1`, `reject` as `0`, a second slot carries the contribution count, and the collector adds the ciphertexts. + +BGV belongs to the FHE family, but this bounded preview does not perform bootstrapping or claim arbitrary-depth computation. Lattigo warns that its v6 API is fast-moving and that example parameters are for experimentation rather than a production deployment profile. Obtain an independent cryptographic and protocol audit before production reliance. + +In the local end-to-end check used for this preview, one binary ciphertext was about 131 KB and its JSON ballot about 176 KB. That is far larger than a ZK receipt, but one ciphertext can pack thousands of values; encrypted aggregation is the intended use, not replacing a compact proof. diff --git a/fhe_vote_cmd.go b/fhe_vote_cmd.go new file mode 100644 index 0000000..d23c037 --- /dev/null +++ b/fhe_vote_cmd.go @@ -0,0 +1,249 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/renezander030/draftcat/internal/fhevote" +) + +func runFHEVoteCmd(args []string) int { + if len(args) == 0 { + printFHEVoteUsage() + return 2 + } + switch args[0] { + case "keygen": + return runFHEVoteKeygen(args[1:]) + case "encrypt": + return runFHEVoteEncrypt(args[1:]) + case "tally": + return runFHEVoteTally(args[1:]) + case "decrypt": + return runFHEVoteDecrypt(args[1:]) + case "-h", "--help", "help": + printFHEVoteUsage() + return 0 + default: + fmt.Fprintf(os.Stderr, "unknown fhe-vote command %q\n", args[0]) + printFHEVoteUsage() + return 2 + } +} + +func printFHEVoteUsage() { + fmt.Fprintln(os.Stderr, "Usage:") + fmt.Fprintln(os.Stderr, " draftcat fhe-vote keygen [--secret fhe-secret.json] [--public fhe-public.json]") + fmt.Fprintln(os.Stderr, " draftcat fhe-vote encrypt --public fhe-public.json --context --ballot --vote approve|reject --out vote.json") + fmt.Fprintln(os.Stderr, " draftcat fhe-vote tally --public fhe-public.json --context --out tally.json ...") + fmt.Fprintln(os.Stderr, " draftcat fhe-vote decrypt --secret fhe-secret.json --context --expected --quorum ") +} + +func runFHEVoteKeygen(args []string) int { + flags := flag.NewFlagSet("fhe-vote keygen", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + secretPath := flags.String("secret", "fhe-secret.json", "secret decryption key path") + publicPath := flags.String("public", "fhe-public.json", "shareable encryption key path") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 || *secretPath == *publicPath { + fmt.Fprintln(os.Stderr, "keygen requires different --secret and --public paths and no positional arguments") + return 2 + } + secret, public, err := fhevote.GenerateKeys() + if err != nil { + fmt.Fprintf(os.Stderr, "generate FHE keys: %v\n", err) + return 1 + } + secretJSON, err := fhevote.Marshal(secret) + if err != nil { + fmt.Fprintf(os.Stderr, "encode secret key: %v\n", err) + return 1 + } + publicJSON, err := fhevote.Marshal(public) + if err != nil { + fmt.Fprintf(os.Stderr, "encode public key: %v\n", err) + return 1 + } + if err := writeExclusive(*secretPath, append(secretJSON, '\n'), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "write secret key: %v\n", err) + return 1 + } + if err := writeExclusive(*publicPath, append(publicJSON, '\n'), 0o644); err != nil { + _ = os.Remove(*secretPath) + fmt.Fprintf(os.Stderr, "write public key: %v\n", err) + return 1 + } + fmt.Printf("created encrypted-vote keys; share %s and keep %s private\n", *publicPath, *secretPath) + fmt.Printf("key ID: %s\n", public.KeyID) + return 0 +} + +func runFHEVoteEncrypt(args []string) int { + flags := flag.NewFlagSet("fhe-vote encrypt", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + publicPath := flags.String("public", "", "shareable encryption key path") + context := flags.String("context", "", "workflow/action context; only its hash is stored") + ballot := flags.String("ballot", "", "unique random ballot token; only its hash is stored") + vote := flags.String("vote", "", "approve or reject") + outputPath := flags.String("out", "", "encrypted ballot path") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 || *publicPath == "" || *context == "" || *ballot == "" || *outputPath == "" || (*vote != "approve" && *vote != "reject") { + fmt.Fprintln(os.Stderr, "usage: draftcat fhe-vote encrypt --public --context --ballot --vote approve|reject --out ") + return 2 + } + public, err := readPublicKey(*publicPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + encrypted, err := fhevote.EncryptVote(public, *context, *ballot, *vote == "approve") + if err != nil { + fmt.Fprintf(os.Stderr, "encrypt vote: %v\n", err) + return 1 + } + encoded, err := fhevote.Marshal(encrypted) + if err != nil { + fmt.Fprintf(os.Stderr, "encode vote: %v\n", err) + return 1 + } + if err := writeExclusive(*outputPath, append(encoded, '\n'), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "write encrypted vote: %v\n", err) + return 1 + } + fmt.Printf("encrypted one vote to %s; the bundle contains no readable approve/reject value\n", *outputPath) + return 0 +} + +func runFHEVoteTally(args []string) int { + flags := flag.NewFlagSet("fhe-vote tally", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + publicPath := flags.String("public", "", "pinned encryption key path") + context := flags.String("context", "", "workflow/action context") + outputPath := flags.String("out", "", "encrypted tally path") + if err := flags.Parse(args); err != nil { + return 2 + } + if *publicPath == "" || *context == "" || *outputPath == "" || flags.NArg() == 0 { + fmt.Fprintln(os.Stderr, "usage: draftcat fhe-vote tally --public --context --out ...") + return 2 + } + public, err := readPublicKey(*publicPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + ballots := make([]fhevote.Ballot, 0, flags.NArg()) + for _, path := range flags.Args() { + data, err := os.ReadFile(path) // #nosec G304 -- explicit CLI input. + if err != nil { + fmt.Fprintf(os.Stderr, "read encrypted ballot %s: %v\n", path, err) + return 1 + } + ballot, err := fhevote.ParseBallot(data) + if err != nil { + fmt.Fprintf(os.Stderr, "parse encrypted ballot %s: %v\n", path, err) + return 1 + } + ballots = append(ballots, ballot) + } + tally, err := fhevote.Aggregate(public, *context, ballots) + if err != nil { + fmt.Fprintf(os.Stderr, "tally encrypted votes: %v\n", err) + return 1 + } + encoded, err := fhevote.Marshal(tally) + if err != nil { + fmt.Fprintf(os.Stderr, "encode tally: %v\n", err) + return 1 + } + if err := writeExclusive(*outputPath, append(encoded, '\n'), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "write encrypted tally: %v\n", err) + return 1 + } + fmt.Printf("combined %d encrypted votes into %s without decrypting any vote\n", tally.Ballots, *outputPath) + return 0 +} + +func runFHEVoteDecrypt(args []string) int { + flags := flag.NewFlagSet("fhe-vote decrypt", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + secretPath := flags.String("secret", "", "secret decryption key path") + context := flags.String("context", "", "workflow/action context") + expected := flags.Int("expected", 0, "exact number of eligible ballots expected") + quorum := flags.Int("quorum", 0, "approvals required") + if err := flags.Parse(args); err != nil { + return 2 + } + if *secretPath == "" || *context == "" || *expected < 3 || *quorum < 1 || *quorum > *expected || flags.NArg() != 1 { + fmt.Fprintln(os.Stderr, "usage: draftcat fhe-vote decrypt --secret --context --expected =3+ --quorum ") + return 2 + } + secretData, err := os.ReadFile(*secretPath) // #nosec G304 -- explicit CLI input. + if err != nil { + fmt.Fprintf(os.Stderr, "read secret key: %v\n", err) + return 1 + } + secret, err := fhevote.ParseSecretKey(secretData) + if err != nil { + fmt.Fprintf(os.Stderr, "parse secret key: %v\n", err) + return 1 + } + tallyData, err := os.ReadFile(flags.Arg(0)) // #nosec G304 -- explicit CLI input. + if err != nil { + fmt.Fprintf(os.Stderr, "read tally: %v\n", err) + return 1 + } + tally, err := fhevote.ParseTally(tallyData) + if err != nil { + fmt.Fprintf(os.Stderr, "parse tally: %v\n", err) + return 1 + } + result, err := fhevote.Decrypt(secret, *context, tally) + if err != nil { + fmt.Fprintf(os.Stderr, "decrypt tally: %v\n", err) + return 1 + } + if result.Ballots != *expected { + fmt.Fprintf(os.Stderr, "expected %d eligible ballots but the tally contains %d; refusing the result\n", *expected, result.Ballots) + return 1 + } + if result.Approvals >= *quorum { + fmt.Printf("QUORUM MET: %d of %d encrypted votes approved (required %d)\n", result.Approvals, result.Ballots, *quorum) + return 0 + } + fmt.Printf("QUORUM NOT MET: %d of %d encrypted votes approved (required %d)\n", result.Approvals, result.Ballots, *quorum) + return 3 +} + +func readPublicKey(path string) (fhevote.PublicKeyFile, error) { + data, err := os.ReadFile(path) // #nosec G304 -- explicit CLI input. + if err != nil { + return fhevote.PublicKeyFile{}, fmt.Errorf("read public key: %w", err) + } + public, err := fhevote.ParsePublicKey(data) + if err != nil { + return fhevote.PublicKeyFile{}, fmt.Errorf("parse public key: %w", err) + } + return public, nil +} + +func writeExclusive(path string, data []byte, mode os.FileMode) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("output path must not be empty") + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) // #nosec G304 -- explicit CLI output. + if err != nil { + return err + } + if _, err = file.Write(data); err != nil { + _ = file.Close() + return err + } + return file.Close() +} diff --git a/fhe_vote_cmd_test.go b/fhe_vote_cmd_test.go new file mode 100644 index 0000000..3bda384 --- /dev/null +++ b/fhe_vote_cmd_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestFHEVoteCommandEndToEnd(t *testing.T) { + directory := t.TempDir() + secretPath := filepath.Join(directory, "campaign.fhe-secret.json") + publicPath := filepath.Join(directory, "campaign.fhe-public.json") + tallyPath := filepath.Join(directory, "tally.json") + context := "invoice-4821" + + if code := runFHEVoteKeygen([]string{"--secret", secretPath, "--public", publicPath}); code != 0 { + t.Fatalf("keygen exited %d", code) + } + for i, vote := range []string{"approve", "reject", "approve"} { + ballotPath := filepath.Join(directory, fmt.Sprintf("ballot-%d.json", i)) + if code := runFHEVoteEncrypt([]string{ + "--public", publicPath, + "--context", context, + "--ballot", fmt.Sprintf("random-invite-%d", i), + "--vote", vote, + "--out", ballotPath, + }); code != 0 { + t.Fatalf("encrypt %d exited %d", i, code) + } + } + if code := runFHEVoteTally([]string{ + "--public", publicPath, + "--context", context, + "--out", tallyPath, + filepath.Join(directory, "ballot-0.json"), + filepath.Join(directory, "ballot-1.json"), + filepath.Join(directory, "ballot-2.json"), + }); code != 0 { + t.Fatalf("tally exited %d", code) + } + if code := runFHEVoteDecrypt([]string{ + "--secret", secretPath, "--context", context, "--expected", "3", "--quorum", "2", tallyPath, + }); code != 0 { + t.Fatalf("decrypt exited %d", code) + } + if code := runFHEVoteDecrypt([]string{ + "--secret", secretPath, "--context", context, "--expected", "4", "--quorum", "2", tallyPath, + }); code != 1 { + t.Fatalf("partial tally exited %d, want 1", code) + } + + for _, path := range []string{secretPath, filepath.Join(directory, "ballot-0.json"), tallyPath} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("%s mode = %o, want 600", path, got) + } + } +} diff --git a/go.mod b/go.mod index 8f6ce47..5c6e3a2 100644 --- a/go.mod +++ b/go.mod @@ -6,27 +6,34 @@ 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 + github.com/tuneinsight/lattigo/v6 v6.2.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.50.1 ) require ( + github.com/ALTree/bigfloat v0.2.0 // indirect github.com/bits-and-blooms/bitset v1.24.6 // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect github.com/google/uuid v1.6.0 // 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/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/stretchr/testify v1.11.1 // indirect github.com/x448/float16 v0.8.4 // indirect golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect modernc.org/libc v1.72.3 // indirect diff --git a/go.sum b/go.sum index 8114b73..9a3a7a4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM= +github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4= 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= @@ -47,10 +49,14 @@ 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/tuneinsight/lattigo/v6 v6.2.0 h1:HZrksD5u87bOr/4hWHI1Jhps14Tafdvb84Fxmi3dou0= +github.com/tuneinsight/lattigo/v6 v6.2.0/go.mod h1:GggYhNDBTIsKOB5AVOdt6qdmqhZqXu7uDyiXQYQANlY= 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/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 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= diff --git a/internal/fhevote/fhevote.go b/internal/fhevote/fhevote.go new file mode 100644 index 0000000..f52abb3 --- /dev/null +++ b/internal/fhevote/fhevote.go @@ -0,0 +1,423 @@ +// Package fhevote implements Draftcat's experimental encrypted vote tally. +// +// It uses the BGV scheme from Lattigo. Individual 0/1 votes are encrypted by +// the approvers, added by an untrusted collector, and decrypted only by the +// owner of the secret key. This package deliberately handles only the private +// computation. Existing Draftcat authentication decides which ballots are +// eligible, and the ZK receipt remains the transferable integrity proof. +package fhevote + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/tuneinsight/lattigo/v6/core/rlwe" + "github.com/tuneinsight/lattigo/v6/schemes/bgv" +) + +const ( + schemaPrivate = "draftcat.fhe-vote-secret.v1" + PublicKeySchema = "draftcat.fhe-vote-public.v1" + BallotSchema = "draftcat.fhe-vote-ballot.v1" + TallySchema = "draftcat.fhe-vote-tally.v1" + Suite = "LATTIGO_BGV_128_N12_QP109" + maxBinaryBytes = 8 << 20 + minBallots = 3 + maxBallots = 4096 +) + +// SecretKeyFile stays with the party allowed to read the final tally. +type SecretKeyFile struct { + Schema string `json:"schema"` + Suite string `json:"suite"` + KeyID string `json:"key_id"` + SecretKeyBase64 string `json:"secret_key_base64"` + PublicKeyBase64 string `json:"public_key_base64"` +} + +// PublicKeyFile can be shared with every eligible voter and the collector. +type PublicKeyFile struct { + Schema string `json:"schema"` + Suite string `json:"suite"` + KeyID string `json:"key_id"` + PublicKeyBase64 string `json:"public_key_base64"` +} + +// Ballot is safe to give to the tally host: it contains an encrypted 0 or 1, +// not the readable vote. ContextID and BallotCommitment are hashes so workflow +// names and voter identities do not have to travel with the ciphertext. +type Ballot struct { + Schema string `json:"schema"` + Suite string `json:"suite"` + KeyID string `json:"key_id"` + ContextID string `json:"context_id"` + BallotCommitment string `json:"ballot_commitment"` + CiphertextBase64 string `json:"ciphertext_base64"` + CiphertextBytes int `json:"ciphertext_bytes"` +} + +// Tally is the homomorphic sum of a set of Ballots. The collector learns only +// how many encrypted ballots it accepted, not how any one of them voted. +type Tally struct { + Schema string `json:"schema"` + Suite string `json:"suite"` + KeyID string `json:"key_id"` + ContextID string `json:"context_id"` + Ballots int `json:"ballots"` + Contributors []string `json:"contributors"` + CiphertextBase64 string `json:"ciphertext_base64"` + CiphertextBytes int `json:"ciphertext_bytes"` +} + +// Result is available only after decrypting the aggregate with SecretKeyFile. +type Result struct { + Approvals int + Ballots int +} + +func parameters() (bgv.Parameters, error) { + // Lattigo's small-depth example parameter set is estimated at 128-bit + // security. This preview needs additions only; it does not bootstrap or + // claim that these example parameters are a production deployment profile. + return bgv.NewParametersFromLiteral(bgv.ParametersLiteral{ + LogN: 12, + LogQ: []int{39, 31}, + LogP: []int{39}, + PlaintextModulus: 0x10001, + }) +} + +// GenerateKeys creates the key pair used for one encrypted tally domain. +func GenerateKeys() (SecretKeyFile, PublicKeyFile, error) { + params, err := parameters() + if err != nil { + return SecretKeyFile{}, PublicKeyFile{}, err + } + sk, pk := rlwe.NewKeyGenerator(params).GenKeyPairNew() + skBytes, err := sk.MarshalBinary() + if err != nil { + return SecretKeyFile{}, PublicKeyFile{}, fmt.Errorf("encode secret key: %w", err) + } + pkBytes, err := pk.MarshalBinary() + if err != nil { + return SecretKeyFile{}, PublicKeyFile{}, fmt.Errorf("encode public key: %w", err) + } + id := keyID(pkBytes) + public := PublicKeyFile{ + Schema: PublicKeySchema, Suite: Suite, KeyID: id, + PublicKeyBase64: base64.StdEncoding.EncodeToString(pkBytes), + } + secret := SecretKeyFile{ + Schema: schemaPrivate, Suite: Suite, KeyID: id, + SecretKeyBase64: base64.StdEncoding.EncodeToString(skBytes), + PublicKeyBase64: public.PublicKeyBase64, + } + return secret, public, nil +} + +// EncryptVote encrypts approve as 1 and reject as 0. The readable context and +// ballot token are reduced to domain-separated commitments before serialization. +func EncryptVote(public PublicKeyFile, context, ballotToken string, approve bool) (Ballot, error) { + params, pk, err := loadPublicKey(public) + if err != nil { + return Ballot{}, err + } + contextID, err := commitment("draftcat:fhe-vote:context:v1", context) + if err != nil { + return Ballot{}, fmt.Errorf("context: %w", err) + } + ballotID, err := commitment("draftcat:fhe-vote:ballot:v1", ballotToken) + if err != nil { + return Ballot{}, fmt.Errorf("ballot token: %w", err) + } + values := make([]uint64, params.MaxSlots()) + if approve { + values[0] = 1 + } + values[1] = 1 + plaintext := bgv.NewPlaintext(params, params.MaxLevel()) + if err := bgv.NewEncoder(params).Encode(values, plaintext); err != nil { + return Ballot{}, fmt.Errorf("encode vote: %w", err) + } + ciphertext, err := rlwe.NewEncryptor(params, pk).EncryptNew(plaintext) + if err != nil { + return Ballot{}, fmt.Errorf("encrypt vote: %w", err) + } + encoded, err := ciphertext.MarshalBinary() + if err != nil { + return Ballot{}, fmt.Errorf("encode ciphertext: %w", err) + } + return Ballot{ + Schema: BallotSchema, Suite: Suite, KeyID: public.KeyID, + ContextID: contextID, BallotCommitment: ballotID, + CiphertextBase64: base64.StdEncoding.EncodeToString(encoded), CiphertextBytes: len(encoded), + }, nil +} + +// Aggregate adds encrypted ballots without decrypting any one of them. +func Aggregate(public PublicKeyFile, context string, ballots []Ballot) (Tally, error) { + if len(ballots) < minBallots { + return Tally{}, fmt.Errorf("at least %d encrypted ballots are required; smaller groups do not provide meaningful vote privacy", minBallots) + } + if len(ballots) > maxBallots { + return Tally{}, fmt.Errorf("too many ballots: maximum is %d", maxBallots) + } + params, _, err := loadPublicKey(public) + if err != nil { + return Tally{}, err + } + contextID, err := commitment("draftcat:fhe-vote:context:v1", context) + if err != nil { + return Tally{}, fmt.Errorf("context: %w", err) + } + seen := make(map[string]struct{}, len(ballots)) + contributors := make([]string, 0, len(ballots)) + var total *rlwe.Ciphertext + evaluator := bgv.NewEvaluator(params, nil) + for i, ballot := range ballots { + if err := validateBallot(ballot, public.KeyID, contextID); err != nil { + return Tally{}, fmt.Errorf("ballot %d: %w", i+1, err) + } + if _, duplicate := seen[ballot.BallotCommitment]; duplicate { + return Tally{}, fmt.Errorf("ballot %d repeats ballot commitment %s", i+1, ballot.BallotCommitment) + } + seen[ballot.BallotCommitment] = struct{}{} + contributors = append(contributors, ballot.BallotCommitment) + ciphertext, err := decodeCiphertext(params, ballot.CiphertextBase64, ballot.CiphertextBytes) + if err != nil { + return Tally{}, fmt.Errorf("ballot %d: %w", i+1, err) + } + if total == nil { + total = ciphertext.CopyNew() + } else if total, err = evaluator.AddNew(total, ciphertext); err != nil { + return Tally{}, fmt.Errorf("add ballot %d: %w", i+1, err) + } + } + encoded, err := total.MarshalBinary() + if err != nil { + return Tally{}, fmt.Errorf("encode encrypted tally: %w", err) + } + return Tally{ + Schema: TallySchema, Suite: Suite, KeyID: public.KeyID, ContextID: contextID, + Ballots: len(ballots), Contributors: contributors, + CiphertextBase64: base64.StdEncoding.EncodeToString(encoded), CiphertextBytes: len(encoded), + }, nil +} + +// Decrypt opens only the aggregate and rejects malformed or impossible totals. +func Decrypt(secret SecretKeyFile, context string, tally Tally) (Result, error) { + params, sk, err := loadSecretKey(secret) + if err != nil { + return Result{}, err + } + contextID, err := commitment("draftcat:fhe-vote:context:v1", context) + if err != nil { + return Result{}, fmt.Errorf("context: %w", err) + } + if tally.Schema != TallySchema || tally.Suite != Suite { + return Result{}, errors.New("unsupported encrypted tally schema or suite") + } + if tally.KeyID != secret.KeyID { + return Result{}, errors.New("tally key ID does not match the secret key") + } + if tally.ContextID != contextID { + return Result{}, errors.New("tally context does not match") + } + if tally.Ballots < minBallots || tally.Ballots > maxBallots || len(tally.Contributors) != tally.Ballots { + return Result{}, errors.New("tally ballot count is out of range") + } + seen := make(map[string]struct{}, len(tally.Contributors)) + for _, contributor := range tally.Contributors { + if !isSHA256(contributor) { + return Result{}, errors.New("tally contains an invalid contributor commitment") + } + if _, duplicate := seen[contributor]; duplicate { + return Result{}, errors.New("tally repeats a contributor commitment") + } + seen[contributor] = struct{}{} + } + ciphertext, err := decodeCiphertext(params, tally.CiphertextBase64, tally.CiphertextBytes) + if err != nil { + return Result{}, err + } + values := make([]uint64, params.MaxSlots()) + if err := bgv.NewEncoder(params).Decode(rlwe.NewDecryptor(params, sk).DecryptNew(ciphertext), values); err != nil { + return Result{}, fmt.Errorf("decrypt tally: %w", err) + } + if values[1] != uint64(tally.Ballots) { + return Result{}, errors.New("encrypted ballot count does not match the tally metadata") + } + if values[0] > values[1] { + return Result{}, errors.New("decrypted approval count exceeds the number of ballots") + } + for _, value := range values[2:] { + if value != 0 { + return Result{}, errors.New("encrypted tally contains unexpected data outside the vote slot") + } + } + return Result{Approvals: int(values[0]), Ballots: tally.Ballots}, nil // #nosec G115 -- bounded to maxBallots above. +} + +func loadPublicKey(file PublicKeyFile) (bgv.Parameters, *rlwe.PublicKey, error) { + params, err := parameters() + if err != nil { + return bgv.Parameters{}, nil, err + } + if file.Schema != PublicKeySchema || file.Suite != Suite { + return bgv.Parameters{}, nil, errors.New("unsupported FHE public-key schema or suite") + } + encoded, err := decodeBase64(file.PublicKeyBase64, "public key") + if err != nil { + return bgv.Parameters{}, nil, err + } + if keyID(encoded) != file.KeyID { + return bgv.Parameters{}, nil, errors.New("public-key ID mismatch") + } + publicKey := rlwe.NewPublicKey(params) + if err := publicKey.UnmarshalBinary(encoded); err != nil { + return bgv.Parameters{}, nil, fmt.Errorf("decode public key: %w", err) + } + return params, publicKey, nil +} + +func loadSecretKey(file SecretKeyFile) (bgv.Parameters, *rlwe.SecretKey, error) { + params, err := parameters() + if err != nil { + return bgv.Parameters{}, nil, err + } + if file.Schema != schemaPrivate || file.Suite != Suite { + return bgv.Parameters{}, nil, errors.New("unsupported FHE secret-key schema or suite") + } + publicBytes, err := decodeBase64(file.PublicKeyBase64, "public key") + if err != nil { + return bgv.Parameters{}, nil, err + } + if keyID(publicBytes) != file.KeyID { + return bgv.Parameters{}, nil, errors.New("secret file's public-key ID mismatch") + } + secretBytes, err := decodeBase64(file.SecretKeyBase64, "secret key") + if err != nil { + return bgv.Parameters{}, nil, err + } + secretKey := rlwe.NewSecretKey(params) + if err := secretKey.UnmarshalBinary(secretBytes); err != nil { + return bgv.Parameters{}, nil, fmt.Errorf("decode secret key: %w", err) + } + return params, secretKey, nil +} + +func validateBallot(ballot Ballot, expectedKeyID, expectedContextID string) error { + if ballot.Schema != BallotSchema || ballot.Suite != Suite { + return errors.New("unsupported encrypted ballot schema or suite") + } + if ballot.KeyID != expectedKeyID { + return errors.New("ballot key ID does not match the pinned public key") + } + if ballot.ContextID != expectedContextID { + return errors.New("ballot context does not match") + } + if !isSHA256(ballot.BallotCommitment) { + return errors.New("invalid ballot commitment") + } + return nil +} + +func decodeCiphertext(params bgv.Parameters, value string, claimedBytes int) (*rlwe.Ciphertext, error) { + encoded, err := decodeBase64(value, "ciphertext") + if err != nil { + return nil, err + } + if len(encoded) != claimedBytes { + return nil, errors.New("ciphertext byte count does not match") + } + ciphertext := rlwe.NewCiphertext(params, 1, params.MaxLevel()) + if err := ciphertext.UnmarshalBinary(encoded); err != nil { + return nil, fmt.Errorf("decode ciphertext: %w", err) + } + if ciphertext.Degree() != 1 || ciphertext.Level() != params.MaxLevel() { + return nil, errors.New("ciphertext shape does not match the fixed FHE parameters") + } + return ciphertext, nil +} + +func decodeBase64(value, label string) ([]byte, error) { + if value == "" || len(value) > base64.StdEncoding.EncodedLen(maxBinaryBytes) { + return nil, fmt.Errorf("%s is empty or too large", label) + } + decoded, err := base64.StdEncoding.Strict().DecodeString(value) + if err != nil || base64.StdEncoding.EncodeToString(decoded) != value { + return nil, fmt.Errorf("invalid %s encoding", label) + } + if len(decoded) > maxBinaryBytes { + return nil, fmt.Errorf("%s exceeds %d bytes", label, maxBinaryBytes) + } + return decoded, nil +} + +func commitment(domain, value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", errors.New("value must not be empty") + } + sum := sha256.Sum256([]byte(domain + "\x00" + value)) + return hex.EncodeToString(sum[:]), nil +} + +func keyID(publicKey []byte) string { + sum := sha256.Sum256(append([]byte("draftcat:fhe-vote:key:v1\x00"), publicKey...)) + return hex.EncodeToString(sum[:]) +} + +func isSHA256(value string) bool { + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value) +} + +// Marshal returns stable, indented JSON suitable for a command-line bundle. +func Marshal(value any) ([]byte, error) { + return json.MarshalIndent(value, "", " ") +} + +// ParsePublicKey, ParseSecretKey, ParseBallot, and ParseTally reject unknown +// fields so typos do not silently change the trust boundary. +func ParsePublicKey(data []byte) (PublicKeyFile, error) { + var value PublicKeyFile + return value, strictJSON(data, &value) +} + +func ParseSecretKey(data []byte) (SecretKeyFile, error) { + var value SecretKeyFile + return value, strictJSON(data, &value) +} + +func ParseBallot(data []byte) (Ballot, error) { + var value Ballot + return value, strictJSON(data, &value) +} + +func ParseTally(data []byte) (Tally, error) { + var value Tally + return value, strictJSON(data, &value) +} + +func strictJSON(data []byte, destination any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return fmt.Errorf("decode JSON: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("decode JSON: multiple values are not allowed") + } + return fmt.Errorf("decode JSON: %w", err) + } + return nil +} diff --git a/internal/fhevote/fhevote_test.go b/internal/fhevote/fhevote_test.go new file mode 100644 index 0000000..ed09fc5 --- /dev/null +++ b/internal/fhevote/fhevote_test.go @@ -0,0 +1,147 @@ +package fhevote + +import ( + "encoding/base64" + "strings" + "testing" +) + +func TestEncryptedVotesAggregateWithoutReadableVotes(t *testing.T) { + secret, public, err := GenerateKeys() + if err != nil { + t.Fatal(err) + } + context := "invoice-4821" + ballotTokens := []string{"random-invite-alice", "random-invite-bob", "random-invite-carol"} + votes := []bool{true, false, true} + ballots := make([]Ballot, 0, len(votes)) + for i, vote := range votes { + ballot, err := EncryptVote(public, context, ballotTokens[i], vote) + if err != nil { + t.Fatalf("encrypt vote %d: %v", i, err) + } + encoded, err := Marshal(ballot) + if err != nil { + t.Fatal(err) + } + for _, private := range []string{context, ballotTokens[i], "approve", "reject"} { + if strings.Contains(string(encoded), private) { + t.Fatalf("encrypted ballot disclosed %q", private) + } + } + ballots = append(ballots, ballot) + } + + tally, err := Aggregate(public, context, ballots) + if err != nil { + t.Fatal(err) + } + result, err := Decrypt(secret, context, tally) + if err != nil { + t.Fatal(err) + } + if result.Approvals != 2 || result.Ballots != 3 { + t.Fatalf("unexpected tally: %+v", result) + } +} + +func TestAggregateRejectsWrongContextKeyAndDuplicateBallot(t *testing.T) { + _, public, err := GenerateKeys() + if err != nil { + t.Fatal(err) + } + _, otherPublic, err := GenerateKeys() + if err != nil { + t.Fatal(err) + } + ballot, err := EncryptVote(public, "release-7", "invite-1", true) + if err != nil { + t.Fatal(err) + } + ballot2, err := EncryptVote(public, "release-7", "invite-2", false) + if err != nil { + t.Fatal(err) + } + ballot3, err := EncryptVote(public, "release-7", "invite-3", true) + if err != nil { + t.Fatal(err) + } + ballots := []Ballot{ballot, ballot2, ballot3} + if _, err := Aggregate(public, "release-8", ballots); err == nil || !strings.Contains(err.Error(), "context") { + t.Fatalf("wanted context rejection, got %v", err) + } + if _, err := Aggregate(otherPublic, "release-7", ballots); err == nil || !strings.Contains(err.Error(), "key ID") { + t.Fatalf("wanted key rejection, got %v", err) + } + if _, err := Aggregate(public, "release-7", []Ballot{ballot, ballot, ballot3}); err == nil || !strings.Contains(err.Error(), "repeats ballot commitment") { + t.Fatalf("wanted duplicate rejection, got %v", err) + } + if _, err := Aggregate(public, "release-7", []Ballot{ballot, ballot2}); err == nil || !strings.Contains(err.Error(), "at least 3") { + t.Fatalf("wanted small-group rejection, got %v", err) + } +} + +func TestDecryptRejectsWrongContextAndMalformedTally(t *testing.T) { + secret, public, err := GenerateKeys() + if err != nil { + t.Fatal(err) + } + ballot, err := EncryptVote(public, "payment-9", "invite-9", true) + if err != nil { + t.Fatal(err) + } + ballot2, err := EncryptVote(public, "payment-9", "invite-10", false) + if err != nil { + t.Fatal(err) + } + ballot3, err := EncryptVote(public, "payment-9", "invite-11", true) + if err != nil { + t.Fatal(err) + } + tally, err := Aggregate(public, "payment-9", []Ballot{ballot, ballot2, ballot3}) + if err != nil { + t.Fatal(err) + } + if _, err := Decrypt(secret, "payment-10", tally); err == nil || !strings.Contains(err.Error(), "context") { + t.Fatalf("wanted context rejection, got %v", err) + } + + badLength := tally + badLength.CiphertextBytes++ + if _, err := Decrypt(secret, "payment-9", badLength); err == nil || !strings.Contains(err.Error(), "byte count") { + t.Fatalf("wanted byte-count rejection, got %v", err) + } + + missingContributor := tally + missingContributor.Contributors = missingContributor.Contributors[:2] + if _, err := Decrypt(secret, "payment-9", missingContributor); err == nil || !strings.Contains(err.Error(), "ballot count") { + t.Fatalf("wanted contributor-count rejection, got %v", err) + } + + wrongDeclaredCount := tally + wrongDeclaredCount.Ballots++ + wrongDeclaredCount.Contributors = append(wrongDeclaredCount.Contributors, strings.Repeat("0", 64)) + if _, err := Decrypt(secret, "payment-9", wrongDeclaredCount); err == nil || !strings.Contains(err.Error(), "encrypted ballot count") { + t.Fatalf("wanted encrypted-count rejection, got %v", err) + } + + badEncoding := tally + decoded, err := base64.StdEncoding.DecodeString(badEncoding.CiphertextBase64) + if err != nil { + t.Fatal(err) + } + decoded[len(decoded)/2] ^= 1 + badEncoding.CiphertextBase64 = base64.StdEncoding.EncodeToString(decoded) + if _, err := Decrypt(secret, "payment-9", badEncoding); err == nil { + t.Fatal("wanted altered ciphertext to fail decoding or aggregate validation") + } +} + +func TestStrictJSONRejectsUnknownAndMultipleValues(t *testing.T) { + if _, err := ParseBallot([]byte(`{"schema":"x","extra":true}`)); err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("wanted unknown-field rejection, got %v", err) + } + if _, err := ParseTally([]byte(`{} {}`)); err == nil || !strings.Contains(err.Error(), "multiple values") { + t.Fatalf("wanted multiple-value rejection, got %v", err) + } +} diff --git a/main.go b/main.go index d857000..5a3cf0a 100644 --- a/main.go +++ b/main.go @@ -2660,6 +2660,8 @@ func main() { os.Exit(runAuditVerify(os.Args[2:])) case "zk-receipt": os.Exit(runZKReceiptCmd(os.Args[2:])) + case "fhe-vote": + os.Exit(runFHEVoteCmd(os.Args[2:])) case "runs": os.Exit(runRunsCmd(os.Args[2:])) case "pending": @@ -2677,6 +2679,7 @@ func main() { 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 fhe-vote count encrypted approval votes without reading them") fmt.Println(" draftcat hitl verify run the hitl/v0 conformance suite against a relay") return }