ElGamal
Threshold ElGamal — hashed-ElGamal with AES-GCM hybrid encryption. Reuses existing EC key shares from a DKG cohort.
Threshold ElGamal
ThresholdElgamal encrypts arbitrary byte payloads under a cohort's public key. Decryption requires threshold-many holders to publish a partial; the combiner assembles the partials locally to recover the plaintext.
Construction: hashed ElGamal (DHIES) over an EC group, threshold-decrypted via the Desmedt-Frankel template. See Cryptographic foundations — Threshold ElGamal for citations to ElGamal (1985), Desmedt-Frankel (1989), and the DHIES security analysis (Abdalla-Bellare-Rogaway 2001).
The big convenience: the cohort can reuse the EC key shares it already created for signing. A DKG ceremony you ran for ECDSA threshold-signing produces shares that work directly for ElGamal decryption — no separate key-generation step required.
Supported curves
Secp256k1, P256, P384, P521.
Edwards-family curves (Ed25519, Ed448) and the Schnorr variants are intentionally not supported — their public-key encoding conventions differ from the SEC1 form ElGamal expects.
API
All methods are static on the ThresholdElgamal class.
import { ThresholdElgamal, Curve } from '@zafeguard/mpc-sdk';
// Single-party encrypt — anyone with the cohort's pubkey can call this.
const ciphertext: Buffer = ThresholdElgamal.encrypt(curve, publicKey, plaintext);
// Each holder publishes a partial decryption — pass the `KeyShare`
// handle directly (recommended), or the raw `(curve, shareX, shareY)`.
const partial: Buffer = ThresholdElgamal.partialDecrypt(keyShare, ciphertext);
// Combiner: assembles threshold-many partials into the plaintext.
const plaintext: Buffer = ThresholdElgamal.combine(curve, threshold, partials, ciphertext);encrypt(curve, publicKey, plaintext) → Buffer
Returns an opaque ciphertext buffer that contains everything the combiner needs: the ephemeral group element, the AES-GCM nonce, the AES-GCM ciphertext+tag, and a scheme/version tag.
License-free. encrypt is a pure function — no private material, no cohort participation, no Mpc.init(...) required. Browser clients, third-party producers, or any code that only knows the cohort's public key can call it without an SDK license. Mpc.init is required only on the holder / combiner side where private shares are touched. This is the property that lets a sealed-bid auction publish the cohort's public key on the bidder-facing page: bidders encrypt without ever signing into your system.
publicKey is the cohort's compressed-form EC point. Where it comes from depends on the cohort topology:
- Cluster-only cohort (
ClusterAgentceremony): afteragent.sessions.createKeyShare(...)reaches theCompletephase, the terminalAgentSessionResponse.publicKeyHexis the cohort public key. Store it on the auction / vote / decrypt-target record at setup time and publish the hex on the consumer-facing page. See Cluster Agent — sessions. - Embedded cohort (
EmbeddedAgentdevice + agents): every loaded handle exposes the sameloaded.publicKeyon every party. Pull it once and publish.
In either case the public key is safe to expose — knowing it only enables encrypt, not decrypt.
The plaintext can be empty or arbitrary length — the hybrid construction (an AES-GCM key wrapped under the ElGamal shared secret) puts no practical upper bound on payload size.
Two calls with the same plaintext and key produce different ciphertexts — the ephemeral scalar is fresh per encrypt.
partialDecrypt(keyShare, ciphertext) → Buffer
One holder's contribution. Two equivalent call shapes:
partialDecrypt(keyShare, ciphertext)— pass aKeySharehandle directly. The recommended shape: the share's curve and Shamir coordinate are closed over internally, so the caller never destructures the share.partialDecrypt(curve, shareX, shareSecret, ciphertext)— legacy / raw shape, kept for callers that hold share material outside aKeySharehandle (e.g. shares deserialised from a custom wire format).
Both shapes produce byte-identical partials. The output is an opaque partial the combiner needs. Holders gossip these partials over whatever transport the application uses — HTTPS, a queue, or a manual collection step for off-line scenarios.
combine(curve, threshold, partials, ciphertext) → Buffer
Assembles threshold-many partials into the recovered plaintext. Extra partials beyond threshold are ignored (Lagrange interpolation is exact for any valid subset). Duplicate Shamir indices, wrong-scheme partials, and curve mismatches are rejected with a clear error.
Tampering with the ciphertext after encryption — bit-flipping a byte, swapping a partial — causes the AES-GCM authentication tag to fail and combine throws.
End-to-end example — via EmbeddedAgent (recommended)
When the holder runs an EmbeddedAgent, use the handle's grouped loaded.thresholdDecrypt.* namespace. The handle holds the share in protected memory and feeds it into the primitive internally — the caller never extracts x / y from the key share:
import { EmbeddedAgent, ThresholdElgamal, Curve, RecoveryKind } from '@zafeguard/mpc-sdk';
// 1. The cohort is already DKG'd; this device is one of the holders.
const signer = new EmbeddedAgent({
agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
threshold: 2,
curve: Curve.Secp256k1,
});
const loaded = await signer.create({
keyId, recovery: { kind: RecoveryKind.Noop }, passphrase,
});
// 2. Encrypt a sensitive payload under the cohort's public key.
const plaintext = Buffer.from('confidential data', 'utf8');
const ciphertext = ThresholdElgamal.encrypt(Curve.Secp256k1, loaded.publicKey, plaintext);
// 3. This holder's partial — no x/y extraction, the handle does it.
const devicePartial = await loaded.thresholdDecrypt.partial({ ciphertext });
// 4. Collect threshold-many other partials (from the agent's
// /decrypt/elgamal/partial endpoint, or from a peer holder running
// their own loaded.thresholdDecrypt.partial(...)) — see the
// multi-node orchestrator section below for the agents-only helper.
const otherPartials: Buffer[] = await collectPartialsFromCohort(ciphertext);
// 5. Combine via the same handle. Pure aggregator — no secrets needed,
// but exposed on LoadedKeyShare for symmetry so one handle drives
// both ends of the flow.
const recovered = loaded.thresholdDecrypt.combine({
ciphertext,
partials: [devicePartial, ...otherPartials],
threshold: 2,
});
// recovered.equals(plaintext) === trueEnd-to-end example — low-level / offline (no LoadedKeyShare)
If you've generated shares via Scheme.shamirSecretShare(...) for a cohort-less offline flow, the pure-function primitives let you participate without a LoadedKeyShare. Pass each holder's KeyShare handle directly:
import { ThresholdElgamal, Curve } from '@zafeguard/mpc-sdk';
const cohortPubKey: Buffer = /* cohort.publicKey from Scheme.shamirSecretShare */;
const plaintext = Buffer.from('confidential data', 'utf8');
const ciphertext = ThresholdElgamal.encrypt(Curve.Secp256k1, cohortPubKey, plaintext);
const partial1 = ThresholdElgamal.partialDecrypt(party1KeyShare, ciphertext);
const partial2 = ThresholdElgamal.partialDecrypt(party2KeyShare, ciphertext);
const recovered = ThresholdElgamal.combine(
Curve.Secp256k1, 2, [partial1, partial2], ciphertext,
);If you're holding raw share material outside a KeyShare handle, the legacy (curve, shareX, shareSecret, ciphertext) shape produces byte-identical partials:
const partial1 = ThresholdElgamal.partialDecrypt(
Curve.Secp256k1, party1.x, party1.y, ciphertext,
);The three paths — loaded.thresholdDecrypt.partial(...), ThresholdElgamal.partialDecrypt(keyShare, ...), and the legacy raw-share form — all produce byte-compatible partials and combine interchangeably.
Multi-node orchestrator
When your cohort is a set of ClusterAgent nodes that each store a key share, you can skip writing the parallel-fetch + combine plumbing yourself:
const plaintext = await ThresholdElgamal.decryptViaAgents({
ciphertext,
keyShareId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
threshold: 2,
agents: [
{ baseUrl: 'https://node-1.example', apiKey: 'k1' },
{ baseUrl: 'https://node-2.example', apiKey: 'k2' },
{ baseUrl: 'https://node-3.example', apiKey: 'k3' },
],
});The orchestrator fires parallel POST /key-shares/{id}/decrypt/elgamal/partial requests at every agent in the list, takes the first threshold-many that succeed, and runs combine locally. One agent being down doesn't break the call as long as threshold-many others respond.
Failure mode: if fewer than threshold agents succeed, the call throws an aggregate Error carrying every per-agent failure reason for diagnostics.
The combiner never sees plaintext at the agent — it's produced inside the calling process, consistent with how partial signatures are assembled on the SDK side rather than the agent side.
Key reuse with the signing surface
A Curve.Secp256k1 share from a DKG ceremony works as both a signing share and an ElGamal decryption share. The same threshold cohort that signs a transaction can decrypt a payload encrypted to its public key — no extra key-generation step required.
This is useful for compliance / audit flows where:
- A user's wallet has a threshold-signing key.
- An auditor encrypts a regulatory snapshot to that wallet's pubkey.
- The user can grant decryption access by cooperating with the agents (requires threshold-many shares), without exposing the signing key itself.
Security properties
- Authenticated encryption: the AES-GCM tag rejects any bit-flip in the ciphertext body. Tampered ciphertexts throw at
combinerather than decrypting to garbage. - Threshold gate: fewer than
thresholdpartials must NOT decrypt. Verified end-to-end. - Scheme isolation: mixing ElGamal partials with Paillier or RSA partials at combine time is rejected with a clear error.
- Curve isolation: a ciphertext encrypted under one curve cannot be partial-decrypted with a share from a different curve.
- Replay determinism: a holder's partial decryption for a given ciphertext is deterministic — replaying produces byte-identical output, which the combiner can verify across redundant requests.
Limitations
- The cohort's public key MUST be on one of the four supported curves.
- The combiner role is responsible for delivering the plaintext to whichever consumer should receive it. The primitive itself doesn't return-channel.
- For very large payloads (10 MB+) consider chunking — the AES-GCM single-message limit is ~64 GiB but practical throughput becomes the bottleneck at multi-megabyte sizes.