crypto-vote 0.4.2

Sign and verify anonymous, double-vote-resistant ballots with linkable BLSAG ring signatures over Ristretto255 + Blake2b-512. Native CLI and WebAssembly (browser + WASI) builds.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
# cryptoVote

[![crates.io](https://img.shields.io/crates/v/crypto-vote?logo=rust&label=crates.io)](https://crates.io/crates/crypto-vote)
[![docs.rs](https://img.shields.io/docsrs/crypto-vote?logo=docsdotrs&label=docs.rs)](https://docs.rs/crypto-vote)
[![npm](https://img.shields.io/npm/v/@condorcet.vote/crypto-vote?logo=npm&label=npm)](https://www.npmjs.com/package/@condorcet.vote/crypto-vote)
[![license](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue)](LICENSE)

> [!IMPORTANT]
> **Not independently audited.** The crate is usable as it stands; two
> caveats are worth knowing before an election whose result someone has a
> real incentive to attack.
>
> - **No third-party cryptographic review has taken place.** The
>   implementation, its test surface and its threat model have been
>   reviewed by the author with AI assistance. They are backed by 71 unit
>   and integration tests, four fuzz targets, and frozen protocol vectors
>   that pin the wire format across versions — but no outside
>   cryptographer has examined them.
> - **The election-scoped key-image variant is not from a published
>   paper.** Standard BLSAG defines the linking tag as `I = x · H_p(P)`;
>   this crate uses `I_e = x · H_p(domain ‖ election_id ‖ P)` so the same
>   identity is unlinkable across separate elections. It is a small,
>   conservative modification of BLSAG, but it has **not** been formally
>   analysed in the cryptographic literature (as of writing).
>
> In practice: fine for prototypes, research, teaching, internal and
> associative ballots. For a high-stakes vote — contested outcome, funded
> or motivated adversary, legal weight — treat the two points above as
> open questions and commission a review first.

A pure-Rust cryptographic oracle for **verifiable, anonymous, double-vote-resistant** ballots.

The crate is a minimal building block that offers a small set of
operations and refuses to take part in anything else.

| # | Operation | Where it runs | Function |
|---|-----------|---------------|----------|
| A | Generate identity | Either side | `generate_identity()` |
| B | Sign a ballot | Voter's device (WebAssembly in the browser) | `sign_vote(secret, vote, election_id, ring)` |
| C | Validate a proof | Host / server | `verify_vote(vote, election_id, signature, key_image, ring)` |
| D | Prove ownership of a key image | Voter's device (prove) / anyone (verify) | `prove_ownership(secret, election_id, context)` / `verify_ownership(public, key_image, election_id, context, proof)` |

Operations A–C are the core anonymous-voting flow. **Operation D is
optional and opt-in** — it is the deliberate *inverse* of the ring
signature's anonymity. See [Proving ownership of a
vote](#proving-ownership-of-a-vote-operation-d).

## Cryptographic choices

- **Curve**: Ristretto255 (`curve25519-dalek`). Prime-order, constant-time, pure Rust.
- **Ring signature scheme**: an experimental BLSAG (Back's Linkable
  Spontaneous Anonymous Group) variant implemented locally from the
  LSAG/BLSAG equations, with election-scoped key images.
- **Hash**: Blake2b-512 (`blake2` crate). Natively 64-byte output, fed
  directly into `Scalar::from_hash`.
- **CSPRNG**: `SysRng`, which on `wasm32` delegates to `Crypto.getRandomValues`
  through the `getrandom` crate's `wasm_js` feature.

## Anti-double-vote contract

The module never tells the host whether a voter has already voted.
That decision belongs to the host. The protocol gives the host one
deterministic identifier per `(secret key, election_id)` pair — the
**key image** — which it must store and de-duplicate against:

1. Retrieve the key image returned by `sign_vote`.
2. Look it up in the host's storage.
3. Reject the transaction if it already exists.
4. Otherwise ask `verify_vote` whether the proof is mathematically valid,
   and on success store the key image.

### Election binding

The key image is a function of both the secret key and the
`election_id`. The same voter using the same secret key twice in one
election produces the same tag, so double voting is detectable; the
same voter using that key in another election produces a different tag,
so public proofs are not linkable across elections just by comparing
key images.

Every call to `sign_vote` and `verify_vote` also mixes the same
`election_id` byte string into the BLSAG challenge chain. The host
should pass a stable per-election identifier (UUID, slug, hash of the
event configuration, …) on both sides.

This gives two election-context checks:

- The **key image** is itself scoped by `election_id`.
- The **signature** itself only validates against the `election_id`
  it was produced with.

The library normalises `election_id` to **Unicode NFC** before
hashing, on both the signing and the verification side. Callers can
therefore pass the same logical identifier in any Unicode form
(NFC, NFD, mixed) without breaking verification — the typical case
where a server stores the ID in one normalisation and the voter's
page receives it in another no longer silently invalidates every
ballot. ASCII identifiers are NFC by definition, so UUIDs and slugs
are unaffected.

### Ring size and the anonymity set

The cryptography guarantees the verifier cannot tell **which** member of
the ring produced a given signature — but the anonymity set is exactly
the ring. A ring of size *n* gives a `1/n` chance of guessing the signer
uniformly at random, and **no more**:

- **n = 2**: the protocol still validates, but the "anonymity" is
  binary — every ballot leaks down to "voter A or voter B". The
  library accepts it because cryptographically it is sound, not
  because it is privacy-meaningful. Treat it as a debugging
  configuration, not a production one.
- **n < 8**: real-world side-channels (registration order, login
  timing, IP correlation on the host) usually let an observer narrow
  the set further. Treat anything below 8 as practically
  de-anonymising.
- **n ≥ 16**: a reasonable floor for a real ballot. Larger rings cost
  more (`O(n)` for both signing and verification, plus signature size
  of `32 * (1 + n)` bytes), so pick the largest ring your latency
  budget allows.

The minimal `n = 2` floor is enforced inside the library; **picking a
useful n is the host's responsibility**.

### Ring lifecycle: freeze before voting opens

The full set of authorised public keys (the "ring") is mixed into the
hash chain of every signature, the same way `election_id` and the
ballot bytes are. As a consequence, the ring **must be frozen before
the first ballot is cast** and stay composition-identical for the
whole duration of the election. In practice:

- **All voter identities must be generated and registered before
  voting opens.** The natural moment is during the election's
  enrolment window, which closes when voting opens.
- Once voting opens, the host serves the same ring to every voter and
  uses that same ring at `verify_vote` time. Adding, removing, or
  swapping a member instantly invalidates every signature produced
  under the previous ring — there is no migration path.
- Ring **order** does not matter (both sides canonicalise it
  internally by sorting lexicographically on the compressed point
  encoding), so the host is free to return it in any order over the
  wire. Only the *set* of members matters.
- If a voter is added later, treat it as a new election: new
  `election_id`, fresh ring, fresh key-image store. Ballots from the
  old election remain verifiable as long as the host keeps a snapshot
  of the old ring.

For the same reason, the host should persist the ring it used to
verify each ballot (or at least the election's frozen ring) alongside
the ballot itself, so audits later on can rerun `verify_vote`
deterministically.

## Proving ownership of a vote (Operation D)

The ring signature deliberately hides *which* authorised voter cast a
ballot. Operation D is the **opt-in inverse**: it lets the holder of a
secret key prove to a third party that a given key image — and therefore
the ballot next to it in the public registry — is theirs, **without
revealing the secret key**.

The intended use case is **mandated / proxy voting**: a voter (or a
mandate-holder voting on someone's behalf) must be able to demonstrate,
after the fact, how a ballot was cast.

```rust
use crypto_vote::{
    generate_identity, sign_vote, generate_nonce, prove_ownership, verify_ownership,
};

let voter   = generate_identity();
let other   = generate_identity();
let ring    = vec![voter.public_key, other.public_key];
let election_id = "550e8400-e29b-41d4-a716-446655440000";

// The voter casts a ballot as usual.
let vote = sign_vote(&voter.secret_key, b"option-A", election_id, &ring).unwrap();

// Later, a verifier (possibly external to the election) generates a fresh
// nonce and sends it to the voter, who proves the registry's key image is
// theirs. `context` is opaque bytes — here, the nonce's raw bytes.
let nonce = generate_nonce();
let proof = prove_ownership(&voter.secret_key, election_id, nonce.as_bytes());

// The verifier checks it with public data only — the voter's public key,
// the key image from the registry, the election id and the same nonce.
assert!(verify_ownership(
    &voter.public_key,
    &vote.key_image,
    election_id,
    nonce.as_bytes(),
    &proof,
));
```

### What it is

A non-interactive **Chaum–Pedersen proof of equality of discrete
logarithms**. The key image is `I = x·B`, where `B = H_p(election_id || P)`
is the same election-scoped base the key image was built from and
`P = x·G` is the voter's public key. The proof demonstrates knowledge of
a single scalar `x` satisfying **both** `P = x·G` and `I = x·B` — which
only the true owner of `I` knows — while revealing nothing else about `x`.
On the wire it is 64 bytes (`own_<128 hex>_<8 hex checksum>` in the
prefixed format), independent of the ring size.

### The verifier can be anyone, with their own nonce

Verification needs **only public data**: the voter's public key, the key
image, the `election_id` and the proof. No cooperation from the
organisers and no secret are required, so a party completely external to
the election can check a proof on its own.

Everything the verifier wants the proof bound to goes into `context`. The
recommended pattern is a **verifier-chosen nonce**:

1. the verifier picks a fresh random nonce and sends it to the prover;
2. the prover calls `prove_ownership` with `context = nonce` (the service
   may also append the ballot bytes);
3. the verifier checks with the same `context`.

The nonce is folded into the proof's challenge, so the prover could not
have precomputed it — the proof is **fresh** and cannot be replayed.

`generate_nonce()` is provided as a verifier-side convenience: it returns
a `Nonce` (32 fresh bytes) from the same platform CSPRNG (`SysRng`; Web
Crypto in the browser). The organisation requesting the proof calls it,
sends the nonce to the prover, and both pass its bytes as `context`. Using
it is optional — any unpredictable, single-use value works.

Like every other public value, a `Nonce` has the full encoding surface,
including the self-describing, checksum-protected prefixed form
(`nonce_<hex>_<checksum>`). On the high-level bindings the nonce travels
**exclusively** in that form (validated on the way in), exactly like keys,
signatures and the proof itself — the prefix is transport only, the proof
binds the nonce's raw bytes. The pure-Rust API stays fully general: it
takes opaque `context: &[u8]`, so a richer context (e.g. the nonce with
the ballot bytes appended) is always available there.

### Properties and limits

- **No secret key is revealed.** The proof is zero-knowledge for `x`.
- **Sound.** A third party cannot prove ownership of a key image that is
  not theirs (they would have to know the matching secret scalar).
- **De-anonymising by design.** Producing a proof intentionally ties
  `P ↔ I`; it is the opt-in opposite of the ring signature's anonymity.
- **Transferable, *not* designated-verifier.** A Chaum–Pedersen proof is
  publicly checkable, so the nonce gives *freshness* but **not**
  non-transferability: whoever holds `(context, proof)` can convince
  anyone else too. For ordinary mandate scenarios this is fine. If you
  need a proof that convinces *only* the designated verifier, you need a
  different (designated-verifier) construction — this crate does not
  provide one.
- **It does not consult any registry.** `verify_ownership` answers exactly
  "does the holder of this public key vouch for this key image, under this
  election and context?" Tying the key image to a specific ballot is the
  registry's job (the vote signature binds ballot + key image, and the
  host de-duplicates on the key image); the caller does that lookup.
- **Coercion note.** This makes the "prove how I voted" capability
  concrete and transferable. It is the right tool for mandated voting, but
  by the same token it means a coercer who can compel a proof (or the
  secret key) can learn how someone voted. This is an inherent property of
  *verifiable* receipts, not a flaw in the proof.

### From JavaScript / Extism

Every value crossing these boundaries — including the nonce and the proof
— uses the prefixed, checksum-protected form (`nonce_…`, `own_…`, `pk_…`,
`ki_…`), validated on input.

- **wasm-bindgen:** `generate_nonce_wasm()` returns a fresh prefixed nonce
  string (`nonce_…`); `prove_ownership_wasm(secret, electionId, nonce)`
  returns the prefixed `own_…` proof; `verify_ownership_wasm(publicKey,
  keyImage, electionId, nonce, proof)` returns a `bool`.
- **Extism:** `generate_nonce` takes no input and returns
  `{"nonce": nonce_…}`; `prove_ownership` takes
  `{"secret": sk_…, "election_id": str, "nonce": nonce_…}` and returns
  `{"proof": own_…}`; `verify_ownership` takes
  `{"public": pk_…, "key_image": ki_…, "election_id": str, "nonce": nonce_…, "proof": own_…}`
  and returns `{"valid": bool}`.

For a custom or binary context (e.g. nonce + ballot bytes), use the
pure-Rust API, which takes opaque `context: &[u8]`.

## Build matrix

The release workflow produces eight artefacts; the same commands work
locally. Pick the triple/flavour that matches your need, install its
one-off prerequisite, then run the matching build command. Edition 2024
implies Rust ≥ 1.85; CI tracks `stable`.

| Triple — flavour | Build host | One-off setup | Output |
|---|---|---|---|
| `x86_64-unknown-linux-gnu` | Linux x64 | system C linker (any dev box has one) | dynamic ELF, ~700 KB |
| `x86_64-unknown-linux-musl` | Linux x64 | `musl-tools` (provides `musl-gcc`) | **static ELF**, ~800 KB |
| `aarch64-unknown-linux-gnu` | Linux x64 or arm | `gcc-aarch64-linux-gnu` cross-toolchain | dynamic ELF |
| `aarch64-unknown-linux-musl` | Linux x64 or arm | none — uses `rust-lld` | **static ELF** |
| `riscv64gc-unknown-linux-gnu` | Linux x64 or arm | `gcc-riscv64-linux-gnu` cross-toolchain | dynamic ELF |
| `aarch64-apple-darwin` | macOS arm (M-series) | Xcode Command Line Tools | Mach-O |
| `wasm32-unknown-unknown` — wasm-bindgen | any | `cargo install wasm-pack` | ES-module bundle for browsers |
| `wasm32-wasip1`**Extism plugin** | any | none — uses `rust-lld` | single `.wasm` for every Extism host SDK |

> Install commands below are **Debian/Ubuntu** (`apt`); adapt to your
> distribution (`dnf`, `pacman`, `zypper`, `brew`, …) or use
> [`cross`]https://github.com/cross-rs/cross for a Docker-based path
> that needs nothing on the host. CI runs on `ubuntu-latest`, which is
> why the upstream pipeline uses apt.

### Build commands

Standard template — works as-is for `x86_64-unknown-linux-gnu`,
`x86_64-unknown-linux-musl` (after `apt install musl-tools`) and
`aarch64-apple-darwin` (on a macOS host):

```bash
cargo build --release --locked --target <TRIPLE> --bin cryptovote
# → target/<TRIPLE>/release/cryptovote
```

Three cross-Linux rows need a linker selector — set it in the
environment, then run the same command:

```bash
# aarch64-unknown-linux-gnu  (after `apt install gcc-aarch64-linux-gnu`)
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc

# aarch64-unknown-linux-musl  (no apt package needed)
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld

# riscv64gc-unknown-linux-gnu  (after `apt install gcc-riscv64-linux-gnu`)
export CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER=riscv64-linux-gnu-gcc
```

Browser WASM uses `wasm-pack` (which wraps `cargo build` and emits JS
glue alongside the `.wasm`). The `wasm-release` profile is the
WebAssembly-tuned variant defined in `Cargo.toml`: same size knobs as
`release`, plus `panic = "abort"` (see Cargo.toml's `[profile.release]`
comment for why panic strategy is kept out of the default release
profile):

```bash
wasm-pack build --profile wasm-release --target web --out-dir pkg-browser \
    -- --no-default-features --features wasm --locked
# → pkg-browser/{crypto_vote.js, crypto_vote_bg.wasm, …}
```

`--target web` emits a self-contained ES module you can `import` from a
static page, a bundler (Vite, Rollup, esbuild), Bun, or Node — it
instantiates the WASM itself via an async `init()`, so no bundler plugin
is required anywhere. This is the build published to npm.
`--no-default-features` drops `clap`; `--features wasm` enables
`wasm-bindgen`, `js-sys`, and the `getrandom/wasm_js` backend so
randomness comes from `Crypto.getRandomValues`.

Server-side WASM uses the Extism flavour — the same `.wasm` is then
loadable by every Extism host SDK (browser, Node, Python, Go, Rust, …).
`getrandom` autoselects WASI's `random_get` on this target, so no extra
backend wiring is needed:

```bash
cargo build --profile wasm-release --locked --target wasm32-wasip1 --lib --no-default-features --features extism
# → target/wasm32-wasip1/wasm-release/crypto_vote.wasm   (~360 KB)
```

See [Extism plugin](#extism-plugin) for the host-side usage from JS,
Node, and other Extism SDKs.

## Using the library

```rust
use crypto_vote::{generate_identity, sign_vote, verify_vote};

let alice   = generate_identity();
let bob     = generate_identity();
let charlie = generate_identity();

let ring        = vec![alice.public_key, bob.public_key, charlie.public_key];
let election_id = "550e8400-e29b-41d4-a716-446655440000"; // any stable string (UUID, slug, …)

// Bob signs "option-A" — `bob.secret_key` never leaves Bob's device.
let proof = sign_vote(&bob.secret_key, b"option-A", election_id, &ring).unwrap();

// The host: first dedup `proof.key_image`, then verify.
assert!(verify_vote(
    b"option-A",
    election_id,
    &proof.signature,
    &proof.key_image,
    &ring,
));
```

## Encoding formats

Every value the API exchanges as text — public keys, secret keys, key
images, signatures — has **two interchangeable string encodings**. They
carry the exact same bytes; nothing about the cryptography changes
between them.

| Format | Example | Methods |
|---|---|---|
| **Bare hex** | `e2f2ae0a…2d76` | `to_hex()` / `from_hex(..)` |
| **Prefixed** | `pk_e2f2ae0a…2d76_bfb1c73d` | `to_prefixed()` / `from_prefixed(..)` |

The **prefixed** format wraps the same lowercase hex body with two
conveniences:

```text
  pk_e2f2ae0a…e08d2d76_bfb1c73d
  │  │                 │
  │  │                 └ checksum: 4 bytes (8 hex chars)
  │  └ body: identical to to_hex()
  └ tag: pk | sk | ki | blsag
```

- a **tag** up front (`pk_`, `sk_`, `ki_`, `blsag_`) says what kind of
  value it is, so a public key pasted where a key image was expected is
  caught immediately instead of failing deep in verification;
- a **checksum** at the end (a 4-byte BLAKE3 digest, hex-encoded) catches
  the overwhelming majority of single-character typos, transpositions and
  truncated pastes. The tag is folded into the checksum, so relabelling a
  `pk_…` value as `ki_…` is also detected.

The checksum is an **integrity / typo guard only — never a security
primitive**. Anyone can compute a valid checksum for any bytes;
authenticity comes solely from the BLSAG proof.

| Type | Tag | `from_prefixed` signature |
|---|---|---|
| `PublicKey` | `pk_` | `PublicKey::from_prefixed(s)` |
| `SecretKey` | `sk_` | `SecretKey::from_prefixed(s)` |
| `KeyImage` | `ki_` | `KeyImage::from_prefixed(s)` |
| `Signature` | `blsag_` | `Signature::from_prefixed(s, ring_size)` |

```rust
let id = crypto_vote::generate_identity();
let pretty = id.public_key.to_prefixed();        // "pk_…_…"
assert!(pretty.starts_with("pk_"));
let back = crypto_vote::PublicKey::from_prefixed(&pretty).unwrap();
assert_eq!(back, id.public_key);

// Wrong tag is rejected even though the bytes would decode fine as hex:
assert!(crypto_vote::KeyImage::from_prefixed(&pretty).is_err());
```

`from_prefixed` returns `Error::InvalidPrefix` (missing/wrong tag) or
`Error::InvalidChecksum` (mistyped or corrupted value), in addition to
the usual length / encoding errors.

**The WASM, Extism and CLI front ends speak the prefixed format
*exclusively*** — it is all they emit and all they accept; bare hex is
rejected at those boundaries. The bare-hex `to_hex`/`from_hex` pair is
reserved for the **pure-Rust library API**, where the caller is trusted
to know what it is decoding. This keeps every value that crosses a
high-level boundary self-describing and checksum-protected.

## Private key format and properties

A secret key is a **uniformly random scalar in the Ristretto255 scalar
field** — mathematically an integer in `[1, ℓ)`, where ℓ is the prime
order of the group (`ℓ = 2^252 + 27742317777372353535851937790883648493`,
≈ 2²⁵². So roughly 2²⁵² distinct keys exist). The crate ships two
interchangeable encodings; pick the one that matches your transport:

| Encoding | Shape | Where it's used |
|---|---|---|
| **Raw bytes** | `[u8; 32]` little-endian | `SecretKey::{to,from}_bytes`, `sign_vote_*_wasm` and `is_valid_secret_key_wasm` secret inputs |
| **Hex** | 64 lowercase characters | `SecretKey::{to,from}_hex` — pure-Rust library API only |
| **Prefixed** | `sk_<64 hex>_<8 hex>` | `SecretKey::{to,from}_prefixed`, Extism plugin, CLI (see [Encoding formats]#encoding-formats) |

All three encodings carry the same value bit-for-bit. The prefixed form
is the **only** one the Extism and CLI front ends accept (bare hex is
rejected there — it is reserved for the pure-Rust library API); raw
bytes is the only
format the wasm-bindgen flavour accepts for secret material, because a
`Uint8Array` is mutable and the JS caller can wipe it with `.fill(0)`
— a JS `String` (and therefore a hex string) cannot be erased from the
heap on demand.

### Validity rules

Every parser (`SecretKey::from_bytes`, `from_hex`, and the WASM /
Extism entry points) enforces three checks. A key that fails any of
them is rejected, never silently coerced:

1. **Length.** Exactly 32 decoded bytes — `Error::InvalidLength`.
   Hex input must therefore be 64 characters.
2. **Canonical encoding modulo ℓ.** The 32 bytes must decode to a
   scalar in `[0, ℓ)``Error::InvalidScalar`. The library refuses
   any "non-reduced" encoding (e.g. a value ≥ ℓ but < 2²⁵⁶), because
   that would let two different byte strings designate the same key
   — a classic malleability footgun.
3. **Non-zero.** The zero scalar is rejected — `Error::InvalidSecretKey`.
   Its derived public key would be the Ristretto identity point, which
   the protocol cannot use as a participant (and which `PublicKey`
   independently refuses with `Error::InvalidIdentityPoint`).

### Cryptographic properties

- **The public key is fully determined by the secret key.** Internally
  `public_key = secret_key · G` where `G` is the Ristretto255 base
  point. Round-tripping a secret through `to_bytes``from_bytes`
  derives the **same** public key, so you only need to persist the
  32-byte secret — the public key (and therefore the ring entry) can
  always be re-derived. There is no separate key-schedule, clamping,
  or hashing step applied to the secret.
- **Same key, same ring, same election ⇒ same key image.** This is
  what makes double-vote detection possible. The key image is
  `I_e = x · H_p(domain ‖ election_id ‖ x · G)`, so it is fully
  deterministic in `(secret_key, election_id)` and changes between
  elections (see [Election binding]#election-binding).
- **Entropy.** `generate_identity` samples from `SysRng`  `getrandom(2)` / `getentropy(2)` / `BCryptGenRandom` natively,
  `Crypto.getRandomValues` in the browser. Any uniform value in
  `[1, ℓ)` is a valid secret. **Never** derive a secret from a user
  password without a strong KDF (Argon2id / scrypt) producing 32
  uniform bytes — the protocol's anonymity rests on the secret being
  unguessable.
- **Memory hygiene.** Inside Rust the scalar lives in a `SecretKey`
  whose `Drop` impl calls `Zeroize`. Every transient buffer that
  touches the secret on the WASM / Extism boundary is wrapped in
  `Zeroizing`. The JS-side `Uint8Array` is the caller's
  responsibility — see [Operation A]#operation-a--generate-an-identity
  for the wipe pattern. The hex form gives up that last step (a JS
  `String` is immutable), which is why the wasm-bindgen flavour keeps
  the secret as bytes.

### Validating a secret key without constructing one

Reading a stored secret back and want a quick "is this still a usable
key?" check before doing anything else? Two zero-cost helpers apply
the three rules above and return a plain `bool`:

```rust
use crypto_vote::SecretKey;

let bytes: [u8; 32] = load_from_disk();
if !SecretKey::is_valid_bytes(&bytes) {
    return Err("stored secret is corrupted");
}

assert!(SecretKey::is_valid_hex("aabbccdd…"));        // canonical, non-zero
assert!(!SecretKey::is_valid_hex("not hex"));          // bad encoding
assert!(!SecretKey::is_valid_hex(&"00".repeat(32)));   // zero scalar

// Same check for the prefixed form (tag + checksum must also be valid):
assert!(SecretKey::is_valid_prefixed("sk_aabbccdd…_1a2b3c4d"));
```

These are the same checks performed implicitly at the start of every
`sign_vote` call, so calling them up-front is purely a convenience —
useful for surfacing a clear UI message before the user has filled in
the rest of the form. The WASM and Extism layers expose the same
helper (see below).

## Using the command line

```bash
# Generate an identity (one per voter). Output uses the prefixed format.
$ cryptovote keygen
secret=sk_…_…
public=pk_…_…

# Sign a ballot. `ring.txt` is one prefixed public key (`pk_…`) per
# line. Keep the secret in a file or pass `--secret -` to read it from
# stdin.
$ cryptovote sign --secret-file secret.key --vote "option-A" \
    --election-id "election-2026-05" --ring ring.txt
signature=blsag_…_…
key_image=ki_…_…

# Verify. Exit code 0 = valid, 1 = invalid, 2 = bad input. The
# --signature / --key-image values are prefixed (`blsag_…`, `ki_…`).
$ cryptovote verify --vote "option-A" --election-id "election-2026-05" \
    --signature blsag_…_… --key-image ki_…_… --ring ring.txt
valid
```

Every key / signature argument is the prefixed form (`pk_…`,
`sk_…`, `ki_…`, `blsag_…`); bare hex is rejected. The CLI always prints
the prefixed form.

## Vote payload format

The library treats the vote as **opaque bytes** — `sign_vote` and
`verify_vote` both take `&[u8]`, with no size limit and no parsing.
JSON, Protobuf, raw text, or arbitrary binary all work the same way.
The WASM and Extism bindings expose **two entry points** per operation
to cover this: a *text* one (`vote` as a UTF-8 string) and a *binary*
one (`vote` as a `Uint8Array` in WASM, hex-encoded in Extism). Both
funnel into the same `&[u8]` core, so a ballot signed through one is
verifiable through any of them as long as the bytes match. See those
sections below.

**The contract for the host:** what you sign is what you store is what
you verify, byte-for-byte. If the host re-encodes the payload between
receiving it and verifying it — JSON re-serialisation, BOM stripping,
line-ending normalisation, Unicode normalisation, lowercasing,
anything — verification *will* fail, because Blake2b is sensitive to
every single byte. The safe rule is: persist the raw bytes received
from the voter, and hand those same bytes back to `verify_vote`.

Note that the vote payload is treated as *raw bytes* and is never
normalised by the library — unlike `election_id`, which is forced to
NFC. The asymmetry is deliberate: the election ID is a *label* the
host controls and re-emits across encodings, so normalising it makes
the API robust; the vote is *content* the host stores verbatim, so
normalising it would silently change what was signed.

### Large or structured payloads in the browser

There are two signing entry points. Pick by ballot type:

- **`sign_vote_str_wasm(secret, voteStr, electionId, ring)`**`vote`
  is a string. Use it for text ballots (a label, a stringified JSON
  ballot). Most common.
- **`sign_vote_bytes_wasm(secret, voteBytes, electionId, ring)`**  `vote` is a `Uint8Array`. Use it for binary ballots (raw Protobuf,
  any non-UTF-8 bytes).

The natural pattern for a JSON ballot is to serialise it once on the
voter's device and pass that exact string everywhere afterwards:

```js
const ballot     = JSON.stringify(form);                   // a string
const electionId = "550e8400-e29b-41d4-a716-446655440000"; // plain JS string
const [sigHex, tagHex] = sign_vote_str_wasm(
    secretBytes, ballot, electionId, ringHex,
);
// `secretBytes` is the `Uint8Array` returned by `generate_identity_wasm`
// (or read back from your store). Wipe it with `secretBytes.fill(0)`
// as soon as you no longer need it in memory.

// Send `ballot` (the same string), `sigHex` and `tagHex` to the host.
// The host stores `ballot` verbatim — it must never JSON.parse +
// JSON.stringify the payload, or the re-serialised string may differ
// byte-for-byte and verification will fail.

// Binary ballot? Sign the exact bytes and store them verbatim instead:
//   const [sigHex, tagHex] = sign_vote_bytes_wasm(
//       secretBytes, ballotBytes /* Uint8Array */, electionId, ringHex,
//   );
```

### Large or structured payloads on the CLI

The `--vote` flag accepts `-` to read the ballot from standard input.
Use it for anything that exceeds your shell's argv limit, contains
newlines, or is binary:

```bash
cat ballot.json | cryptovote sign \
  --secret-file secret.hex --vote - --election-id "election-2026-05" --ring ring.txt

cat ballot.json | cryptovote verify --vote - \
    --election-id "election-2026-05" \
    --signature <hex> --key-image <hex> --ring ring.txt
```

## Using from JavaScript / WebAssembly

The library exposes a thin `wasm-bindgen` layer behind the `wasm`
feature. The npm package and a self-built bundle are the **same**
`--target web` artefact: a self-contained ES module that instantiates
the WASM itself through an async `init()` default export. It works
the same everywhere — a bundler (Vite, Rollup, esbuild), Bun, Node, or
a plain `<script type="module">` — with **no bundler plugin required**.
You call `await init()` exactly once.

### Install from npm

```bash
npm install @condorcet.vote/crypto-vote
```

The package is published to [npmjs.com](https://www.npmjs.com/package/@condorcet.vote/crypto-vote)
on every release:

```js
import init, {
    generate_identity_wasm,
    derive_public_key_wasm,
    sign_vote_str_wasm,    // text ballot
    sign_vote_bytes_wasm,  // binary ballot (Uint8Array)
    verify_vote_str_wasm,
    verify_vote_bytes_wasm,
    is_valid_secret_key_wasm,
    secret_key_from_prefixed_wasm,      // import sk_… string → bytes
    secret_key_to_prefixed_wasm,        // export bytes → sk_… string
    is_valid_prefixed_secret_key_wasm,  // validate an sk_… string
} from "@condorcet.vote/crypto-vote";

// Instantiate the WASM once. A bundler resolves the .wasm asset for you;
// no plugin needed. Then every function is ready to call.
await init();
const [secretBytes, publicKey] = generate_identity_wasm();
```

> Why `--target web` and not `--target bundler`? The `bundler` target
> relies on the non-standard WebAssembly/ESM-integration that only
> webpack implements, so it breaks under Vite (needs `vite-plugin-wasm`)
> and Bun (treats the `.wasm` as a static asset). `--target web` is the
> portable choice.

### Build from source

See the [Build matrix](#build-matrix) above for the full command and
flag breakdown. The short version, run from the crate root:

```bash
wasm-pack build --profile wasm-release --target web -- --no-default-features --features wasm
```

The resulting `pkg/` directory contains the `.wasm` artefact and the
JS glue — the same files the npm package ships. Copy it next to your
page or import it through your bundler.

### Initialise the module

`await` the default export exactly once before calling anything else;
it streams and instantiates the `.wasm` file. This applies to both the
npm package and a self-built bundle:

```js
import init, {
    generate_identity_wasm,
    derive_public_key_wasm,
    sign_vote_str_wasm,    // text ballot
    sign_vote_bytes_wasm,  // binary ballot (Uint8Array)
    verify_vote_str_wasm,
    verify_vote_bytes_wasm,
    is_valid_secret_key_wasm,
    secret_key_from_prefixed_wasm,
    secret_key_to_prefixed_wasm,
    is_valid_prefixed_secret_key_wasm,
} from "@condorcet.vote/crypto-vote"; // or "./pkg/crypto_vote.js" when self-built

await init();
```

### Operation A — generate an identity

```js
// The secret comes back as a `Uint8Array` (32 raw bytes); the public
// key as a prefixed string ("pk_…_…"). The asymmetry is deliberate: a JS
// string is immutable, so once a secret has been turned into one it
// cannot be erased from the JS heap until the GC eventually collects it.
// A `Uint8Array` is a mutable buffer the caller can wipe explicitly with
// `.fill(0)` once the secret has been used or persisted.
const [secretBytes, publicKey] = generate_identity_wasm();

// 1. Persist `secretBytes` wherever you want it to live across
//    sessions. The library does not care which store you pick.
await persistVoterSecret(secretBytes);

// 2. Register the public key with the host.
await fetch("/api/register", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ public_key: publicKey }),
});

// 3. Wipe the in-memory copy. After this point the only readable
//    instance of the secret is whatever your `persistVoterSecret`
//    produced — nothing is sitting around in the JS heap.
secretBytes.fill(0);
```

> **Note on the threat model.** Wiping the JS-side `Uint8Array` does
> not protect against XSS / a malicious script running *while the
> secret is still loaded*. What it does protect against is post-hoc
> memory inspection: core dumps, devtools snapshots taken later, swap
> partitions, extensions that scan page memory periodically. The
> window of exposure is reduced to the smallest interval the caller
> can manage. The library does its share by `Zeroizing` every
> Rust-side copy of the secret automatically.

### Validating a stored secret key

`is_valid_secret_key_wasm` runs the same canonical-encoding and
non-zero-scalar checks the `sign_vote_*_wasm` functions apply internally, but
without producing anything — useful for surfacing a clear UI error
before the voter has filled in the rest of the form. The input is
wrapped in `Zeroizing` on the Rust side; the caller still owns
wiping its own `Uint8Array` afterwards.

```js
const secretBytes = await loadVoterSecret();
if (!is_valid_secret_key_wasm(secretBytes)) {
    secretBytes.fill(0);
    throw new Error("stored secret is corrupted or invalid");
}
// secretBytes is safe to feed into the sign_vote_*_wasm functions.
```

Returns `false` (never throws) on any malformed input: wrong length,
non-canonical encoding, or the zero scalar.

### Recovering a public key from a stored secret

`derive_public_key_wasm` re-derives the public key from a 32-byte secret key.
The derivation is a pure scalar multiplication — no randomness — so the same
secret always yields the same public key. Useful when a caller has persisted
the secret key but needs to recover or re-display the corresponding public key
(e.g. to re-register after a device migration, or to display a voter's ring
entry in the UI without storing the public key separately).

The input is wrapped in `Zeroizing` on the Rust side; wipe the JS-side
`Uint8Array` with `.fill(0)` once the call returns.

```js
const secretBytes = await loadVoterSecret();
let publicKey;
try {
    // Returns the prefixed public key ("pk_…_…"), or throws on malformed
    // input (wrong length, non-canonical encoding, zero scalar).
    publicKey = derive_public_key_wasm(secretBytes);
} finally {
    secretBytes.fill(0);
}
```

### Importing / exporting a secret key in the prefixed format

The wasm-bindgen flavour handles secret material as a `Uint8Array` so the
JS caller can wipe it with `.fill(0)`. But a voter who wants to **back up
or re-import** their key works with the human-friendly prefixed string
(`sk_<hex>_<checksum>`). These two functions bridge the gap:

- `secret_key_from_prefixed_wasm(str)`**import**: validates the `sk_`
  tag, the checksum and the canonical-scalar rule, then returns the
  32-byte `Uint8Array` the rest of the API consumes. Throws a clear error
  (wrong prefix, mistyped checksum, non-canonical scalar) instead of
  yielding a silently-wrong key.
- `secret_key_to_prefixed_wasm(bytes)`**export**: turns a raw
  `Uint8Array` (e.g. the `secretBytes` from `generate_identity_wasm`) into
  the `sk_…_…` string to show or download.
- `is_valid_prefixed_secret_key_wasm(str)`**validate only**: `true` /
  `false` for live form feedback, never throws.

```js
// --- Import: the voter pastes their backup string into a form ---
const pasted = form.secretKey.value.trim(); // "sk_…_…"

if (!is_valid_prefixed_secret_key_wasm(pasted)) {
    throw new Error("That doesn't look like a valid secret key.");
}

let secretBytes;
try {
    secretBytes = secret_key_from_prefixed_wasm(pasted); // → Uint8Array(32)
    // …persist it, then use it to sign…
    const [signature, keyImage] =
        sign_vote_str_wasm(secretBytes, "option-A", electionId, ring);
} finally {
    secretBytes?.fill(0);
}

// --- Export: let the voter back up a freshly generated key ---
const [freshBytes, publicKey] = generate_identity_wasm();
try {
    const backup = secret_key_to_prefixed_wasm(freshBytes); // "sk_…_…"
    showDownload(backup); // a String can't be .fill(0)'d — show it, then drop it
} finally {
    freshBytes.fill(0);
}
```

A `String` cannot be wiped from JS memory the way a `Uint8Array` can, so
only call the **export** function at the moment you actually display or
download the backup, and let the reference go out of scope right after.

### Operation B — sign a ballot

```js
// 1. Serialise the ballot ONCE into a string. This exact string is
//    what gets signed, what you send to the host, and what the host
//    must store verbatim. Do not re-stringify on the host —
//    verification compares the UTF-8 bytes of the string.
const ballot = JSON.stringify({ choice: "option-A" });

// 2. Election context the host has told the page about.
const electionId = "550e8400-e29b-41d4-a716-446655440000";

// 3. The full authorised ring — one entry per authorised voter,
//    including the current one. Each entry is a prefixed public key
//    ("pk_…_…", what `generate_identity_wasm` returns); bare hex is not
//    accepted at the WASM boundary. The module canonicalises the order
//    internally, so the server can return them in any order
//    (registration order, sorted, whatever):
//
//        GET /api/election/ring
//        200 OK
//        Content-Type: application/json
//        [
//          "pk_84e5498b443e3617cfa8d54d9699922e2733105f287cf5bbbe90174544875b0e_1a2b3c4d",
//          "pk_541e3d09e31ac28016ce4f652f591a4745920aff4103b4e3b272204812d4f153_5e6f7a8b",
//          ...
//        ]
//
//    Each entry is exactly what `PublicKey::to_prefixed()` produces.
const ring = await fetch("/api/election/ring").then(r => r.json());

// 4. Read the secret bytes back from wherever you persisted them.
//    `sign_vote_str_wasm` takes the secret as a `Uint8Array` of length
//    32. Wrap the call in try/finally so the in-memory copy of the
//    secret is wiped even on error.
const secretBytes = await loadVoterSecret();
let signature, keyImage;
try {
    // `sign_vote_str_wasm` throws on bad inputs (empty vote, empty
    // election ID, secret of the wrong length, signer not in the ring,
    // …). The Rust side wraps the incoming bytes in `Zeroizing` so the
    // WASM linear-memory copy is wiped on return. (For a binary ballot,
    // call `sign_vote_bytes_wasm` with a `Uint8Array` instead.)
    [signature, keyImage] = sign_vote_str_wasm(
        secretBytes,
        ballot,
        electionId,
        ring,
    );
} finally {
    // 5. Wipe the JS-side copy of the secret as soon as signing is
    //    done. The `Uint8Array` is the only place the secret lived in
    //    the JS heap; after `.fill(0)` it is unreadable.
    secretBytes.fill(0);
}

// 6. Send the proof + the ballot string to the host. The ballot must
//    arrive byte-for-byte unchanged, so transmit it as-is (here a
//    plain form field; a JSON body works too) and never re-serialise.
const form = new FormData();
form.append("election_id", electionId);
form.append("signature", signature);
form.append("key_image", keyImage);
form.append("ballot", ballot);

await fetch("/api/vote", { method: "POST", body: form });
```

### Operation C — verify (server-side or in a Node host)

The same WASM module works in Node.js (or Deno / Bun) for hosts that
prefer to keep verification inside a JS runtime instead of linking the
Rust crate natively:

```js
import init, { verify_vote_str_wasm } from "./pkg/crypto_vote.js";

await init();

// `ballot` is the exact string you stored verbatim when receiving the
// vote — read it back without any re-encoding or re-serialisation.
// (A binary ballot stored as bytes verifies with `verify_vote_bytes_wasm`.)
const isValid = verify_vote_str_wasm(
    ballot,
    electionId,
    signature,
    keyImage,
    ring,
);

// `verify_vote_str_wasm` never throws and never returns anything other
// than a boolean: any parse error / malformed input is just `false`.
if (!isValid) {
    return reject("invalid proof");
}
```

### Host-side checklist

Before calling `verify_vote_str_wasm` / `verify_vote_bytes_wasm`, the host should:

1. Read `key_image` from the submission and look it up in its
   per-election store. If present → reject (double vote).
2. Otherwise call the matching `verify_vote_*_wasm`. On `true`, persist `key_image`
   to the store *atomically with* recording the ballot, so a crash
   between the two cannot let a voter slip through twice.

## Extism plugin

The Extism flavour ships **one `.wasm` artefact that runs in every
Extism host SDK** — browser via `@extism/extism`, Node, Deno, Bun,
Python, Go, Rust, Java, even the standalone `extism` CLI. Same binary,
same calls, regardless of the host language. Build it with
`--features extism --target wasm32-wasip1` (see the build matrix
above); WASI provides the RNG, no extra wiring required.

### Recommended split: pick the right flavour per role

The two flavours are **not interchangeable** for secret-handling code.
The architectural split that matches their trade-offs:

| Role | Flavour | Why |
|---|---|---|
| **Voter device** (browser, signs ballots) | wasm-bindgen | Secret is exposed as a mutable `Uint8Array` that the JS caller can `.fill(0)`. Every Rust-side temporary is `Zeroizing`-wrapped. This is the only flavour built around memory hygiene for the secret. |
| **Verifier** (server, mobile app, audit tool, CLI tooling, …) | **Extism** | Verification never touches a secret — `verify_vote_str` / `verify_vote_hex` only handle public bytes (signature, key image, ring, ballot). One binary supports verifiers written in any host language. |

The Extism flavour *can* technically run the `sign_vote_*` functions
and `generate_identity`, but doing so on a voter device degrades secret
hygiene compared to wasm-bindgen — see the next subsection. For
verifiers and non-secret-handling tooling, the Extism flavour is the
right default.

### Secret-hygiene trade-off vs wasm-bindgen

Going through JSON means the secret key transits as a (prefixed) string
in the plugin's input/output buffer. Three protections that the
wasm-bindgen flavour provides are weakened or lost:

- The **Rust-side intermediate `String`** holding the parsed secret
  *is* wrapped in `Zeroizing` (its heap allocation is overwritten when
  the sign call returns). However the JSON parser's internal buffers and
  the Extism PDK's input buffer in WASM linear memory are not under
  our control.
- The **JS-side `String`** holding the secret is **immutable** — the
  host cannot call `.fill(0)` on it. It lives until V8 garbage-collects
  it (no guaranteed timing).
- The **Extism input/output buffers** in WASM linear memory may
  persist between plugin calls (Extism re-uses the instance), so the
  secret bytes can linger across calls until a future allocation
  overwrites the region.

Net effect: the Extism flavour reduces *post-hoc* memory hygiene
(core dumps, devtools snapshots taken later, swap to disk, periodic
memory scans). It does **not** change the *live* attack surface — a
script running in the same context while the secret is in memory can
read it under both flavours. If your threat model prioritises the
post-hoc class, sign on a voter device with the wasm-bindgen flavour.

### Plugin function signatures

Sign and verify each come in two flavours, differing only in how the
`vote` field is carried:

Every key / signature field below uses the prefixed form (`pk_…`,
`sk_…`, `ki_…`, `blsag_…`), both in and out — bare hex is **rejected**
at the plugin boundary (it is only accepted by the pure-Rust library
API). Below, `<pk>` etc. denote a prefixed value; `<vote>` is unaffected.
See [Encoding formats](#encoding-formats).

| Plugin function | JSON input | JSON output |
|---|---|---|
| `generate_identity` | *(empty)* | `{"secret": <sk>, "public": <pk>}` |
| `derive_public_key` | `{"secret": <sk>}` | `{"public": <pk>}` |
| `sign_vote_str` | `{"secret": <sk>, "vote": <str>, "election_id": <str>, "ring": [<pk>, …]}` | `{"signature": <blsag>, "key_image": <ki>}` |
| `sign_vote_hex` | `{"secret": <sk>, "vote": <hex>, "election_id": <str>, "ring": [<pk>, …]}` | `{"signature": <blsag>, "key_image": <ki>}` |
| `verify_vote_str` | `{"vote": <str>, "election_id": <str>, "signature": <blsag>, "key_image": <ki>, "ring": [<pk>, …]}` | `{"valid": <bool>}` |
| `verify_vote_hex` | `{"vote": <hex>, "election_id": <str>, "signature": <blsag>, "key_image": <ki>, "ring": [<pk>, …]}` | `{"valid": <bool>}` |
| `is_valid_secret_key` | `{"secret": <sk>}` | `{"valid": <bool>}` |

- **`_str`**`vote` is a plain JSON string; its UTF-8 bytes *are* the
  ballot, fed verbatim with no decoding. Ergonomic for text ballots (a
  label, a stringified JSON ballot). A JSON string only carries valid
  UTF-8, so this flavour can't represent a non-UTF-8 ballot.
- **`_hex`**`vote` is hex-encoded bytes, decoded before hashing. Use
  it for *arbitrary binary* ballots (raw Protobuf, NUL bytes, any
  non-UTF-8 sequence). This `hex` concerns only the *ballot*; the keys,
  signatures and tags on the same wire use the prefixed format.

The two are just front doors to the same byte-level operation — the
library always sees `&[u8]`. So a proof made with `sign_vote_str`
verifies under `verify_vote_hex` and vice-versa, as long as the bytes
match (signing `"oui"` with `_str` == signing `"6f7569"` with `_hex`).
This is also what lets a ballot signed by either wasm-bindgen function
verify here: only the vote bytes matter. A binary ballot signed in the
browser with `sign_vote_bytes_wasm` is verified on the server by
hex-encoding those stored bytes and calling `verify_vote_hex`.

`derive_public_key` re-derives the public key from a stored secret key —
useful when a caller has persisted only the secret and needs to recover the
corresponding ring entry. Input: `{"secret": <sk>}` (prefixed `sk_…`).
Output: `{"public": <pk>}` (prefixed). Errors (bad prefix, bad checksum,
bad hex, wrong length, zero scalar) are returned as plugin-level errors.
The Rust-side copy of the secret is wrapped in `Zeroizing`; the same
JSON-buffer caveat as `sign_vote_*` applies.

```bash
extism call crypto_vote-extism.wasm derive_public_key \
    --input '{"secret":"sk_…_…"}' --wasi
# → {"public":"pk_…_…"}
```

`is_valid_secret_key` is a pure utility that mirrors
`SecretKey::is_valid_prefixed`: it returns `{"valid": false}` (never an
error) for malformed input — bad prefix/checksum, bad hex, wrong length,
non-canonical encoding, or the zero scalar. The Rust-side copy of the secret is
wrapped in `Zeroizing`, but the same JSON-buffer caveat as the
[`sign_vote_*`](#secret-hygiene-trade-off-vs-wasm-bindgen) functions
applies; for secret-handling on a voter device, prefer the wasm-bindgen
flavour.

### Browser example

> The browser snippet below exercises the identity, sign and verify
> functions for completeness, but in a real deployment a voter device should sign
> with the [wasm-bindgen flavour](#using-from-javascript--webassembly)
> for better secret hygiene. The Extism flavour in the browser is
> idiomatic for *verifier* roles: audit pages, results dashboards,
> mobile webview verifiers, etc.

```js
// One-time install:  npm install @extism/extism
import createPlugin from "@extism/extism";

// `useWasi: true` is required because the plugin is built for
// wasm32-wasip1 (so `SysRng` can read random bytes from the WASI
// shim). The browser SDK ships a WASI polyfill internally.
const plugin = await createPlugin(
    "/static/crypto_vote-extism.wasm",
    { useWasi: true },
);

// --- Operation A — generate an identity ---
const { secret, public: publicKey } = await plugin
    .call("generate_identity", "")
    .then(out => out.json());

// `secret` is a prefixed string ("sk_…_…"). Persist it however you want;
// this flavour does not offer the `.fill(0)` zeroisation hook that the
// wasm-bindgen flavour does.
await persistVoterSecret(secret);

// --- Operation B — sign a ballot ---
// Text ballot → `sign_vote_str`, vote passed through unchanged, no hex
// step. (Binary ballot → `sign_vote_hex` with `vote` set to the hex of
// your bytes.)
const ballot = JSON.stringify({ choice: "option-A" });

const ring = await fetch("/api/election/ring").then(r => r.json());

const { signature, key_image } = await plugin
    .call("sign_vote_str", JSON.stringify({
        secret,
        vote:        ballot,
        election_id: "election-2026",
        ring,
    }))
    .then(out => out.json());

// --- Operation C — verify (also works server-side; see below) ---
// Use the flavour matching how the ballot was signed: `verify_vote_str`
// for a string vote, `verify_vote_hex` for a hex one.
const { valid } = await plugin
    .call("verify_vote_str", JSON.stringify({
        vote:        ballot,
        election_id: "election-2026",
        signature,
        key_image,
        ring,
    }))
    .then(out => out.json());
```

### Node.js / Deno / Bun example

**Same code as the browser** — that is the whole point of the Extism
flavour. The only difference is how you load the `.wasm` (a file path
on the server, a URL in the browser):

```js
import createPlugin from "@extism/extism";
import { readFileSync } from "node:fs";

const plugin = await createPlugin(
    { wasm: [{ data: readFileSync("./crypto_vote-extism.wasm") }] },
    { useWasi: true },
);

const { valid } = await plugin
    .call("verify_vote_str", JSON.stringify({ /* ... same shape ... */ }))
    .then(out => out.json());
```

The host SDK is also available for [Python](https://github.com/extism/python-sdk),
[Go](https://github.com/extism/go-sdk),
[Rust](https://github.com/extism/extism/tree/main/runtime),
[Java](https://github.com/extism/java-sdk), and others — the JSON
shapes above are identical for all of them.

### Quick smoke test from the CLI

The `extism` standalone CLI is the fastest way to confirm the plugin
loads and behaves correctly before integrating it anywhere:

```bash
# Install once: cargo install extism-cli   (or grab a release binary)
extism call crypto_vote-extism.wasm generate_identity --wasi
# → {"secret":"…","public":"…"}
```

## Tests

```bash
cargo test
```

Tests cover round-trips, same-election deterministic tags,
cross-election tag separation, ring-order independence, bit-level
malleability resistance on both the signature and the key image, and
every documented "invalid" case (tampered vote, tampered signature,
swapped tag, wrong ring, subset/superset ring, malformed inputs).

### Linting

Three commands mirror the CI lint job exactly — run them before pushing:

```bash
# 1. Formatting — must produce no diff.
cargo fmt --all -- --check

# 2. Clippy — default features (CLI + native library).
cargo clippy --all-targets -- -D warnings

# 3. Clippy — WASM feature, cross-compiled to wasm32 so wasm-bindgen
#    macros type-check correctly. Requires the target to be installed:
#    rustup target add wasm32-unknown-unknown
cargo clippy --no-default-features --features wasm \
    --target wasm32-unknown-unknown --lib -- -D warnings
```

To auto-fix formatting instead of just checking: `cargo fmt --all`.

### Fuzzing

A `cargo-fuzz` harness lives in [`fuzz/`](fuzz/) with four targets:

```bash
cargo install cargo-fuzz   # one-off
cargo +nightly fuzz run verify_vote      # parse + verify pipeline
cargo +nightly fuzz run parse_signature  # Signature::from_bytes
cargo +nightly fuzz run parse_keys       # PublicKey / SecretKey / KeyImage
cargo +nightly fuzz run roundtrip        # sign → verify differential
```

The `fuzz/` package is excluded from the main workspace so it does
not affect stable builds.

## Layout

```
src/
├── lib.rs            — crate entry point, re-exports, top-level docs
├── error.rs          — `Error` enum (input parsing only)
├── types.rs          — PublicKey / SecretKey / Signature / KeyImage / VoteProof
├── identity.rs       — Operation A
├── blsag.rs          — Experimental election-scoped BLSAG implementation
├── signing.rs        — Operation B (ring canonicalisation + NFC of election_id)
├── verifying.rs      — Operation C
├── wasm.rs           — wasm-bindgen layer (feature-gated, Zeroizing secrets)
├── extism.rs         — Extism PDK layer (feature-gated, JSON-over-WASI)
└── main.rs           — CLI binary
tests/
├── integration.rs    — public-API round-trips, malleability, negative paths
└── upgrade_vectors.rs — frozen test vectors (protocol-drift tripwires)
fuzz/
├── Cargo.toml        — separate package, excluded from the workspace
└── fuzz_targets/     — four cargo-fuzz harnesses (see "Fuzzing")
```

## License

This project is licensed under the **GNU Affero General Public License
v3.0 or later** (AGPL-3.0-or-later). See [LICENSE](LICENSE) for the
full text.

The AGPL is a strong copyleft licence. In short: anyone running a
modified version of this code as a network service must make their
modifications available to its users. If that obligation is
incompatible with your use case, please open an issue before
integrating the crate.