Federated learning makes a beautiful promise: the data never leaves home. Hospitals, phones or banks train a shared model on their own private data, and send only their model updates to a central server, which averages them into a better global model. No patient records, no keystrokes, no transactions are ever uploaded. The catch is that model updates are not as innocent as they look. They are computed from the data, and researchers have shown in Deep Leakage from Gradients that the training data itself can be reconstructed from shared gradients, sometimes pixel by pixel.

So the server shouldn’t see individual updates either. Here is the good news: it never needed them in the first place, only their average. Secure aggregation gives the server exactly that and nothing more: clients encrypt their updates, the server adds them up without decrypting them, and only the sum is ever decrypted. In this post, we’ll build it with homomorphic encryption using LightPHE in Python, Go, Java and TypeScript, and finish with a model that learns from four clients without anyone seeing their data or their updates.
🙋♂️ You may consider to enroll my top-rated cryptography course on Udemy
Federated Learning in 30 Seconds
The most common recipe is federated averaging, from Communication-Efficient Learning of Deep Networks from Decentralized Data, the paper that coined the term federated learning. Each round looks like this:
1. server sends the current global model to the clients 2. each client trains it on its own data for a while, and gets a local model 3. each client sends its local model, or the difference to the global model, to the server 4. server averages them: global model = (local model_1 + ... + local model_k) / k 5. repeat
Step 4 is the only place the server touches the updates, and it is a plain sum divided by a public count. A sum is exactly what additively homomorphic encryption is good at.
Required Homomorphic Features
1. Additive homomorphism. Multiplying ciphertexts adds the plaintexts, so the server turns E(u_1), E(u_2), ..., E(u_k) into E(u_1 + u_2 + ... + u_k) with the public key alone. The number of clients is public, so dividing the decrypted sum by it gives the average.
2. Quantization. Model weights are floats, while homomorphic encryption works on integers. Each value is scaled by 10^5 and rounded, keeping 5 decimal digits, and every client declares a max_value, so all quantized values fall into a known range [-B, B]. They are then shifted by B to be non-negative.
3. Packing, the feature that makes it practical. A real model has thousands or millions of weights. Encrypting each one separately would be painfully slow, and a waste: a 1024-bit Paillier plaintext can hold a number with over 300 decimal digits, while a quantized weight needs about 6. So LightPHE packs many values into one plaintext, each in its own fixed-width slot, the way BatchCrypt does for cross-silo federated learning:
plaintext = (x_1 + B) + (x_2 + B) * 2^s + (x_3 + B) * 2^(2s) + ... + (x_40 + B) * 2^(39s) each slot is s bits wide, with room for the sum of max_clients values: s = bits(max_clients * 2B)
Adding two packed ciphertexts adds every slot at once, like 40 additions for the price of one. The slots are sized with headroom for max_clients updates, so a sum never overflows from one slot into the next, and the server refuses to sum more updates than that. With 1024-bit keys, 40 values fit into a ciphertext. On my laptop, encrypting an update of 1000 values took 0.49 seconds as 25 packed ciphertexts, against 19.85 seconds as 1000 separate ones—about 40 times faster, and 40 times less data to send.
4. Threshold decryption with proofs. Somebody has to decrypt the sum, and it must not be someone who could also decrypt individual updates. So the private key is split among a committee, e.g. 5 independent organizations, any 3 of whom decrypt the sum together, as we covered in our previous post on splitting a private key into pieces. Each member proves its partial decryption, so a dishonest member can’t slip a manipulated average into the global model. And the members only ever decrypt aggregates.
5. Tolerance to dropouts. Phones go offline, hospitals have outages. A client that drops out is simply not summed, as the sum keeps its own count, and the committee still decrypts the sum as long as 3 of its 5 members show up.
This isn’t the only way to do secure aggregation. Google’s protocol by Bonawitz et al., Practical Secure Aggregation for Privacy-Preserving Machine Learning, takes a different route, with pairwise random masks between clients that cancel out in the sum instead of encryption. The homomorphic route is simpler to reason about and suits a modest number of clients, such as hospitals or banks, very well.
Which Algorithms Can Be Used, and Why
Secure aggregation needs a large plaintext space that decrypts directly, for packing, and threshold decryption, for the committee. Only two cryptosystems in LightPHE have both. I tried the others, and LightPHE refused them with does not support secure aggregation. Use Paillier or Damgard-Jurik instead:
| Algorithms | Secure aggregation | Why |
| Paillier | ✅ | Large plaintext space with direct decryption, so 40 values fit into a 1024-bit ciphertext, and threshold decryption with proofs when keys are generated with safe_primes = True |
| Damgard-Jurik | ✅ | Same as Paillier, with an even larger plaintext space: 53 values per ciphertext with default keys in my run |
| Exponential ElGamal, Elliptic Curve ElGamal | ❌ | They support threshold decryption, but decrypting means solving a discrete logarithm, which is only feasible for small numbers, never for a packed plaintext of hundreds of digits |
| Okamoto-Uchiyama, Benaloh, Joye-Libert, Bresson-Catalano-Pointcheval | ❌ | Additively homomorphic, but LightPHE has no threshold decryption for them, so there could be no committee |
| Damgard-Geisler-Kroigaard | ❌ | A 32-bit plaintext space is far too small for packing, and no threshold decryption either |
| RSA, ElGamal, Goldwasser-Micali and others | ❌ | Not additively homomorphic, so they cannot sum updates at all |
Example 1: One Round Among Hospitals
Three hospitals train a tumor detection model on their own scans. A committee of 5 independent organizations holds the shares of the private key, any 3 of which can decrypt. The hospitals encrypt their model updates, the server adds them up, and the committee decrypts only the sum. We keep the model to 3 weights to make the numbers readable, and then encrypt a realistic update of 1000 weights to see packing at work.
from lightphe import LightPHE # dealer splits the key among a committee of 5 independent organizations, any 3 can decrypt dealer_cs = LightPHE(algorithm_name = "Paillier", safe_primes = True) share_keys = dealer_cs.generate_threshold_keys(threshold = 3, parties = 5) dealer_cs.export_keys(target_file = "public.txt", public = True) committee = [LightPHE(algorithm_name = "Paillier", keys = keys) for keys in share_keys] # each hospital trains on its own scans, and encrypts its model update with the public key. # values must be in [-max_value, max_value], and up to max_clients updates can be summed hospital_cs = LightPHE(algorithm_name = "Paillier", key_file = "public.txt") updates = [[0.12, -0.53, 0.98], [0.33, 0.21, -0.47], [-0.25, 0.08, 0.61]] encrypted_updates = [hospital_cs.encrypt_update(update, max_value = 1, max_clients = 100) for update in updates] # server sums the encrypted updates without decrypting them server_cs = LightPHE(algorithm_name = "Paillier", key_file = "public.txt") encrypted_sum = server_cs.aggregate_updates(encrypted_updates) # any 3 committee members decrypt only the sum, and prove their partial decryptions partials = [member.partial_decrypt_aggregate(encrypted_sum, prove = True) for member in committee[:3]] result = server_cs.combine_aggregate(encrypted_sum, partials, verify = True) assert result["count"] == 3 assert [round(x, 2) for x in result["mean"]] == [0.07, -0.08, 0.37] # a real model has thousands of weights, and packing puts 40 of them into a single ciphertext large_update = hospital_cs.encrypt_update([0.001] * 1000, max_value = 1, max_clients = 100) assert len(large_update["chunks"]) == 25
package main
import (
"fmt"
"log"
"os"
"github.com/serengil/lightphe-go/lightphe"
)
func check(err error) {
if err != nil {
log.Fatal(err)
}
}
func main() {
// dealer splits the key among a committee of 5 independent organizations, any 3 can decrypt
dealerCs, err := lightphe.New(lightphe.Paillier, lightphe.WithSafePrimes(true))
check(err)
publicKey, shareKeys, err := dealerCs.GenerateThresholdKeys(3, 5)
check(err)
check(os.WriteFile("public.txt", publicKey, 0644))
var committee []*lightphe.Cryptosystem
for _, keys := range shareKeys {
member, err := lightphe.New(lightphe.Paillier, lightphe.WithKeys(keys))
check(err)
committee = append(committee, member)
}
// each hospital trains on its own scans, and encrypts its model update with the public key.
// values must be in [-maxValue, maxValue], and up to maxClients updates can be summed
hospitalCs, err := lightphe.New(lightphe.Paillier, lightphe.WithKeyFile("public.txt"))
check(err)
updates := [][]float64{{0.12, -0.53, 0.98}, {0.33, 0.21, -0.47}, {-0.25, 0.08, 0.61}}
var encryptedUpdates []*lightphe.EncryptedUpdate
for _, update := range updates {
encryptedUpdate, err := hospitalCs.EncryptUpdate(update, 1, 100)
check(err)
encryptedUpdates = append(encryptedUpdates, encryptedUpdate)
}
// server sums the encrypted updates without decrypting them
serverCs, err := lightphe.New(lightphe.Paillier, lightphe.WithKeyFile("public.txt"))
check(err)
encryptedSum, err := serverCs.AggregateUpdates(encryptedUpdates)
check(err)
// any 3 committee members decrypt only the sum, and prove their partial decryptions
var partials [][]*lightphe.PartialDecryption
for _, member := range committee[:3] {
memberPartials, err := member.PartialDecryptAggregate(encryptedSum, true)
check(err)
partials = append(partials, memberPartials)
}
result, err := serverCs.CombineAggregate(encryptedSum, partials, true)
check(err)
fmt.Printf("%d updates, mean %.2f\n", result.Count, result.Mean) // 3 updates, mean [0.07 -0.08 0.37]
// a real model has thousands of weights, and packing puts 40 of them into a single ciphertext
largeUpdate := make([]float64, 1000)
for i := range largeUpdate {
largeUpdate[i] = 0.001
}
encryptedLargeUpdate, err := hospitalCs.EncryptUpdate(largeUpdate, 1, 100)
check(err)
fmt.Println(len(encryptedLargeUpdate.Chunks), "ciphertexts") // 25 ciphertexts
}
import io.github.serengil.lightphe.LightPHE;
import io.github.serengil.lightphe.model.Aggregate;
import io.github.serengil.lightphe.model.EncryptedUpdate;
import io.github.serengil.lightphe.model.Keys;
import io.github.serengil.lightphe.model.PartialDecryption;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
// dealer splits the key among a committee of 5 independent organizations, any 3 can decrypt
LightPHE dealerCs = LightPHE.builder().algorithmName("Paillier").safePrimes(true).build();
List<Keys> shareKeys = dealerCs.generateThresholdKeys(3, 5);
dealerCs.exportKeys(Path.of("public.txt"), true);
List<LightPHE> committee = shareKeys.stream()
.map(keys -> LightPHE.builder().algorithmName("Paillier").keys(keys).build())
.toList();
// each hospital trains on its own scans, and encrypts its model update with the public key.
// values must be in [-maxValue, maxValue], and up to maxClients updates can be summed
LightPHE hospitalCs = LightPHE.builder().algorithmName("Paillier").keyFile(Path.of("public.txt")).build();
List<List<Double>> updates = List.of(
List.of(0.12, -0.53, 0.98), List.of(0.33, 0.21, -0.47), List.of(-0.25, 0.08, 0.61));
List<EncryptedUpdate> encryptedUpdates = updates.stream()
.map(update -> hospitalCs.encryptUpdate(update, 1, 100))
.toList();
// server sums the encrypted updates without decrypting them
LightPHE serverCs = LightPHE.builder().algorithmName("Paillier").keyFile(Path.of("public.txt")).build();
EncryptedUpdate encryptedSum = serverCs.aggregateUpdates(encryptedUpdates);
// any 3 committee members decrypt only the sum, and prove their partial decryptions
List<List<PartialDecryption>> partials = committee.subList(0, 3).stream()
.map(member -> member.partialDecryptAggregate(encryptedSum, true))
.toList();
Aggregate result = serverCs.combineAggregate(encryptedSum, partials, true);
assert result.count() == 3;
assert result.mean().stream().map(x -> Math.round(x * 100) / 100.0).toList()
.equals(List.of(0.07, -0.08, 0.37));
// a real model has thousands of weights, and packing puts 40 of them into a single ciphertext
EncryptedUpdate largeUpdate = hospitalCs.encryptUpdate(Collections.nCopies(1000, 0.001), 1, 100);
assert largeUpdate.chunks().size() == 25;
import { LightPHE } from 'lightphe-ts';
// dealer splits the key among a committee of 5 independent organizations, any 3 can decrypt
const dealerCs = new LightPHE({ algorithmName: 'Paillier', safePrimes: true });
const shareKeys = dealerCs.generateThresholdKeys(3, 5);
dealerCs.exportKeys('public.txt', true);
const committee = shareKeys.map((keys) => new LightPHE({ algorithmName: 'Paillier', keys }));
// each hospital trains on its own scans, and encrypts its model update with the public key.
// values must be in [-maxValue, maxValue], and up to maxClients updates can be summed
const hospitalCs = new LightPHE({ algorithmName: 'Paillier', keyFile: 'public.txt' });
const updates = [
[0.12, -0.53, 0.98],
[0.33, 0.21, -0.47],
[-0.25, 0.08, 0.61],
];
const encryptedUpdates = updates.map((update) => hospitalCs.encryptUpdate(update, 1, 100));
// server sums the encrypted updates without decrypting them
const serverCs = new LightPHE({ algorithmName: 'Paillier', keyFile: 'public.txt' });
const encryptedSum = serverCs.aggregateUpdates(encryptedUpdates);
// any 3 committee members decrypt only the sum, and prove their partial decryptions
const partials = committee
.slice(0, 3)
.map((member) => member.partialDecryptAggregate(encryptedSum, { prove: true }));
const result = serverCs.combineAggregate(encryptedSum, partials, { verify: true });
console.assert(result.count === 3);
console.assert(result.mean.map((x) => x.toFixed(2)).join() === '0.07,-0.08,0.37');
// a real model has thousands of weights, and packing puts 40 of them into a single ciphertext
const largeUpdate = hospitalCs.encryptUpdate(new Array(1000).fill(0.001), 1, 100);
console.assert(largeUpdate.chunks.length === 25);
The committee gets the average update [0.07, -0.08, 0.37] and the count of 3, and nothing else: not which hospital contributed what, not even whether one of them sent an outlier. The 1000-weight update fits into 25 ciphertexts, which is what each hospital uploads instead of 1000. Java snippets use assert for brevity, so run them with -ea to enable assertions.
Example 2: Training a Model Without Seeing Anyone’s Data
Now let’s run real federated learning, round after round. Four clients hold different slices of private data, all following the hidden relationship y = 2x - 1, and want to learn a linear model y = w x + b together. In each round, every client takes a gradient descent step on its own data, encrypts its local model, and the committee decrypts only the average, which becomes the next global model.
from lightphe import LightPHE
# a committee of 3, any 2 of them decrypt the average model of each round
dealer_cs = LightPHE(algorithm_name = "Paillier", safe_primes = True)
share_keys = dealer_cs.generate_threshold_keys(threshold = 2, parties = 3)
dealer_cs.export_keys(target_file = "public.txt", public = True)
committee = [LightPHE(algorithm_name = "Paillier", keys = keys) for keys in share_keys]
public_cs = LightPHE(algorithm_name = "Paillier", key_file = "public.txt")
# 4 clients hold different slices of private data, all following y = 2x - 1
slices = [[-1.0, -0.9, -0.8], [-0.4, -0.3, -0.2], [0.2, 0.3, 0.4], [0.8, 0.9, 1.0]]
clients = [(xs, [2 * x - 1 for x in xs]) for xs in slices]
# global model y = w x + b starts from zero
w, b = 0.0, 0.0
for _ in range(30):
encrypted_updates = []
for xs, ys in clients:
# each client takes one gradient descent step on its own data, and encrypts its local model
grad_w = sum(2 * (w * x + b - y) * x for x, y in zip(xs, ys)) / len(xs)
grad_b = sum(2 * (w * x + b - y) for x, y in zip(xs, ys)) / len(xs)
local_model = [w - 0.5 * grad_w, b - 0.5 * grad_b]
encrypted_updates.append(public_cs.encrypt_update(local_model, max_value = 10, max_clients = 4))
# server sums the encrypted local models, and 2 committee members decrypt only their average
encrypted_sum = public_cs.aggregate_updates(encrypted_updates)
partials = [member.partial_decrypt_aggregate(encrypted_sum) for member in committee[:2]]
w, b = public_cs.combine_aggregate(encrypted_sum, partials)["mean"]
# the global model learned y = 2x - 1, without anyone seeing a client's data or local model
assert round(w, 2) == 2.0 and round(b, 2) == -1.0
package main
import (
"fmt"
"log"
"os"
"github.com/serengil/lightphe-go/lightphe"
)
func check(err error) {
if err != nil {
log.Fatal(err)
}
}
func main() {
// a committee of 3, any 2 of them decrypt the average model of each round
dealerCs, err := lightphe.New(lightphe.Paillier, lightphe.WithSafePrimes(true))
check(err)
publicKey, shareKeys, err := dealerCs.GenerateThresholdKeys(2, 3)
check(err)
check(os.WriteFile("public.txt", publicKey, 0644))
var committee []*lightphe.Cryptosystem
for _, keys := range shareKeys {
member, err := lightphe.New(lightphe.Paillier, lightphe.WithKeys(keys))
check(err)
committee = append(committee, member)
}
publicCs, err := lightphe.New(lightphe.Paillier, lightphe.WithKeyFile("public.txt"))
check(err)
// 4 clients hold different slices of private data, all following y = 2x - 1
slices := [][]float64{{-1.0, -0.9, -0.8}, {-0.4, -0.3, -0.2}, {0.2, 0.3, 0.4}, {0.8, 0.9, 1.0}}
// global model y = w x + b starts from zero
w, b := 0.0, 0.0
for round := 0; round < 30; round++ {
var encryptedUpdates []*lightphe.EncryptedUpdate
for _, xs := range slices {
// each client takes one gradient descent step on its own data, and encrypts its local model
gradW, gradB := 0.0, 0.0
for _, x := range xs {
y := 2*x - 1
gradW += 2 * (w*x + b - y) * x / float64(len(xs))
gradB += 2 * (w*x + b - y) / float64(len(xs))
}
localModel := []float64{w - 0.5*gradW, b - 0.5*gradB}
encryptedUpdate, err := publicCs.EncryptUpdate(localModel, 10, 4)
check(err)
encryptedUpdates = append(encryptedUpdates, encryptedUpdate)
}
// server sums the encrypted local models, and 2 committee members decrypt only their average
encryptedSum, err := publicCs.AggregateUpdates(encryptedUpdates)
check(err)
var partials [][]*lightphe.PartialDecryption
for _, member := range committee[:2] {
memberPartials, err := member.PartialDecryptAggregate(encryptedSum, false)
check(err)
partials = append(partials, memberPartials)
}
result, err := publicCs.CombineAggregate(encryptedSum, partials, false)
check(err)
w, b = result.Mean[0], result.Mean[1]
}
// the global model learned y = 2x - 1, without anyone seeing a client's data or local model
fmt.Printf("w = %.2f, b = %.2f\n", w, b) // w = 2.00, b = -1.00
}
import io.github.serengil.lightphe.LightPHE;
import io.github.serengil.lightphe.model.EncryptedUpdate;
import io.github.serengil.lightphe.model.Keys;
import io.github.serengil.lightphe.model.PartialDecryption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
// a committee of 3, any 2 of them decrypt the average model of each round
LightPHE dealerCs = LightPHE.builder().algorithmName("Paillier").safePrimes(true).build();
List<Keys> shareKeys = dealerCs.generateThresholdKeys(2, 3);
dealerCs.exportKeys(Path.of("public.txt"), true);
List<LightPHE> committee = shareKeys.stream()
.map(keys -> LightPHE.builder().algorithmName("Paillier").keys(keys).build())
.toList();
LightPHE publicCs = LightPHE.builder().algorithmName("Paillier").keyFile(Path.of("public.txt")).build();
// 4 clients hold different slices of private data, all following y = 2x - 1
double[][] slices = {{-1.0, -0.9, -0.8}, {-0.4, -0.3, -0.2}, {0.2, 0.3, 0.4}, {0.8, 0.9, 1.0}};
// global model y = w x + b starts from zero
double w = 0, b = 0;
for (int round = 0; round < 30; round++) {
List<EncryptedUpdate> encryptedUpdates = new ArrayList<>();
for (double[] xs : slices) {
// each client takes one gradient descent step on its own data, and encrypts its local model
double gradW = 0, gradB = 0;
for (double x : xs) {
double y = 2 * x - 1;
gradW += 2 * (w * x + b - y) * x / xs.length;
gradB += 2 * (w * x + b - y) / xs.length;
}
List<Double> localModel = List.of(w - 0.5 * gradW, b - 0.5 * gradB);
encryptedUpdates.add(publicCs.encryptUpdate(localModel, 10, 4));
}
// server sums the encrypted local models, and 2 committee members decrypt only their average
EncryptedUpdate encryptedSum = publicCs.aggregateUpdates(encryptedUpdates);
List<List<PartialDecryption>> partials = committee.subList(0, 2).stream()
.map(member -> member.partialDecryptAggregate(encryptedSum, false))
.toList();
List<Double> mean = publicCs.combineAggregate(encryptedSum, partials, false).mean();
w = mean.get(0);
b = mean.get(1);
}
// the global model learned y = 2x - 1, without anyone seeing a client's data or local model
assert Math.round(w * 100) == 200 && Math.round(b * 100) == -100;
import { LightPHE } from 'lightphe-ts';
// a committee of 3, any 2 of them decrypt the average model of each round
const dealerCs = new LightPHE({ algorithmName: 'Paillier', safePrimes: true });
const shareKeys = dealerCs.generateThresholdKeys(2, 3);
dealerCs.exportKeys('public.txt', true);
const committee = shareKeys.map((keys) => new LightPHE({ algorithmName: 'Paillier', keys }));
const publicCs = new LightPHE({ algorithmName: 'Paillier', keyFile: 'public.txt' });
// 4 clients hold different slices of private data, all following y = 2x - 1
const slices = [
[-1.0, -0.9, -0.8],
[-0.4, -0.3, -0.2],
[0.2, 0.3, 0.4],
[0.8, 0.9, 1.0],
];
// global model y = w x + b starts from zero
let [w, b] = [0, 0];
for (let round = 0; round < 30; round++) {
const encryptedUpdates = slices.map((xs) => {
// each client takes one gradient descent step on its own data, and encrypts its local model
let [gradW, gradB] = [0, 0];
for (const x of xs) {
const y = 2 * x - 1;
gradW += (2 * (w * x + b - y) * x) / xs.length;
gradB += (2 * (w * x + b - y)) / xs.length;
}
return publicCs.encryptUpdate([w - 0.5 * gradW, b - 0.5 * gradB], 10, 4);
});
// server sums the encrypted local models, and 2 committee members decrypt only their average
const encryptedSum = publicCs.aggregateUpdates(encryptedUpdates);
const partials = committee.slice(0, 2).map((member) => member.partialDecryptAggregate(encryptedSum));
[w, b] = publicCs.combineAggregate(encryptedSum, partials).mean;
}
// the global model learned y = 2x - 1, without anyone seeing a client's data or local model
console.assert(w.toFixed(2) === '2.00' && b.toFixed(2) === '-1.00');
After 30 rounds, the global model is w = 2.00 and b = -1.00, the hidden relationship, learned in about 5 seconds in Python. Quantization keeps 5 decimal digits, which is plenty for training, and the result matches federated averaging without encryption. Along the way, no client saw another client’s data, and nobody saw a single client’s local model—not the server, and not the committee.
More Scenarios
- Medical imaging: Hospitals train a tumor detection model on their own scans, and a central server averages their encrypted updates, so neither the server nor the other hospitals see any hospital’s update, which could leak information about its patients.
- Keyboard prediction: A keyboard app improves next-word prediction from many phones, and a committee of independent organizations decrypts only the average update of each round, so no single party, including the app maker, can inspect the update of a particular phone.
- Fraud detection: Banks build a shared fraud detection model without pooling their customers’ transactions, and verified partial decryptions stop a dishonest committee member from slipping a manipulated average into the shared model.
- Energy forecasting: Smart meters train a consumption forecasting model at home, and the utility learns only the averaged model, not the daily routine that a household’s own update would reveal.
- Beyond machine learning: The same pipeline sums any vectors privately, e.g. companies computing industry-wide salary averages per role, or retailers computing total sales per product category, where each participant’s numbers stay encrypted.
Caveats
- The average is revealed by design: Secure aggregation hides individual updates, not the aggregate. With only 2 clients, each one can subtract its own update from the average and learn the other’s. Require a minimum number of clients per round, and consider adding differential privacy noise when the aggregate itself is sensitive.
- Encryption hides poisoned updates, too: The server can no longer inspect updates, so it can’t spot a malicious client’s poisoned one either.
max_valueis checked when an honest client encrypts its update, but a malicious client running modified code could still encrypt a crafted value. Zero-knowledge range proofs can close that gap. - The usual threshold caveats: The dealer knows the private key while splitting it, and 3 colluding committee members could decrypt an individual update. Choose members with competing interests, and have them refuse to decrypt anything but aggregates.
- Everyone must agree on the layout: Clients must use the same key, update length,
max_valueandmax_clients, otherwise the server refuses to sum their updates.
Conclusion
Federated learning keeps the data at home, but the updates still leave, and they can leak the data. Secure aggregation closes that gap by letting the server compute the only thing it needs, the average, without seeing a single update. It takes additive homomorphism for the sum, quantization for floats, packing to turn a thousand encryptions into 25, and threshold decryption with proofs so that only a committee, and only the sum, is ever decrypted. Paillier and Damgard-Jurik are the two cryptosystems in LightPHE that offer all of this.
Our four clients learned y = 2x - 1 together without showing anyone their data or their updates. Swap the linear model for a neural network, and the pipeline stays the same—in Python, Go, Java or TypeScript.
Support this blog financially if you do like!
