Introduction

The cox vignette fits a stratified Cox model across three sites under CKKS. There the master holds the secret key and decrypts the encrypted sum at every iteration of the optimizer, so the master must be trusted not to decrypt anything else, such as a single site’s contribution.

In this vignette the three sites generate the CKKS key pair jointly, and each keeps its own share of the secret key. No single party holds the whole key. The master becomes an aggregator: it adds the encrypted contributions and collects the sites’ partial decryptions, but it cannot decrypt anything by itself.

The data and the model are the same as in vignette("cox"). Only who can decrypt changes.

Threat model

Three sites and one untrusted aggregator:

What the aggregator sees, by stage:

  1. Encrypted local contributions \(\mathit{ct}_i = E_{\mathit{pk}_{1..n}}(\ell_i)\). None decryptable alone.
  2. The encrypted sum \(\mathit{ct}_{\text{sum}} = \boxplus_i \mathit{ct}_i\). Not decryptable alone.
  3. Partial decryptions \(\rho_i\) contributed by each site. Not decryptable individually.
  4. After combining the partial decryptions, the sum \(\ell(\beta) = \sum_i \ell_i\) in the clear.

Step 4 reveals \(\ell(\beta)\) to the aggregator, which is what the master saw in vignette("cox"). The difference is that no single party can decrypt an individual contribution or any intermediate value. That takes a partial decryption from every site.

The Cox setup (same DLBCL data as cox.Rmd)

suppressPackageStartupMessages(library(survival))
library(homomorpheR)
data(DLBCL)

cox_data <- split(
  DLBCL[, c("time", "status", "GCB_sig", "LN_sig",
            "Prolif_sig", "BMP6", "MHC2_sig", "Subgroup")],
  DLBCL$Subgroup)

The protocol

Setup (once):

  1. Site 1 calls key_gen(cc) to produce its keypair \((\mathit{pk}_1, \mathit{sk}_1)\).
  2. Site 2 calls multiparty_key_gen(cc, pk_1) to produce \((\mathit{pk}_{12}, \mathit{sk}_2)\).
  3. Site 3 calls multiparty_key_gen(cc, pk_{12}) to produce \((\mathit{pk}_{123}, \mathit{sk}_3)\).
  4. The final \(\mathit{pk}_{123}\) is the joint public key. Each site keeps its own \(\mathit{sk}_i\).

Per query (called inside the optimizer):

  1. Each site \(i\) computes its local Cox negative log-likelihood \(\ell_i(\beta)\) and encrypts it under the joint public key.
  2. The aggregator sums the encrypted contributions homomorphically.
  3. Each site partial-decrypts the sum using its own \(\mathit{sk}_i\).
  4. The aggregator fuses the partials to recover \(\ell(\beta)\).

make_threshold_master() runs this chain across the sites in one call and returns a ThresholdMaster holding the joint public key. Each site keeps the share it generated. To decrypt, the master asks every site for a partial decryption and combines them. This happens inside the decrypt() method, so the master_aggregate() runner from cox.Rmd works unchanged.

Implementation

cph_control <- replace(coxph.control(), "iter.max", 0)

local_cox_nll <- function(data, beta) {
    fit <- tryCatch(
        coxph(Surv(time, status) ~ GCB_sig + LN_sig + Prolif_sig +
                  BMP6 + MHC2_sig,
              data    = data,
              init    = beta,
              control = cph_control),
        error = function(e) NULL)
    if (is.null(fit)) NA_real_ else -fit$loglik[1]
}

The CKKS context needs the MULTIPARTY feature enabled so the chained multiparty_key_gen() calls work:

cc <- openfhe.R::fhe_context("CKKS",
                           multiplicative_depth = 1L,
                           scaling_mod_size     = 59L,
                           first_mod_size       = 60L,
                           batch_size           = 8L,
                           features             = c(openfhe.R::Feature$MULTIPARTY))

The sites come first, because the joint public key is built from them. make_threshold_master() then takes the sites and returns the master.

