threshold-elgamal is a browser-native TypeScript library for verifiable score-voting research prototypes. It focuses on one workflow:
- additive ElGamal on
ristretto255 - honest-majority GJKR DKG
- one explicit global contiguous score range per ceremony
- manifest
scoreRange.maxcapped at100to keep proofs and tally recovery tractable - one public manifest shape:
rosterHash,optionList, andscoreRange - organizer-signed
ballot-closebefore decryption - full local recomputation and full ceremony verification from the public board
This package is library-only. WebSockets, retries, persistence, bulletin-board storage, mobile lifecycle handling, reminders, and organizer UX live in the application.
This is a hardened research prototype. It has not been audited.
npm install threshold-elgamal- Use ESM imports such as
import { createElectionManifest } from 'threshold-elgamal'. - Browsers need native
biginttogether with Web Crypto. - Node must satisfy the package
engines.noderequirement and exposeglobalThis.crypto. - Authentication signatures require Web Crypto
Ed25519. - Transport share exchange requires Web Crypto
X25519.
See Runtime and compatibility for environment requirements.
- Homepage: tenemo.github.io/threshold-elgamal
- Getting started: tenemo.github.io/threshold-elgamal/guides/getting-started
- Runtime and compatibility: tenemo.github.io/threshold-elgamal/guides/runtime-and-compatibility
- Browser and worker usage: tenemo.github.io/threshold-elgamal/guides/browser-and-worker-usage
- Honest-majority voting flow: tenemo.github.io/threshold-elgamal/guides/three-participant-voting-flow
- Published payload examples: tenemo.github.io/threshold-elgamal/guides/published-payload-examples
- Verifying a public board: tenemo.github.io/threshold-elgamal/guides/verifying-a-public-board
- Security boundary: tenemo.github.io/threshold-elgamal/guides/security-and-non-goals
- Production voting safety review: tenemo.github.io/threshold-elgamal/guides/production-voting-safety-review
- API docs: tenemo.github.io/threshold-elgamal/api
The cryptographic browser path is fixed:
-
Ed25519for protocol payload signatures -
X25519for encrypted share transport -
Use modern browsers that expose Web Crypto
Ed25519, Web CryptoX25519, and nativebigint -
Validate your target environments with
pnpm exec tsx ./tools/ci/verify-browser-compat.tsbefore deployment
Older browsers, stale embedded webviews, and runtimes without Web Crypto X25519 support are not supported.
The supported boardroom flow is:
- Freeze the roster in the application and hash it with
hashRosterEntries(...). - Build the manifest with
createElectionManifest({ rosterHash, optionList, scoreRange }). - Publish the manifest, registrations, and manifest acceptances.
- Run the honest-majority GJKR transcript.
- Post ballot payloads for complete scores inside the manifest-declared range.
- Post one organizer-signed
ballot-closepayload that freezes which complete ballots are counted. - Post threshold decryption shares and tally publications for the close-selected ballot set.
- Verify the whole ceremony with
verifyElectionCeremony(...).
The cryptographic threshold is derived internally from the accepted registration roster:
k = ceil(n / 2)- odd participant counts are recommended
- even participant counts are supported and use
k = n / 2
There is no supported n-of-n mode and no supported public k-of-n configuration.
Transcript verification requires key-derivation-confirmation payloads from every qualified participant. In the current design those unanimous confirmations are part of verifier soundness: the library does not implement a public post-Feldman complaint/reconstruction phase, so the DKG verifier is participant-confirmed rather than fully public-data-only. Lowering confirmation acceptance to threshold-many is out of scope unless that missing public consistency machinery is added.
See Honest-majority voting flow for the full phase-by-phase transcript.
import {
createElectionManifest,
deriveSessionId,
hashElectionManifest,
hashRosterEntries,
majorityThreshold,
} from "threshold-elgamal";
const rosterHash = await hashRosterEntries([
{
participantIndex: 1,
authPublicKey: "auth-key-1",
transportPublicKey: "transport-key-1",
},
{
participantIndex: 2,
authPublicKey: "auth-key-2",
transportPublicKey: "transport-key-2",
},
{
participantIndex: 3,
authPublicKey: "auth-key-3",
transportPublicKey: "transport-key-3",
},
]);
const manifest = createElectionManifest({
rosterHash,
optionList: ["Option A", "Option B"],
scoreRange: { min: 0, max: 5 },
});
const manifestHash = await hashElectionManifest(manifest);
const sessionId = await deriveSessionId(
manifestHash,
rosterHash,
"public-nonce",
"2026-04-11T12:00:00Z",
);
console.log(majorityThreshold(3)); // 2
console.log(sessionId.length); // 64The example uses 0..5 only as one concrete score range. The supported rule is
one manifest-declared contiguous range with non-negative bounds and
scoreRange.max <= 100.
If your application consumes a complete public board, start with Verifying a public board and then move directly to the verifier entry point:
import {
tryVerifyElectionCeremony,
type VerifyElectionCeremonyInput,
} from "threshold-elgamal";
const bundle: VerifyElectionCeremonyInput = {
manifest,
sessionId,
dkgTranscript,
ballotPayloads,
ballotClosePayloads: [ballotClosePayload],
decryptionSharePayloads,
tallyPublications,
};
const result = await tryVerifyElectionCeremony(bundle);
if (!result.ok) {
console.error(result.error.stage, result.error.code, result.error.reason);
} else {
console.log(result.verified.perOptionTallies);
console.log(result.verified.boardAudit.overall.fingerprint);
}Pass the full published ballot-close slot in ballotClosePayloads, even when the normal case is one organizer payload. The verifier audits that slot, collapses only exact retransmissions, and requires exactly one accepted close record.
The root package exposes the builders and lower-level helpers required for the documented ceremony, including:
- manifest publication
- registration
- manifest acceptance
- phase checkpoints
- Pedersen commitments
- encrypted dual-share envelopes
- Feldman commitments
- key-derivation confirmations
- ballot submission
- ballot close
- decryption shares
- tally publication
The reveal path also works from the root package:
- prepare the accepted aggregate with
prepareAggregateForDecryption(...) - compute each partial share with
createDecryptionShare(...) - prove it with
createDLEQProof(...) - publish it with
createDecryptionSharePayload(...)
After collecting a threshold subset, recover the tally with combineDecryptionShares(...) against the prepared aggregate ciphertext.
The grouped public submodules remain available when you prefer narrower imports by subsystem, but the supported full ceremony does not require them.
For concrete posted JSON shapes, use Published payload examples.
The library is designed for an honest-origin, honest-client, static-adversary setting.
What it tries to enforce:
- additive-only tallying on
ristretto255 - one explicit global contiguous manifest score range
- grouped per-option ballot verification
- mandatory local aggregate recomputation before decryption
- organizer-visible and auditable ballot cutoff through
ballot-close - end-to-end ceremony verification from signed public payloads
What it does not claim:
- coercion resistance
- receipt-freeness
- cast-as-intended against a compromised client
- constant-time JavaScript
bigintexecution - production readiness
ballot-close is an auditable administrative cutoff, not a fairness proof about board arrival order. The library proves which ballots count, not whether the organizer waited long enough before closing.
For a production-threat-model verdict that maps these boundaries to the verifier and tests, read the production voting safety review.
pnpm install
pnpm run lint
pnpm run tsc
pnpm run test
pnpm run coverage:node
pnpm run build
pnpm exec playwright install chromium firefox webkit
pnpm exec tsx ./tools/ci/verify-browser-compat.ts
pnpm run verify:docs
pnpm run docs:build:site
pnpm run smoke:packThis project is licensed under MPL-2.0. See LICENSE.