Paillier
Threshold Paillier — additive-homomorphic encryption. Add two ciphertexts without decrypting, then quorum-decrypt the result.
Threshold Paillier
Construction: Paillier (1999) additive-homomorphic cryptosystem with the g = n+1 simplification, threshold-decrypted via the Damgård-Jurik / Fouque-Poupard-Stern template. See Cryptographic foundations — Threshold Paillier for the full citations.
ThresholdPaillier is the only primitive of the three with additive homomorphism: you can add two ciphertexts under the same key and decrypt to the sum of their plaintexts, without ever decrypting either input. The same property allows scalar multiplication: encrypt m, raise the ciphertext to the k-th power, decrypt k · m.
This makes Paillier the right choice when you need to compute on encrypted data — confidential vote tallying, sealed-bid auction sums, encrypted analytics — even when the underlying numbers must stay private.
When to choose Paillier
| Use case | Why Paillier |
|---|---|
| Aggregate sums over encrypted values | Sum the ciphertexts, decrypt only the total. Individual values remain private. |
| Weighted score combinations | Multiply ciphertexts by public scalars; decrypt the weighted total. |
| Private set summation | Each participant encrypts a contribution; nobody can read individual values, only the cohort can decrypt the sum. |
| Confidential analytics where individual values must remain private | Aggregate first under encryption, decrypt only the aggregate. |
If you don't need the additive property, ElGamal (which can reuse existing EC key shares) or RSA (standards-compatible OAEP envelope) is usually a better fit.
API
import { ThresholdPaillier } from '@zafeguard/mpc-sdk';
// Generate a fresh single-party keypair.
const kp = ThresholdPaillier.keygen(primeBits);
// kp.nBytes - public modulus n
// kp.shareSecretBytes - holder's secret bundle (trusted-holder mode)
// Generate a keypair AND distribute Shamir shares across a cohort.
const set = ThresholdPaillier.shamirShareLambda(primeBits, threshold, maxShares);
// set.nBytes - public modulus n shared across the cohort
// set.shares - one (x, shareSecretBytes) per holder
// Encrypt under the public modulus.
const ct = ThresholdPaillier.encrypt(publicKey, plaintext);
// One holder's contribution.
const partial = ThresholdPaillier.partialDecrypt(shareX, shareSecret, ct);
// Combiner: assembles threshold-many partials into the plaintext.
const plaintext = ThresholdPaillier.combine(threshold, partials, ct);
// Additive homomorphism:
const sum = ThresholdPaillier.addCiphertexts(ct1, ct2);
const scaled = ThresholdPaillier.scalarMul(ct, scalarBytes);keygen(primeBits) → PaillierKeyPair
Generates a fresh keypair. Production keys use primeBits >= 1024 (so n >= 2048 bits). Smaller values are fine for tests but NOT for real deployments.
The output bundles two opaque buffers:
nBytes— the public modulus. Anyone with this can encrypt.shareSecretBytes— the holder's secret in trusted-holder mode (a single party holds the whole key).
shamirShareLambda(primeBits, threshold, maxShares) → PaillierKeyPairWithShares
Generates a keypair AND distributes Shamir shares across maxShares holders, with threshold-of-n decryption semantics. The output is the public modulus plus an array of (x, shareSecretBytes) tuples — one per holder.
Constraints:
threshold >= 1threshold <= maxSharesmaxShares <= 255
encrypt(publicKey, plaintext) → Buffer
Anyone with the public modulus can call this — no cohort participation needed.
License-free. Like ThresholdElgamal.encrypt, this is a pure function — no private material, no Mpc.init(...) required. Hospitals encrypting their patient counts from a browser or backend process can call it without an SDK license; only the cohort holders (partialDecrypt) and the combiner (combine / addCiphertexts / scalarMul) need an initialised SDK. The same applies to addCiphertexts and scalarMul — both operate on ciphertext-only inputs and have no private dependency.
Plaintext encoding. The plaintext is interpreted as a big-endian unsigned integer in [0, n). For an integer value v, encode as Buffer.from(v.toString(16).padStart(2, '0'), 'hex') or bigintToBytes(v) — leading zeros are accepted and stripped at decrypt time. For modulus sizes of 2048 bits this gives ~255 bytes of plaintext space per ciphertext (i.e. any non-negative integer up to ~2^2047 fits). Attempting to encrypt a value ≥ n throws.
Overflow on addCiphertexts. Sums silently reduce modulo n — the cryptographic primitive returns (a + b) mod n with no error. For real-world aggregation (hospital counts, vote tallies) this is fine as long as the expected sum fits in the modulus; a 2048-bit modulus comfortably holds sums of any reasonable population. If your application can produce sums approaching the modulus, validate upstream.
Larger payloads (raw records, not integers) need either a larger key or a hybrid construction (encrypt an AES-GCM key with Paillier, then AES-GCM the actual payload).
Two encryptions of the same plaintext under the same key produce different ciphertexts — the blinding factor is fresh per encrypt.
partialDecrypt(shareX, shareSecret, ciphertext) → Buffer
One holder's contribution. The SDK dispatches internally on the share-secret version: single-party (trusted-holder) mode produces a partial that carries the plaintext directly; trusted-dealer threshold mode produces a partial carrying the holder's Shamir tuple.
combine(threshold, partials, ciphertext) → Buffer
Assembles threshold-many partials into the recovered plaintext. Returns the plaintext as big-endian bytes.
Validation: mixing single-party and threshold partials in one call is rejected. Wrong-scheme partials, duplicate Shamir indices, and partials from a different cohort all surface clear errors at combine time.
addCiphertexts(a, b) → Buffer
Returns a new ciphertext that decrypts to (a + b) mod n. Pure on-public-data — no decryption involved. Both inputs MUST use the same Paillier modulus; mismatched moduli are rejected.
scalarMul(ciphertext, kBytes) → Buffer
Returns a new ciphertext that decrypts to (k · m) mod n, where m is the ciphertext's plaintext and k is the scalar (big-endian bytes). Useful for weighted sums:
// Weight three encrypted votes by w1, w2, w3 and decrypt only the total.
const weighted1 = ThresholdPaillier.scalarMul(vote1, w1);
const weighted2 = ThresholdPaillier.scalarMul(vote2, w2);
const weighted3 = ThresholdPaillier.scalarMul(vote3, w3);
const total = ThresholdPaillier.addCiphertexts(
ThresholdPaillier.addCiphertexts(weighted1, weighted2),
weighted3,
);
// Decrypt total → w1*v1 + w2*v2 + w3*v3End-to-end example — confidential vote tally
import { ThresholdPaillier } from '@zafeguard/mpc-sdk';
// 1. Trusted dealer mints a 2-of-3 keypair.
const set = ThresholdPaillier.shamirShareLambda(1024, 2, 3);
// 2. Each voter encrypts their vote under the cohort's public modulus.
const v1 = ThresholdPaillier.encrypt(set.nBytes, Buffer.from([1])); // yes
const v2 = ThresholdPaillier.encrypt(set.nBytes, Buffer.from([1])); // yes
const v3 = ThresholdPaillier.encrypt(set.nBytes, Buffer.from([0])); // no
// 3. Aggregate the encrypted votes — nobody can read individuals yet.
const tallyCt = ThresholdPaillier.addCiphertexts(
ThresholdPaillier.addCiphertexts(v1, v2), v3,
);
// 4. Two of three holders publish partials to decrypt the TOTAL only.
const p1 = ThresholdPaillier.partialDecrypt(
set.shares[0].x, set.shares[0].shareSecretBytes, tallyCt,
);
const p2 = ThresholdPaillier.partialDecrypt(
set.shares[1].x, set.shares[1].shareSecretBytes, tallyCt,
);
// 5. Combiner recovers the tally.
const tally = ThresholdPaillier.combine(2, [p1, p2], tallyCt);
// tally[tally.length - 1] === 2 (sum of three votes: 1 + 1 + 0)Individual votes are never decrypted — only the aggregate.
Trust model
shamirShareLambda is a trusted-dealer flow: one party (or service, or HSM) generates the keypair locally and immediately distributes Shamir shares to the cohort holders. The dealer momentarily knows the full secret; after the shares are distributed and the dealer's local copy is destroyed, the secret can only be reconstructed by threshold-many holders cooperating.
When threshold-many holders cooperate at combine time, the combiner gains enough information to reconstruct the private key derivation. This is fine for high-assurance HSM-backed deployments where the combiner is itself a trusted environment, but it's not zero-trust.
The zero-trust variant (where the combiner never reconstructs the secret) is shipped for the RSA primitive — see RSA Shoup mode. The Paillier equivalent has stricter key-shape requirements and is not yet exposed via this SDK.
Key-size policy
primeBits | n size | Use for |
|---|---|---|
| 64-128 | 128-256 bits | Tests / examples only. Sub-millisecond keygen; trivially breakable. |
| 1024 | 2048 bits | Minimum for production. Multi-second keygen. |
| 1536 | 3072 bits | Long-term confidentiality (years). |
| 2048 | 4096 bits | Maximum-strength; multi-tens-of-seconds keygen. |
Pick primeBits based on the value of what you're protecting and how long it needs to remain confidential. The SDK doesn't ship a "secure default" because the only safe value (>= 1024) has multi-second latency that would surprise callers — explicit is better than implicit.
Security properties
- Threshold gate: fewer than
thresholdpartials must NOT decrypt. - Plaintext-space enforcement: encrypting a plaintext
>= nis rejected at encrypt time. - Tampering rejection: bit-flipping a ciphertext on the wire produces a different (random-looking) plaintext, NOT the original — Paillier is malleable, so applications that need authentication should add an AEAD layer above the ciphertext (e.g. wrap a Paillier ciphertext + MAC in their own envelope).
- Mixed-mode rejection: a partial set mixing single-party (0x01) and threshold (0x02) partials is rejected at combine.
- Wire-format stability: every share, partial, and ciphertext carries a version byte; the format is locked against silent drift.