RSA
Threshold RSA-OAEP — trusted-dealer mode for simplicity, zero-trust mode for combiner-blind decryption.
Threshold RSA
Construction: RSA-OAEP-SHA256 (RFC 8017 / PKCS #1 v2.2) with two threshold-decryption modes — trusted-dealer Shamir-sharing of d per Shoup (2000) §2.1, and the zero-trust combiner-blind "Lagrange in the exponent" variant per Shoup (2000) §2.2. See Cryptographic foundations — Threshold RSA for the full citations.
ThresholdRsa implements RSA-OAEP-SHA256 encryption with two distinct threshold modes:
- Trusted-dealer mode — simplest threshold flow. The combiner reconstructs the private exponent during decryption.
- Zero-trust mode — combiner-blind decryption. Even a malicious combiner cooperating with
threshold-many holders cannot reconstruct the private exponent. Use this when the combiner role is itself a potential adversary.
Both modes are interchangeable from the caller's perspective — the only difference is which factory method mints the shares.
When to choose RSA
| Reason | When it matters |
|---|---|
| Standards compatibility | The ciphertext format is RSA-OAEP-SHA256 — interoperable with any RSA library that speaks PKCS #1 v2.2. |
| Existing key-management tooling expects RSA | HSMs, KMS systems, and many compliance frameworks. |
| Hybrid envelope around an AES key | Standard pattern: RSA-encrypt a fresh AES key, then AES-encrypt the actual payload. |
| Combiner is untrusted (zero-trust requirement) | Use the Shoup mode — see below. |
If you have flexibility on the format and don't need RSA specifically, ElGamal (reuses your EC signing shares) is usually a better fit because it avoids the separate keygen step. Paillier is the right choice if you need additive homomorphism.
API
import { ThresholdRsa } from '@zafeguard/mpc-sdk';
// Generate a fresh single-party keypair.
const kp = ThresholdRsa.keygen(primeBits, publicExponent);
// Trusted-dealer threshold sharing (simpler, combiner learns the private exponent).
const dealer = ThresholdRsa.shamirShareD(primeBits, publicExponent, threshold, maxShares);
// Zero-trust threshold sharing (combiner NEVER learns the private exponent).
const shoup = ThresholdRsa.shamirShareDShoup(primeBits, publicExponent, threshold, maxShares);
// Encrypt — RSA-OAEP-SHA256 padding applied internally.
const ct = ThresholdRsa.encrypt(publicKey, plaintext);
// One holder's contribution.
const partial = ThresholdRsa.partialDecrypt(shareX, shareSecret, ct);
// Combiner: assembles threshold-many partials.
const plaintext = ThresholdRsa.combine(threshold, partials, ct);keygen(primeBits, e?) → RsaKeyPair
Generates a fresh single-party keypair. primeBits is the per-prime bit size; production keys use primeBits >= 1024 (so n >= 2048 bits). e defaults to 65537.
shamirShareD(primeBits, e, threshold, maxShares) → RsaKeyPairWithShares
Trusted-dealer threshold sharing of the private exponent d. Returns the public key bytes plus an array of (x, shareSecretBytes) tuples.
In this mode the combiner reconstructs d from threshold-many holder partials and then runs a normal RSA decryption locally. This is simpler than the Shoup mode but trusts the combiner with full decryption authority once threshold-many holders cooperate.
shamirShareDShoup(primeBits, e, threshold, maxShares) → RsaKeyPairWithShares
Zero-trust threshold sharing — the combiner reconstructs the plaintext without ever reconstructing the private exponent d. The mathematics rearranges so that the combiner performs only operations in the public group Z_n*, never gaining usable information about d itself.
Constraint: maxShares <= 32. The internal computation scales with (maxShares!) so larger cohorts grow the per-decryption cost unsustainably; 32 covers any realistic cohort size while keeping the math tractable.
Use this when the combiner role is itself a potential adversary — for example a public-facing API gateway that brokers decryption requests for clients.
encrypt(publicKey, plaintext) → Buffer
Always applies RSA-OAEP-SHA256 padding to the plaintext before encryption. Raw textbook RSA is never exposed through this surface — it's malleable and the wrong default.
License-free. Pure function — no private material, no Mpc.init(...) required. Any third-party encryption tool (browser, backend, OpenSSL CLI) that knows the cohort's public key can produce ciphertexts. Only partialDecrypt (holders) and combine (combiner) need an initialised SDK.
publicKey format. RsaKeyPair.publicKeyBytes is a SubjectPublicKeyInfo DER encoding (RFC 5280 §4.1) — the standard X.509 representation. To use it from an external tool:
// Convert SDK public-key bytes to PEM for OpenSSL CLI consumption.
import { ThresholdRsa } from '@zafeguard/mpc-sdk';
const cohort = ThresholdRsa.shamirShareD(1024, 65537, 3, 5);
const pem =
'-----BEGIN PUBLIC KEY-----\n' +
cohort.publicKeyBytes.toString('base64').match(/.{1,64}/g)!.join('\n') +
'\n-----END PUBLIC KEY-----\n';
// Hand this PEM to any RSA-OAEP-SHA256-compatible client. For OpenSSL:
// echo -n 'secret payload' | openssl pkeyutl -encrypt \
// -pubin -inkey cohort.pem \
// -pkeyopt rsa_padding_mode:oaep \
// -pkeyopt rsa_oaep_md:sha256 \
// -pkeyopt rsa_mgf1_md:sha256 > ciphertext.bin
//
// Node `crypto.publicEncrypt({ key: pem, padding: RSA_PKCS1_OAEP_PADDING,
// oaepHash: 'sha256' }, plaintext)` produces interchangeable output.The ciphertext bytes from any standards-compliant RSA-OAEP-SHA256 encryptor decrypt cleanly through ThresholdRsa.combine(...) because the SDK's encrypt and the OpenSSL flag combination above produce byte-identical padding. This is the standard interop path for legacy-system migrations: the existing encryption tooling keeps working unchanged; only the decryption side moves into the threshold cohort.
Plaintext size. The plaintext maximum length is (modulusBytes - 2 * hashLen - 2). For a 2048-bit key with SHA-256 that's 256 - 64 - 2 = 190 bytes. Larger payloads need a hybrid envelope: encrypt a fresh 32-byte AES key with RSA-OAEP, then AES-256-GCM the actual payload. The cohort recovers the AES key by threshold decrypt; the bulk decryption happens locally.
// Hybrid envelope for arbitrarily large payloads.
import { ThresholdRsa, Utils } from '@zafeguard/mpc-sdk';
// 1. Sender (external client, no SDK license) generates a fresh AES key
// and nonce, encrypts the payload with AES-256-GCM, then wraps the
// AES key with the cohort's RSA-OAEP public key.
const aesKey = Utils.secureRandomBytes(32);
const aesNonce = Utils.secureRandomBytes(12);
const aesAad = Buffer.from('record-id:42');
const aesCt = Utils.aes256gcmSeal({
key: aesKey, nonce: aesNonce, aad: aesAad, plaintext: longPayload,
});
const wrappedKey = ThresholdRsa.encrypt(cohort.publicKeyBytes, aesKey);
// Wire format: { wrappedKey, nonce, aad, ciphertext }
// 2. Cohort threshold-decrypts wrappedKey to recover aesKey,
// then anyone with aesKey + nonce + aad opens aesCt locally.
const recoveredAesKey = ThresholdRsa.combine(3, partials, wrappedKey);
const recovered = Utils.aes256gcmOpen({
key: recoveredAesKey, nonce: aesNonce, aad: aesAad, ciphertextWithTag: aesCt,
});Two encryptions of the same plaintext produce different ciphertexts — OAEP's random seed is fresh per encrypt.
partialDecrypt(shareX, shareSecret, ciphertext) → Buffer
One holder's contribution. The SDK dispatches internally on the share-secret version byte: trusted-holder mode (0x01), trusted-dealer threshold mode (0x02), or zero-trust Shoup mode (0x03). Holders don't need to know which mode the dealer chose — the dispatch is automatic from the blob format.
combine(threshold, partials, ciphertext) → Buffer
Assembles threshold-many partials into the OAEP-decoded plaintext. Returns the plaintext bytes.
Validation: all partials in one call MUST use the same mode (mixing 0x01, 0x02, 0x03 in one combine call is rejected). Wrong-scheme partials, duplicate Shamir indices, and curve-tagged ciphertexts are all rejected.
End-to-end example — trusted-dealer mode
import { ThresholdRsa } from '@zafeguard/mpc-sdk';
// 1. Dealer mints a 2-of-3 keypair.
const set = ThresholdRsa.shamirShareD(1024, undefined, 2, 3);
// 2. Encrypt — anyone with the public key can do this.
const plaintext = Buffer.from('confidential payload');
const ct = ThresholdRsa.encrypt(set.publicKeyBytes, plaintext);
// 3. Two of three holders publish partials.
const p1 = ThresholdRsa.partialDecrypt(
set.shares[0].x, set.shares[0].shareSecretBytes, ct,
);
const p2 = ThresholdRsa.partialDecrypt(
set.shares[1].x, set.shares[1].shareSecretBytes, ct,
);
// 4. Combiner recovers the plaintext.
const recovered = ThresholdRsa.combine(2, [p1, p2], ct);
// recovered.equals(plaintext) === trueZero-trust (Shoup) mode
The Shoup variant differs only in the factory call:
// Same flow as above, just a different factory method.
const set = ThresholdRsa.shamirShareDShoup(1024, undefined, 2, 3);
// Everything else is identical.
const ct = ThresholdRsa.encrypt(set.publicKeyBytes, plaintext);
const p1 = ThresholdRsa.partialDecrypt(
set.shares[0].x, set.shares[0].shareSecretBytes, ct,
);
const p2 = ThresholdRsa.partialDecrypt(
set.shares[1].x, set.shares[1].shareSecretBytes, ct,
);
const recovered = ThresholdRsa.combine(2, [p1, p2], ct);The user-visible API is unchanged — only the underlying mathematics differs. The combiner gains the plaintext but never gains usable information about the private exponent itself. A malicious combiner colluding with threshold-many holders can decrypt the specific ciphertext they're combining, but cannot forge decryptions of other ciphertexts encrypted under the same key.
When to use Shoup mode
- Public-facing decryption endpoints — when an API gateway brokers decryption requests on behalf of clients, you don't want the gateway to gain full decryption authority every time threshold-many holders sign off on a specific request.
- Audit-traceable decryption — every successful decryption requires fresh cohort cooperation; there's no "the combiner cached the key after the first decryption" failure mode.
- Cross-organization cohorts — when the combiner is itself a participant from a different organization, Shoup mode means cooperating on one decryption doesn't surrender the key.
When trusted-dealer mode is fine
- The combiner is a trusted high-assurance environment (HSM, secure enclave, your own backend in a trust-boundary-isolated VPC).
- You're not exposing the combiner role to external callers.
- You want a simpler operational story (the trusted-dealer mode is easier to reason about and slightly faster per decryption).
Key-size policy
primeBits | n size | Use for |
|---|---|---|
| 64-256 | 128-512 bits | Tests / examples only. |
| 384-512 | 768-1024 bits | Demo cohort sizes; below the OAEP-SHA256 minimum modulus when smaller. |
| 1024 | 2048 bits | Minimum for production. |
| 1536 | 3072 bits | Long-term confidentiality. |
| 2048 | 4096 bits | Maximum-strength. |
OAEP-SHA256 requires a modulus of at least 66 bytes (528 bits); smaller primeBits values are rejected at encrypt time.
Security properties
- OAEP padding: tampered ciphertexts fail the constant-time-style padding check on decryption. No silent decrypt-to-garbage path.
- Threshold gate: fewer than
thresholdpartials must NOT decrypt. - Mode isolation: partials from different threshold modes (trusted-dealer vs zero-trust) cannot be mixed in one combine call.
- Scheme isolation: cross-scheme partial mixing is rejected at combine time.
- Plaintext-size enforcement: oversized plaintexts are rejected at encrypt time before any computation.
- Zero-trust property (Shoup mode only): the combiner never reconstructs the private exponent, so cooperation on one decryption does not grant decryption authority for other ciphertexts.
Limitations
- The maximum cohort size for Shoup mode is 32 holders.
- The maximum plaintext per ciphertext is bounded by the modulus size; larger payloads need a hybrid envelope.
- Threshold sharing is currently SDK-side only — there is no agent endpoint yet for distributing RSA shares across an ClusterAgent. The cohort orchestration is the application's responsibility.