worker_gcb <- make_worker(name = "GCB",      data = cox_data[["GCB"]],
                          contribution_fn = local_cox_nll)
worker_abc <- make_worker(name = "ABC",      data = cox_data[["ABC"]],
                          contribution_fn = local_cox_nll)
worker_t3  <- make_worker(name = "Type III", data = cox_data[["Type III"]],
                          contribution_fn = local_cox_nll)

master <- make_threshold_master("Aggregator",
                                crypto_context = cc,
                                sites = list(worker_gcb, worker_abc, worker_t3))

The check below confirms that the master has no property holding key shares and that the GCB site holds its own share:

share_check <- c(master_holds_shares = "secret_keys" %in% names(S7::props(master)),
                 gcb_holds_own_share = !is.null(worker_gcb@state$sk))
share_check
## master_holds_shares gcb_holds_own_share 
##               FALSE                TRUE

Iterative MLE through the threshold protocol

The optimizer code is the same as in cox.Rmd. Only the master class differs.

library(stats4)

encrypted_nLL <- function(GCB_sig, LN_sig, Prolif_sig, BMP6, MHC2_sig) {
    master_aggregate(master, c(GCB_sig, LN_sig, Prolif_sig, BMP6, MHC2_sig))
}

fit <- mle(encrypted_nLL,
           start   = list(GCB_sig = 0, LN_sig = 0, Prolif_sig = 0,
                          BMP6    = 0, MHC2_sig = 0),
           method  = "BFGS",
           control = list(reltol = 1e-7))
summary(fit)
logLik(fit)
##              Estimate Std. Error
## GCB_sig    -0.2638698 0.11940447
## LN_sig     -0.2543587 0.08515178
## Prolif_sig  0.3031250 0.14981283
## BMP6        0.3036367 0.10727837
## MHC2_sig   -0.3191459 0.09412946
## 'log Lik.' -495.229022 (df=5)

Comparison with the cleartext fit

As in vignette("cox"), the check is the identical mle() objective with the encrypted aggregation replaced by an ordinary sum of the three sites’ cleartext values.

library(stats4)

plain_nLL <- function(GCB_sig, LN_sig, Prolif_sig, BMP6, MHC2_sig) {
    beta <- c(GCB_sig, LN_sig, Prolif_sig, BMP6, MHC2_sig)
    sum(vapply(cox_data, local_cox_nll, numeric(1), beta = beta))
}

fit_plain <- mle(plain_nLL,
                 start   = list(GCB_sig = 0, LN_sig = 0, Prolif_sig = 0,
                                BMP6    = 0, MHC2_sig = 0),
                 method  = "BFGS",
                 control = list(reltol = 1e-7))
Threshold-CKKS DLBCL Cox against the same objective evaluated in the clear.
Coefficient \(\hat\beta\), mle() threshold \(\hat\beta\), mle() cleartext \(\lvert \text{difference} \rvert\)
GCB_sig -0.2638698 -0.2638698 \(1.73 \times 10^{-12}\)
LN_sig -0.2543587 -0.2543587 \(8.39 \times 10^{-13}\)
Prolif_sig 0.3031250 0.3031250 \(5.27 \times 10^{-13}\)
BMP6 0.3036367 0.3036367 \(1.37 \times 10^{-12}\)
MHC2_sig -0.3191459 -0.3191459 \(2.72 \times 10^{-13}\)

The threshold fit agrees with the cleartext fit to within 1.73e-12 in every coefficient.

Discussion

  1. No single party holds the decryption key. Each site generated its own share and kept it; the master holds only the joint public key, and has no property in which a share could sit. Encrypted intermediate values are undecryptable by any single party in the system, the aggregator included.
  2. The fit does not change. The coefficients agree with the cleartext fit of the same objective to CKKS precision.
  3. Same optimizer, same callback shape, same code structure. Compared to cox.Rmd only the setup changed: the workers are built first and handed to make_threshold_master() instead of being wired to a make_ckks_master() afterwards, because the joint key cannot exist before the sites do. The optimizer sees nothing different.

Limitations