Privacy-Preserving Count Aggregation

The problem

Multiple research sites each hold patient data and want to know the total count of patients matching a criterion (say age < 50 and sex == "F" and biomarker < 0.2) without revealing the individual per-site counts to anyone, including the aggregator running the aggregation.

Here we use OpenFHE’s BFV scheme via the openfhe-python binding. BFV operates on integer vectors with both addition and multiplication — well-suited to counting and other exact integer-valued aggregates.

This is the BFV companion to the CKKS-based real-valued master/worker pages (maximum likelihood, Cox regression). For exact integer aggregation BFV is the right choice; for real-valued sufficient statistics CKKS is the right choice.

Setup

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
site_data = [
    pd.DataFrame({
        "age":       rng.integers(40, 71, size=n),
        "sex":       rng.choice(["M", "F"], size=n),
        "biomarker": rng.uniform(0.0, 1.0, size=n),
    })
    for n in (1000, 500, 1500)
]

The aggregator sets up encryption

from homomorphepy import fhe_context, packed_codec

# BFV: exact integer arithmetic mod plaintext_modulus.
cc = fhe_context("BFV", plaintext_modulus=65537, multiplicative_depth=1)

keys = cc.KeyGen()
pk, sk = keys.publicKey, keys.secretKey

codec = packed_codec(cc)

In a real deployment the aggregator distributes the public key (and the serialized context) to each site. The secret key stays with the aggregator. We simulate this in one Python session by sharing cc and pk locally.

Each site computes locally and encrypts

from homomorphepy import Ct

QUERY = 'age < 50 and sex == "F" and biomarker < 0.2'

def site_encrypt(site_df, cc, pk):
    count = len(site_df.query(QUERY))
    pt = codec.encode(count)
    return Ct(cc.Encrypt(pk, pt), cc.cc)

ct_site1 = site_encrypt(site_data[0], cc, pk)
ct_site2 = site_encrypt(site_data[1], cc, pk)
ct_site3 = site_encrypt(site_data[2], cc, pk)

These encrypted counts are opaque to the aggregator — it learns nothing about any individual site’s count.

The aggregator aggregates

# Homomorphic addition: the aggregator never decrypts intermediate values.
ct_total = ct_site1 + ct_site2 + ct_site3

# Only the aggregator holds the secret key.
result = cc.Decrypt(ct_total.raw, sk)
total_count = codec.decode(result, 1)[0]
total_count
113

Verification

true_count = sum(len(df.query(QUERY)) for df in site_data)
true_count
113
assert total_count == true_count

The aggregated count matches the cleartext computation exactly. BFV is an exact scheme over the integers: unlike the real-valued arithmetic used elsewhere in this package, it introduces no approximation error at all.

The details of what happened

  1. The aggregator created an encryption context and distributed the public key to all sites.
  2. Each site computed its local count in the clear, encrypted it, and sent the encrypted count to the aggregator.
  3. The aggregator added the encrypted counts using +, which gives the same answer as adding the counts themselves.
  4. Only the aggregator, holding the secret key, could decrypt the total.

No site revealed its individual count. The aggregator never saw any patient-level data. The total is exact.

Serializing for a real distributed protocol

In a real deployment each site needs the aggregator’s context and public key. openfhe-python provides serialization:

import tempfile
from pathlib import Path

import openfhe

tdir = Path(tempfile.mkdtemp())
openfhe.SerializeToFile(str(tdir / "context.bin"), cc.cc, openfhe.BINARY)
openfhe.SerializeToFile(str(tdir / "pubkey.bin"), pk, openfhe.BINARY)

# At a site: read the context and the public key, encrypt a count.
cc_remote, _ = openfhe.DeserializeCryptoContext(str(tdir / "context.bin"), openfhe.BINARY)
pk_remote, _ = openfhe.DeserializePublicKey(str(tdir / "pubkey.bin"), openfhe.BINARY)
ct = cc_remote.Encrypt(pk_remote, cc_remote.MakePackedPlaintext([42]))
openfhe.SerializeToFile(str(tdir / "site_count.bin"), ct, openfhe.BINARY)

# At the aggregator: read the encrypted count and decrypt it.
ct_received, _ = openfhe.DeserializeCiphertext(str(tdir / "site_count.bin"), openfhe.BINARY)
codec.decode(cc.Decrypt(ct_received, sk), 1)[0]
42

The same run, in one call

from homomorphepy.examples import aggregation

result = aggregation.run()
result.total_encrypted, result.total_cleartext, result.exact
(113, 113, True)