Every company that encrypts its data eventually faces the same awkward moment: the person who held the key is gone. An employee leaves, a laptop is stolen, a phone falls into the sea, and everything encrypted with that person’s key becomes unreadable forever. The obvious fix, one company-wide key for everybody, is worse: then every employee can read every other employee’s data. What we really want is what hotels have done for decades. Every guest gets their own room key, which opens only their own room, while the manager holds a master key that opens every room in the building.

In this post, we’ll build exactly that with homomorphic encryption: every user encrypts with their own key pair and decrypts only their own data, while an authority holding a master key can decrypt the data of all users. We’ll use LightPHE in Python, Go, Java and TypeScript. And since a master key is, by design, a key that reads everything, we’ll also be honest about when you should and shouldn’t build one.
🙋‍♂️ You may consider to enroll my top-rated cryptography course on Udemy
How It Works: Two Trapdoors in One Ciphertext
The cryptosystem behind this is Bresson-Catalano-Pointcheval (BCP), from A Simple Public-Key Cryptosystem with a Double Trapdoor Decryption Mechanism and Its Applications (ASIACRYPT 2003). You can think of it as a mix of two old friends: it encrypts like ElGamal, but it lives in the same modular world as Paillier.
authority: n = p * q, the product of two secret safe primes, and a generator g
publishes n and g, and keeps p and q as the master key
user: picks a secret a as its own private key
publishes h = g^a mod n^2 as its own public key
encrypt m: A = g^r mod n^2
B = h^r * (1 + m n) mod n^2 for a random r
Such a ciphertext can be opened in two independent ways:
- The local trapdoor, the user’s own key. A user who knows
acomputesA^a = g^(ar) = h^r, soB / A^a = 1 + m n, and readingmfrom that is trivial. This is plain ElGamal-style decryption. - The master trapdoor, the factorization of n. The authority doesn’t know
aorr. But knowingpandq, it can compute discrete logarithms modulonin this group—the same trick that makes Paillier decryption work. So it recoversa mod nfrom the user’s public keyh, andr mod nfromA, which is enough to strip the mask offBand readm.
Everybody else is stuck: another user doesn’t know a, and doesn’t know the factorization of n either. The most elegant part is how users get their keys. A user needs only the authority’s public key, n and g, to generate a key pair, so the authority never sees, stores or hands out anyone’s private key—and still can open every ciphertext. The escrow lives in the math, not in a database of copied keys.
Required Cryptographic Features
1. A double trapdoor. The same ciphertext must be decryptable both with a per-user secret and with a single master secret, and the two must be independent: knowing your own key must not help you find the master key, or anybody else’s key.
2. Shared public parameters with self-generated user keys. All users build their keys on top of the authority’s public n and g, with the public key alone. That’s what lets one master key open them all, while every user’s private key stays on the user’s own device.
3. Homomorphism that both trapdoors understand. BCP is additively homomorphic and supports multiplication with plain constants, like Paillier. A user can add up their own ciphertexts, e.g. monthly expenses into a total, and both the user and the master key decrypt the result. One subtlety: this works within the ciphertexts of a single user. Each user masks with their own h, so adding Alice’s ciphertext to Bob’s produces garbage that nobody can decrypt—not even the master key. LightPHE doesn’t stop you from doing it, so be careful.
4. A carefully generated modulus. The master trapdoor needs p and q to be safe primes, and g to have the largest possible order. LightPHE takes care of this when the authority generates its keys, in about half a second for a 1024-bit key on my laptop.
Which Algorithms Can Be Used, and Why
Out of the more than 25 cryptosystems in LightPHE, only Bresson-Catalano-Pointcheval supports master key decryption. I asked the others for user keys, and LightPHE refused each of them with does not support generating user keys sharing a master key. The reason is that the others have a single trapdoor:
| Algorithms | Master key decryption | Why |
| Bresson-Catalano-Pointcheval | âś… | Two independent trapdoors: a discrete logarithm per user, and the factorization of n for the authority |
| Paillier, Damgard-Jurik | ❌ | The only trapdoor is the factorization of n. If users shared one n, whoever can decrypt one user’s data can decrypt everybody’s, so there is no room for personal keys |
| ElGamal, Exponential ElGamal, Elliptic Curve ElGamal | ❌ | Every user can have a personal key in a shared group, but discrete logarithms are hard for everybody, including any would-be authority, so there is no master trapdoor. BCP fixes exactly this by moving ElGamal into a group where the factorization makes discrete logarithms easy for the authority |
| RSA and the others | ❌ | A single trapdoor per key pair, so a master key would have to be a copy of every user’s private key |
That last point is the naive alternative, and it works with any algorithm: the authority generates every user’s key pair and keeps a copy of each private key. But then the authority stores thousands of private keys instead of one, has to be online for every new user, and knows every key from day one. With BCP, the authority keeps a single secret, ideally offline, and users generate their own keys without ever talking to it.
Example 1: Employees and Their Company
A company publishes its public key, and every employee generates their own key pair from it. Employees encrypt their expense claims with their own keys, and add them up homomorphically. Each employee reads only their own claims, and when an employee leaves, the company can still read theirs with the master key.
from lightphe import LightPHE
# company generates the master key, and publishes its public key
company_cs = LightPHE(algorithm_name = "Bresson-Catalano-Pointcheval")
company_cs.export_keys(target_file = "public.txt", public = True)
# each employee generates its own key pair with the company's public key only
public_cs = LightPHE(algorithm_name = "Bresson-Catalano-Pointcheval", key_file = "public.txt")
alice_cs = LightPHE(algorithm_name = "Bresson-Catalano-Pointcheval", keys = public_cs.generate_user_keys())
bob_cs = LightPHE(algorithm_name = "Bresson-Catalano-Pointcheval", keys = public_cs.generate_user_keys())
# employees encrypt their expense claims with their own keys, and sum them homomorphically
alice_expenses = alice_cs.encrypt(100) + alice_cs.encrypt(50)
bob_expenses = bob_cs.encrypt(200)
# each employee decrypts its own ciphertexts
assert alice_cs.decrypt(alice_expenses) == 150
assert bob_cs.decrypt(bob_expenses) == 200
# but not each other's - alice gets a meaningless number for bob's claims
assert alice_cs.decrypt(bob_expenses) != 200
# and employees cannot decrypt with the master key
try:
alice_cs.decrypt_with_master_key(bob_expenses)
raise AssertionError("employees must not have the master key")
except ValueError as err:
print(err) # Private key does not have the master trapdoor p and q
# company decrypts the ciphertexts of every employee with the master key
assert company_cs.decrypt_with_master_key(alice_expenses) == 150
assert company_cs.decrypt_with_master_key(bob_expenses) == 200
package main
import (
"fmt"
"log"
"github.com/serengil/lightphe-go/lightphe"
)
func check(err error) {
if err != nil {
log.Fatal(err)
}
}
func main() {
// company generates the master key, and publishes its public key
companyCs, err := lightphe.New(lightphe.BressonCatalanoPointcheval)
check(err)
check(companyCs.ExportKeys("public.txt", true))
// each employee generates its own key pair with the company's public key only
publicCs, err := lightphe.New(lightphe.BressonCatalanoPointcheval, lightphe.WithKeyFile("public.txt"))
check(err)
newEmployee := func() *lightphe.Cryptosystem {
keys, err := publicCs.GenerateUserKeys()
check(err)
employeeCs, err := lightphe.New(lightphe.BressonCatalanoPointcheval, lightphe.WithKeys(keys))
check(err)
return employeeCs
}
aliceCs := newEmployee()
bobCs := newEmployee()
// employees encrypt their expense claims with their own keys, and sum them homomorphically
c1, err := aliceCs.EncryptInt(100)
check(err)
c2, err := aliceCs.EncryptInt(50)
check(err)
aliceExpenses, err := c1.Add(c2)
check(err)
bobExpenses, err := bobCs.EncryptInt(200)
check(err)
// each employee decrypts its own ciphertexts
m, err := aliceCs.Decrypt(aliceExpenses)
check(err)
fmt.Println(m) // 150
m, err = bobCs.Decrypt(bobExpenses)
check(err)
fmt.Println(m) // 200
// but not each other's - alice gets a meaningless number for bob's claims
m, err = aliceCs.Decrypt(bobExpenses)
check(err)
fmt.Println(m.Int64() == 200) // false
// and employees cannot decrypt with the master key
if _, err := aliceCs.DecryptWithMasterKey(bobExpenses); err != nil {
fmt.Println(err) // private key does not have the master trapdoor
} else {
log.Fatal("employees must not have the master key")
}
// company decrypts the ciphertexts of every employee with the master key
m, err = companyCs.DecryptWithMasterKey(aliceExpenses)
check(err)
fmt.Println(m) // 150
m, err = companyCs.DecryptWithMasterKey(bobExpenses)
check(err)
fmt.Println(m) // 200
}
import io.github.serengil.lightphe.LightPHE;
import io.github.serengil.lightphe.model.Ciphertext;
import java.math.BigInteger;
import java.nio.file.Path;
// company generates the master key, and publishes its public key
LightPHE companyCs = LightPHE.builder().algorithmName("Bresson-Catalano-Pointcheval").build();
companyCs.exportKeys(Path.of("public.txt"), true);
// each employee generates its own key pair with the company's public key only
LightPHE publicCs = LightPHE.builder()
.algorithmName("Bresson-Catalano-Pointcheval")
.keyFile(Path.of("public.txt"))
.build();
LightPHE aliceCs = LightPHE.builder()
.algorithmName("Bresson-Catalano-Pointcheval")
.keys(publicCs.generateUserKeys())
.build();
LightPHE bobCs = LightPHE.builder()
.algorithmName("Bresson-Catalano-Pointcheval")
.keys(publicCs.generateUserKeys())
.build();
// employees encrypt their expense claims with their own keys, and sum them homomorphically
Ciphertext aliceExpenses = aliceCs.encrypt(100).add(aliceCs.encrypt(50));
Ciphertext bobExpenses = bobCs.encrypt(200);
// each employee decrypts its own ciphertexts
assert aliceCs.decrypt(aliceExpenses).equals(BigInteger.valueOf(150));
assert bobCs.decrypt(bobExpenses).equals(BigInteger.valueOf(200));
// but not each other's - alice gets a meaningless number for bob's claims
assert !aliceCs.decrypt(bobExpenses).equals(BigInteger.valueOf(200));
// and employees cannot decrypt with the master key
try {
aliceCs.decryptWithMasterKey(bobExpenses);
throw new AssertionError("employees must not have the master key");
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // Private key does not have the master trapdoor p and q
}
// company decrypts the ciphertexts of every employee with the master key
assert companyCs.decryptWithMasterKey(aliceExpenses).equals(BigInteger.valueOf(150));
assert companyCs.decryptWithMasterKey(bobExpenses).equals(BigInteger.valueOf(200));
import { LightPHE } from 'lightphe-ts';
// company generates the master key, and publishes its public key
const companyCs = new LightPHE({ algorithmName: 'Bresson-Catalano-Pointcheval' });
companyCs.exportKeys('public.txt', true);
// each employee generates its own key pair with the company's public key only
const publicCs = new LightPHE({ algorithmName: 'Bresson-Catalano-Pointcheval', keyFile: 'public.txt' });
const newEmployee = () =>
new LightPHE({ algorithmName: 'Bresson-Catalano-Pointcheval', keys: publicCs.generateUserKeys() });
const aliceCs = newEmployee();
const bobCs = newEmployee();
// employees encrypt their expense claims with their own keys, and sum them homomorphically
const aliceExpenses = aliceCs.encrypt(100).add(aliceCs.encrypt(50));
const bobExpenses = bobCs.encrypt(200);
// each employee decrypts its own ciphertexts
console.assert(aliceCs.decrypt(aliceExpenses) === 150n);
console.assert(bobCs.decrypt(bobExpenses) === 200n);
// but not each other's - alice gets a meaningless number for bob's claims
console.assert(aliceCs.decrypt(bobExpenses) !== 200n);
// and employees cannot decrypt with the master key
try {
aliceCs.decryptWithMasterKey(bobExpenses);
throw new Error('employees must not have the master key');
} catch (err) {
console.log((err as Error).message); // Private key does not have the master trapdoor p and q
}
// company decrypts the ciphertexts of every employee with the master key
console.assert(companyCs.decryptWithMasterKey(aliceExpenses) === 150n);
console.assert(companyCs.decryptWithMasterKey(bobExpenses) === 200n);
Note what Alice gets when she tries to decrypt Bob’s claims: not an error, but a meaningless number hundreds of digits long. Her key simply doesn’t fit his lock. When she reaches for the master key, LightPHE refuses, because her private key has her own trapdoor a only, not p and q. Java snippets use assert for brevity, so run them with -ea to enable assertions.
Example 2: Recovering a Lost Key
A savings app lets its users keep their data encrypted on their own phones. Bob encrypts his monthly contributions to three savings goals, and projects them over a year with homomorphic multiplication. Then Bob drops his phone into the sea, and his private key goes with it. After Bob proves his identity, the app recovers his data with its master key, and Bob starts over with a fresh key pair.
from lightphe import LightPHE # a savings app generates the master key, and publishes its public key app_cs = LightPHE(algorithm_name = "Bresson-Catalano-Pointcheval") app_cs.export_keys(target_file = "public.txt", public = True) # bob generates his own key pair on his phone with the app's public key only public_cs = LightPHE(algorithm_name = "Bresson-Catalano-Pointcheval", key_file = "public.txt") bob_cs = LightPHE(algorithm_name = "Bresson-Catalano-Pointcheval", keys = public_cs.generate_user_keys()) # bob encrypts his monthly contributions to three savings goals in cents, and projects a year monthly = [bob_cs.encrypt(cents) for cents in [12050, 8990, 23025]] yearly = [contribution * 12 for contribution in monthly] assert [bob_cs.decrypt(c) for c in yearly] == [144600, 107880, 276300] # bob loses his phone, and his private key with it. # after verifying his identity, the app recovers his data with the master key assert [app_cs.decrypt_with_master_key(c) for c in yearly] == [144600, 107880, 276300]
package main
import (
"fmt"
"log"
"github.com/serengil/lightphe-go/lightphe"
)
func check(err error) {
if err != nil {
log.Fatal(err)
}
}
func main() {
// a savings app generates the master key, and publishes its public key
appCs, err := lightphe.New(lightphe.BressonCatalanoPointcheval)
check(err)
check(appCs.ExportKeys("public.txt", true))
// bob generates his own key pair on his phone with the app's public key only
publicCs, err := lightphe.New(lightphe.BressonCatalanoPointcheval, lightphe.WithKeyFile("public.txt"))
check(err)
bobKeys, err := publicCs.GenerateUserKeys()
check(err)
bobCs, err := lightphe.New(lightphe.BressonCatalanoPointcheval, lightphe.WithKeys(bobKeys))
check(err)
// bob encrypts his monthly contributions to three savings goals in cents, and projects a year
var yearly []*lightphe.Ciphertext
for _, cents := range []int64{12050, 8990, 23025} {
monthly, err := bobCs.EncryptInt(cents)
check(err)
annual, err := monthly.MultiplyByInt(12)
check(err)
yearly = append(yearly, annual)
}
for _, c := range yearly {
m, err := bobCs.Decrypt(c)
check(err)
fmt.Println(m) // 144600, 107880, 276300
}
// bob loses his phone, and his private key with it.
// after verifying his identity, the app recovers his data with the master key
for _, c := range yearly {
m, err := appCs.DecryptWithMasterKey(c)
check(err)
fmt.Println(m) // 144600, 107880, 276300
}
}
import io.github.serengil.lightphe.LightPHE;
import io.github.serengil.lightphe.model.Ciphertext;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.List;
// a savings app generates the master key, and publishes its public key
LightPHE appCs = LightPHE.builder().algorithmName("Bresson-Catalano-Pointcheval").build();
appCs.exportKeys(Path.of("public.txt"), true);
// bob generates his own key pair on his phone with the app's public key only
LightPHE publicCs = LightPHE.builder()
.algorithmName("Bresson-Catalano-Pointcheval")
.keyFile(Path.of("public.txt"))
.build();
LightPHE bobCs = LightPHE.builder()
.algorithmName("Bresson-Catalano-Pointcheval")
.keys(publicCs.generateUserKeys())
.build();
// bob encrypts his monthly contributions to three savings goals in cents, and projects a year
List<Ciphertext> yearly = List.of(12050L, 8990L, 23025L).stream()
.map(cents -> bobCs.encrypt(cents).multiply(12))
.toList();
List<BigInteger> expected = List.of(144600L, 107880L, 276300L).stream()
.map(BigInteger::valueOf)
.toList();
assert yearly.stream().map(bobCs::decrypt).toList().equals(expected);
// bob loses his phone, and his private key with it.
// after verifying his identity, the app recovers his data with the master key
assert yearly.stream().map(appCs::decryptWithMasterKey).toList().equals(expected);
import { LightPHE } from 'lightphe-ts';
// a savings app generates the master key, and publishes its public key
const appCs = new LightPHE({ algorithmName: 'Bresson-Catalano-Pointcheval' });
appCs.exportKeys('public.txt', true);
// bob generates his own key pair on his phone with the app's public key only
const publicCs = new LightPHE({ algorithmName: 'Bresson-Catalano-Pointcheval', keyFile: 'public.txt' });
const bobCs = new LightPHE({
algorithmName: 'Bresson-Catalano-Pointcheval',
keys: publicCs.generateUserKeys(),
});
// bob encrypts his monthly contributions to three savings goals in cents, and projects a year
const yearly = [12050, 8990, 23025].map((cents) => bobCs.encrypt(cents).multiply(12));
console.assert(yearly.map((c) => bobCs.decrypt(c)).join() === '144600,107880,276300');
// bob loses his phone, and his private key with it.
// after verifying his identity, the app recovers his data with the master key
console.assert(yearly.map((c) => appCs.decryptWithMasterKey(c)).join() === '144600,107880,276300');
Amounts are kept in cents, as integers, which is a good habit for money anyway. The master key decrypts the result of homomorphic operations just like the user’s own key does, so the app recovers the yearly projections, not only the raw contributions. Master key decryption works on encrypted tensors as well, and it’s cheap: about 57 milliseconds per ciphertext with a 1024-bit key on my laptop, against 22 milliseconds with the user’s own key.
Master Key vs Splitting a Key Into Pieces
In our previous post, we split a private key into pieces, so that any 3 of 5 directors could decrypt together. A master key is the mirror image of that idea:
| Master key decryption | Splitting a key into pieces | |
| Shape | One key opens the data of many users | Many pieces are needed to open the data of one key |
| Who can decrypt | Each user alone, or the authority alone | Only a quorum of shareholders together |
| Main risk | The master key holder reads everything | A quorum of colluding shareholders reads everything |
| Algorithms in LightPHE | Bresson-Catalano-Pointcheval | ElGamal family, Paillier, Damgard-Jurik |
It’s tempting to combine them, so that no single person holds the master key. LightPHE doesn’t support splitting a BCP key into pieces, so for now, protect the master key the old-fashioned way: keep it offline, e.g. in a hardware security module, and log every use.
More Scenarios
- Employee offboarding: Employees encrypt their work files and figures with their own keys, so colleagues can’t read each other’s data, and the company doesn’t lose anything when someone leaves.
- Account recovery: A consumer app keeps user data encrypted on devices, and offers recovery after a lost device without storing a copy of every user’s private key.
- Regulatory retention: In regulated industries, firms must be able to produce records when auditors ask. Each department encrypts its own records, and the compliance office holds the master key.
- Device fleets: Every sensor in a fleet encrypts its readings with its own key, so a compromised sensor exposes only its own data, while the manufacturer can still decrypt any device’s readings for diagnostics.
Caveats
- A master key is a backdoor by design: Whoever holds it reads everything, and users can’t opt out once their keys are built on the authority’s parameters. That’s fine when users know and agree, as in a company or a recovery feature they signed up for. It’s not fine as a hidden backdoor, and the history of key escrow proposals, such as the Clipper chip of the 1990s, shows how controversial forced escrow is. Be transparent about it.
- One secret to rule them all: If the master key leaks, every user’s data is exposed at once, and new user keys don’t help, since they are built on the same
n. Protect it accordingly, and plan how you would rotate to a new authority key. - Don’t mix users’ ciphertexts: Homomorphic operations work within a single user’s ciphertexts. Adding ciphertexts of different users produces garbage, silently.
Conclusion
Master key decryption gives every user a key of their own, and an authority a key to all of them, just like the rooms and the master key of a hotel. Bresson-Catalano-Pointcheval makes this possible with two independent trapdoors in the same ciphertext: a discrete logarithm that belongs to each user, and the factorization of a shared modulus that belongs to the authority. Users generate their own keys from the authority’s public key alone, so the authority never holds anybody’s private key, yet it can still decrypt everybody’s data, including the results of homomorphic operations.
Used openly, that’s a clean answer to lost keys and departing employees. Used quietly, it’s a backdoor. The math is the same either way, so the responsibility lies with whoever holds the master key—and with LightPHE, it takes only a few lines of Python, Go, Java or TypeScript to build.
Support this blog financially if you do like!
