Twisted ElGamal encryption scheme for confidential on-chain data
The ZK ElGamal Proof SDK implements a twisted ElGamal encryption scheme over the Ristretto group on Curve25519. This variant encrypts messages “in the exponent” and represents ciphertexts as Pedersen commitments with decryption handles.
Traditional ElGamal encryption operates directly on group elements. The twisted variant used in this SDK encrypts scalar values in the exponent, which enables seamless integration with zero-knowledge proof systems designed for Pedersen commitments.
From zk-sdk/src/encryption/elgamal.rs:1-15:A twisted ElGamal ciphertext consists of two components:
A Pedersen commitment that encodes the message to be encrypted
A decryption handle that binds the Pedersen opening to a specific public key
This design allows proof systems designed for Pedersen commitments to work directly on ElGamal ciphertexts.
The keypair contains both public and secret keys, with automatic zeroization for security:
// From zk-sdk/src/encryption/elgamal.rs:149-156#[derive(Clone, Deserialize, PartialEq, Eq, Serialize, Zeroize)]#[zeroize(drop)]pub struct ElGamalKeypair { /// The public half of this keypair public: ElGamalPubkey, /// The secret half of this keypair (zeroized on drop) secret: ElGamalSecretKey,}
The public key is a Ristretto point used for encryption:
// From zk-sdk/src/encryption/elgamal.rs:341-342pub struct ElGamalPubkey(RistrettoPoint);impl ElGamalPubkey { /// Derives the ElGamalPubkey from a secret key pub fn new(secret: &ElGamalSecretKey) -> Self { let s = &secret.0; assert!(s != &Scalar::ZERO); ElGamalPubkey(s.invert() * &(*H)) }}
The public key derivation is pubkey = s^(-1) * H, where s is the secret scalar and H is the Pedersen base point. This unusual construction (using the inverse) is what makes the scheme “twisted”.
fn keygen() -> ElGamalKeypair { // Secret scalar should be non-zero except with negligible probability let s = Zeroizing::new(Scalar::random(&mut OsRng)); Self::keygen_with_scalar(&s)}
Derive a keypair from a seed (useful for wallet integration):
use solana_zk_sdk::encryption::elgamal::ElGamalKeypair;use solana_seed_derivable::SeedDerivable;// Derive from a 32-byte seedlet seed = b"your-32-byte-seed-goes-here!!!!";let keypair = ElGamalKeypair::from_seed(seed)?;
The seed must be at least 32 bytes and at most 65535 bytes. Seeds shorter than 32 bytes will return ElGamalError::SeedLengthTooShort.
For wallet integration, derive an ElGamal keypair from a Solana signer:
use solana_zk_sdk::encryption::elgamal::ElGamalKeypair;use solana_keypair::Keypair;let solana_keypair = Keypair::new();let public_seed = b"my-elgamal-key";// Derives by signing the seed and hashing the signaturelet elgamal_keypair = ElGamalKeypair::new_from_signer( &solana_keypair, public_seed)?;
How it works (from zk-sdk/src/encryption/elgamal.rs:514-531):
pub fn seed_from_signer( signer: &dyn Signer, public_seed: &[u8],) -> Result<Vec<u8>, SignerError> { let message = [b"ElGamalSecretKey", public_seed].concat(); let signature = signer.try_sign_message(&message)?; // Hash the signature to derive the seed Ok(Self::seed_from_signature(&signature))}
This approach allows users to derive ElGamal keys on-the-fly from their Solana wallet without storing separate keypairs.
use solana_zk_sdk::encryption::elgamal::{ElGamalKeypair, ElGamalPubkey};let keypair = ElGamalKeypair::new_rand();let pubkey = keypair.pubkey();// Encrypt an amountlet amount = 1000u64;let ciphertext = pubkey.encrypt(amount);// Ciphertext is randomized - encrypting the same value twice// produces different ciphertextslet ciphertext2 = pubkey.encrypt(amount);assert_ne!(ciphertext.to_bytes(), ciphertext2.to_bytes());
Decrypt values that fit in 32 bits (up to 2^32 - 1):
use solana_zk_sdk::encryption::elgamal::ElGamalKeypair;let keypair = ElGamalKeypair::new_rand();let pubkey = keypair.pubkey();let secret = keypair.secret();// Encrypt a valuelet amount = 42u64;let ciphertext = pubkey.encrypt(amount);// Decrypt back to the original valuelet decrypted = secret.decrypt_u32(&ciphertext);assert_eq!(decrypted, Some(amount));
The decrypt_u32 method returns None if:
The value is larger than 2^32 - 1
The ciphertext was encrypted under a different public key
The discrete logarithm cannot be computed
This method is not constant-time and should only be used after verification.
use solana_zk_sdk::encryption::elgamal::ElGamalKeypair;let keypair = ElGamalKeypair::new_rand();let secret = keypair.secret();let ciphertext = keypair.pubkey().encrypt(1_000_000u64);// Returns a DiscreteLog instancelet discrete_log = secret.decrypt(&ciphertext);// Solve the discrete log with default settings (single-threaded)let decrypted = discrete_log.decode_u32();assert_eq!(decrypted, Some(1_000_000));
Multi-threaded decryption:
let mut discrete_log = secret.decrypt(&ciphertext);// Use 4 threads for faster decryptiondiscrete_log.num_threads(4.try_into().unwrap())?;let decrypted = discrete_log.decode_u32()?;
let ciphertext = pubkey.encrypt(100u64);// Add 50 to the encrypted valuelet ciphertext_plus_50 = ciphertext.add_amount(50u64);// Decrypts to 150assert_eq!(secret.decrypt_u32(&ciphertext_plus_50), Some(150));
Since messages are encrypted “in the exponent”, decryption requires solving a discrete logarithm. This limits practical decryption to values up to ~2^32. For confidential token amounts, this is not a limitation.
// Zero scalars are rejectedpub fn new(secret: &ElGamalSecretKey) -> Self { let s = &secret.0; assert!(s != &Scalar::ZERO); // Panics on zero ElGamalPubkey(s.invert() * &(*H))}
// Split an encrypted amount into two partslet total = pubkey.encrypt(1000u64);let part1 = pubkey.encrypt(600u64);let part2 = total - part1;assert_eq!(secret.decrypt_u32(&part2), Some(400));