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.R package. 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
vignettes (vignette("mle"), vignette("cox")).
For exact integer aggregation BFV is the right choice; for real-valued
sufficient statistics CKKS is the right choice.
library(openfhe.R)
set.seed(42)
site_data <- lapply(c(1000, 500, 1500), function(n) {
data.frame(
age = sample(40:70, n, replace = TRUE),
sex = sample(c("M", "F"), n, replace = TRUE),
biomarker = runif(n, 0, 1)
)
})
## BFV: exact integer arithmetic mod plaintext_modulus.
cc <- fhe_context("BFV",
plaintext_modulus = 65537L,
multiplicative_depth = 1L)
keys <- key_gen(cc)
pk <- keys@public
sk <- keys@secret
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 R session by sharing cc
and pk locally.
site_encrypt <- function(site_df, cc, pk) {
count <- sum(site_df$age < 50 &
site_df$sex == "F" &
site_df$biomarker < 0.2)
pt <- make_packed_plaintext(cc, as.integer(count))
encrypt(pk, pt, cc = cc)
}
ct_site1 <- site_encrypt(site_data[[1]], cc, pk)
ct_site2 <- site_encrypt(site_data[[2]], cc, pk)
ct_site3 <- site_encrypt(site_data[[3]], cc, pk)
These encrypted counts are opaque to the aggregator — it learns nothing about any individual site’s count.
## Homomorphic addition: the aggregator never decrypts intermediate values.
ct_total <- ct_site1 + ct_site2 + ct_site3
## Only the aggregator holds the secret key.
result <- decrypt(ct_total, sk, cc = cc)
total_count <- get_packed_value(result)[1]
total_count
## [1] 105
true_count <- sum(sapply(site_data, function(df) {
sum(df$age < 50 & df$sex == "F" & df$biomarker < 0.2)
}))
true_count
## [1] 105
stopifnot(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.
+,
which gives the same answer as adding the counts themselves.No site revealed its individual count. The aggregator never saw any patient-level data. The total is exact.
In a real deployment each site needs the aggregator’s context and
public key. openfhe.R provides serialization:
tdir <- tempdir()
fhe_serialize(cc, file.path(tdir, "context.bin"))
fhe_serialize(pk, file.path(tdir, "pubkey.bin"))
cc_remote <- fhe_deserialize(file.path(tdir, "context.bin"), "CryptoContext")
pk_remote <- fhe_deserialize(file.path(tdir, "pubkey.bin"), "PublicKey")
ct <- encrypt(pk_remote,
make_packed_plaintext(cc_remote, 42L),
cc = cc_remote)
fhe_serialize(ct, file.path(tdir, "site_count.bin"))
ct_received <- fhe_deserialize(file.path(tdir, "site_count.bin"), "Ciphertext")
result <- decrypt(ct_received, sk, cc = cc)
get_packed_value(result)[1]
## [1] 42