piech.dev

Back to Projects github.com/Tenemo/threshold-elgamal

threshold-elgamal

npm version npm downloads


CI Tests coverage Documentation build


License

threshold-elgamal is a browser-native TypeScript library for verifiable score-voting research prototypes. It focuses on one workflow:

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.

Installation

npm install threshold-elgamal

Runtime requirements

See Runtime and compatibility for environment requirements.

Documentation



Browser support

The cryptographic browser path is fixed:

Older browsers, stale embedded webviews, and runtimes without Web Crypto X25519 support are not supported.

Supported workflow

The supported boardroom flow is:

  1. Freeze the roster in the application and hash it with hashRosterEntries(...).
  2. Build the manifest with createElectionManifest({ rosterHash, optionList, scoreRange }).
  3. Publish the manifest, registrations, and manifest acceptances.
  4. Run the honest-majority GJKR transcript.
  5. Post ballot payloads for complete scores inside the manifest-declared range.
  6. Post one organizer-signed ballot-close payload that freezes which complete ballots are counted.
  7. Post threshold decryption shares and tally publications for the close-selected ballot set.
  8. Verify the whole ceremony with verifyElectionCeremony(...).

The cryptographic threshold is derived internally from the accepted registration roster:

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.

Getting started

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); // 64

The 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:

The reveal path also works from the root package:

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.

Security boundary

The library is designed for an honest-origin, honest-client, static-adversary setting.

What it tries to enforce:

What it does not claim:

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.

Development

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:pack

License

This project is licensed under MPL-2.0. See LICENSE.