Threshold Decryption

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 caseWhy Paillier
Aggregate sums over encrypted valuesSum the ciphertexts, decrypt only the total. Individual values remain private.
Weighted score combinationsMultiply ciphertexts by public scalars; decrypt the weighted total.
Private set summationEach participant encrypts a contribution; nobody can read individual values, only the cohort can decrypt the sum.
Confidential analytics where individual values must remain privateAggregate 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 >= 1
  • threshold <= maxShares
  • maxShares <= 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*v3

End-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

primeBitsn sizeUse for
64-128128-256 bitsTests / examples only. Sub-millisecond keygen; trivially breakable.
10242048 bitsMinimum for production. Multi-second keygen.
15363072 bitsLong-term confidentiality (years).
20484096 bitsMaximum-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 threshold partials must NOT decrypt.
  • Plaintext-space enforcement: encrypting a plaintext >= n is 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.

On this page