
BinFHE: Boolean Circuits and Arbitrary Functions on Encrypted Bits
Balasubramanian Narasimhan
2026-06-13
Source:vignettes/binfhe-boolean-circuits.Rmd
binfhe-boolean-circuits.RmdThe other vignettes in this package focus on CKKS — approximate real-number arithmetic with batched slots, suitable for distributed statistics, regression, and the bulk of the headline “R optimizers run unchanged over an encrypted channel” workflows. CKKS is where most of the audience-A use cases live.
But CKKS has blind spots. It is not well suited to:
-
Comparisons and branching — “is
x > 0?” doesn’t translate cleanly into a polynomial approximation unless you’re willing to burn levels on a Chebyshev sign approximation. - Arbitrary non-polynomial functions on bounded integer domains — a piecewise step function, a bit-extraction, a custom lookup.
- Exact per-bit logic — parity, comparators, small arbitrary-function evaluation.
OpenFHE’s BinFHE subsystem is the tool for those jobs. BinFHE implements the FHEW/TFHE family: single-bit ciphertexts with every gate evaluation performing an in-place bootstrap. That means there’s no depth budget to manage — a BinFHE circuit can be arbitrarily deep — but each gate is comparatively expensive because the bootstrap is on the critical path. It’s the right shape for short circuits with logical or arbitrary-function operations, not for long chains of multiplications.
This vignette walks through the BinFHE operations end-to-end. Every section runs real code and checks real output. The examples use the TOY paramset for speed — in production you’d use STD128 (or STD128_3 / STD128_4 for multi-input gates).
Context and key setup
A BinFHE context is built with a parameter set and a bootstrap
method. BinFHEParamSet carries 44 values covering the full
C++ header range — TOY for tests,
STD128 / STD128Q for 128-bit classical
/ quantum security, STD192 / STD256
for higher security, and the LMKCDEY family for the
newer bootstrap algorithm. BinFHEMethod chooses between
GINX (default), AP (Ducas-Micciancio),
and LMKCDEY (the fastest variant in the current OpenFHE
release).
library(openfhe)
## TOY paramset — fast but zero security. For demos only.
ctx <- bin_fhe_context(BinFHEParamSet$TOY, BinFHEMethod$GINX)
## The secret key generates bootstrap keys that every gate
## will use internally.
sk <- bin_key_gen(ctx)
bin_bt_key_gen(ctx, sk)bin_bt_key_gen() accepts an optional
keygen_mode argument (KeygenMode$SYM_ENCRYPT
default, or KeygenMode$PUB_ENCRYPT) that controls how the
bootstrap keys are generated. Most users leave it at the default.
Two-input boolean gates
The standard 2-input gates are AND, OR,
NAND, NOR, XOR,
XNOR, and the fast variants XOR_FAST /
XNOR_FAST. All of them go through the same
eval_bin_gate(ctx, gate, ct1, ct2) entry point.
ct0 <- bin_encrypt(ctx, sk, 0L)
ct1 <- bin_encrypt(ctx, sk, 1L)
truth <- function(gate_name, gate, ct_a, ct_b) {
r <- eval_bin_gate(ctx, gate, ct_a, ct_b)
bin_decrypt(ctx, sk, r)
}
## Full truth table for AND / OR / XOR.
grid <- expand.grid(a = c(0L, 1L), b = c(0L, 1L))
grid$AND <- mapply(function(a, b) truth("AND", BinGate$AND,
bin_encrypt(ctx, sk, a),
bin_encrypt(ctx, sk, b)),
grid$a, grid$b)
grid$OR <- mapply(function(a, b) truth("OR", BinGate$OR,
bin_encrypt(ctx, sk, a),
bin_encrypt(ctx, sk, b)),
grid$a, grid$b)
grid$XOR <- mapply(function(a, b) truth("XOR", BinGate$XOR,
bin_encrypt(ctx, sk, a),
bin_encrypt(ctx, sk, b)),
grid$a, grid$b)
grid
#> a b AND OR XOR
#> 1 0 0 0 0 0
#> 2 1 0 0 1 1
#> 3 0 1 0 1 1
#> 4 1 1 1 1 0Every row of the truth table matches the standard boolean definitions. Nothing exotic is happening here — the interesting part is that the inputs are encrypted, the gate evaluation does a bootstrap internally, and the output is decryptable with the same secret key.
Negation
The only 1-input gate is eval_not(ctx, ct):
bin_decrypt(ctx, sk, eval_not(ctx, ct0))
#> [1] 1
bin_decrypt(ctx, sk, eval_not(ctx, ct1))
#> [1] 0Multi-input gates
OpenFHE exposes wider boolean gates (3-input and 4-input) that are
more efficient than composing 2-input gates. The supported wide gates
are AND3, OR3, AND4,
OR4, MAJORITY (3-input majority vote),
and CMUX (3-input conditional mux). All of them go
through the same eval_bin_gate() entry point, but instead
of passing two Ciphertext arguments you pass a
list of ciphertexts.
Multi-input gates have two setup constraints the 2-input gates don’t share:
- Parameter set: you need a paramset designed for the input width. STD128_3 for 3-input gates, STD128_4 for 4-input. TOY with the default plaintext modulus produces garbled decryption because the TOY parameters are tuned for 2-input only.
-
Plaintext modulus: the encryption call must use
p = 2 * num_inputs. For 3-input gates that’sp = 6; for 4-input,p = 8. TheSMALL_DIMoutput mode is also required.
Here’s a 3-input AND3 example using the right setup:
ctx3 <- bin_fhe_context(BinFHEParamSet$STD128_3)
sk3 <- bin_key_gen(ctx3)
bin_bt_key_gen(ctx3, sk3)
p3 <- 6L
encrypt_bit_3 <- function(b) {
bin_encrypt(ctx3, sk3, as.integer(b),
output = BinFHEOutput$SMALL_DIM,
p = p3)
}
## 1 AND 1 AND 0 = 0
cts_110 <- list(encrypt_bit_3(1L),
encrypt_bit_3(1L),
encrypt_bit_3(0L))
bin_decrypt(ctx3, sk3,
eval_bin_gate(ctx3, BinGate$AND3, cts_110),
p = p3)
#> [1] 0
## 1 AND 1 AND 1 = 1
cts_111 <- list(encrypt_bit_3(1L),
encrypt_bit_3(1L),
encrypt_bit_3(1L))
bin_decrypt(ctx3, sk3,
eval_bin_gate(ctx3, BinGate$AND3, cts_111),
p = p3)
#> [1] 1Same entry point, different shape: the third argument is a list
instead of a single ciphertext, and the second argument
(ct2) is left unset (defaults to NULL). The
dispatcher detects the list shape and routes to the vector-form C++
overload.
Majority on three bits:
## Majority uses p = 4 (not 2 * num_inputs = 6) per the
## upstream boolean-multi-input example's encoding.
p_maj <- 4L
encrypt_bit_maj <- function(b) {
bin_encrypt(ctx3, sk3, as.integer(b),
output = BinFHEOutput$SMALL_DIM,
p = p_maj)
}
cts <- list(encrypt_bit_maj(1L),
encrypt_bit_maj(1L),
encrypt_bit_maj(0L))
bin_decrypt(ctx3, sk3,
eval_bin_gate(ctx3, BinGate$MAJORITY, cts),
p = p_maj)
## 1 (majority of two 1s and one 0)Arbitrary function evaluation
For functions that don’t fit into the fixed gate repertoire, BinFHE supports arbitrary function evaluation via a lookup table. The workflow is:
- Define a function from integers in
[0, p)to integers in[0, p)in plain R. - Build a
BinFHEContextwitharb_func = TRUE, which selects a paramset and ring dimension large enough for the functional bootstrap. - Generate a lookup table via
generate_lut_via_function(). - Call
eval_func(ctx, ct, lut)on an encrypted input — the output is a ciphertext off(input).
## Build an arb-func context. arb_func = TRUE picks a wider
## paramset; log_q and n control the LARGE_DIM modulus and
## dimension of the functional-bootstrap path.
ctx_f <- bin_fhe_context(BinFHEParamSet$TOY,
arb_func = TRUE)
sk_f <- bin_key_gen(ctx_f)
bin_bt_key_gen(ctx_f, sk_f)
p <- get_max_plaintext_space(ctx_f)
## Example function: squared value mod p. The LUT maps
## input i in [0, p) to f(i) = i^2 mod p.
f_square <- function(x, plaintext_modulus) {
(x * x) %% plaintext_modulus
}
lut <- generate_lut_via_function(f_square, p)
## Encrypt a value in the LARGE_DIM / functional-bootstrap
## path and evaluate.
ct_input <- bin_encrypt(ctx_f, sk_f, 3L,
output = BinFHEOutput$LARGE_DIM,
p = p)
ct_out <- eval_func(ctx_f, ct_input, lut)
bin_decrypt(ctx_f, sk_f, ct_out, p = p)
#> [1] 1
## 9 (if p > 9, otherwise 9 mod p)The LUT evaluation runs one functional bootstrap per
eval_func() call — so it’s not cheap — but it gives you
arbitrary univariate functions over the plaintext domain without any
polynomial approximation.
The sign function
eval_sign(ctx, ct) evaluates the sign of an encrypted
value: it returns 1 if the input is in the “positive” half of the
encoding range and 0 if it’s in the “negative” half. The primary use
case is threshold comparisons (convert x > threshold
into eval_sign(ct - threshold_ct)).
Unlike the other functional-bootstrap primitives in this vignette,
eval_sign() requires a large-Q context:
the paramset must be STD128 (not TOY), the
arb_func flag must be FALSE, and an explicit
log_q must be supplied. The encryption path uses the
LARGE_DIM output mode with both an explicit plaintext
modulus p and an explicit ciphertext modulus
mod = Q.
This setup mirrors the upstream eval-sign.py
example.
log_q <- 17L
ctx_s <- bin_fhe_context(
paramset = BinFHEParamSet$STD128,
method = BinFHEMethod$GINX,
arb_func = FALSE,
log_q = log_q,
n = 0L,
time_optimization = FALSE
)
sk_s <- bin_key_gen(ctx_s)
bin_bt_key_gen(ctx_s, sk_s)
Q <- bitwShiftL(1L, log_q) # 131072
q <- 4096
factor <- bitwShiftL(1L, log_q - as.integer(log2(q))) # 32
p_s <- get_max_plaintext_space(ctx_s) * factorNow encrypt eight values centered on p_s / 2 and test
each one’s sign:
center <- p_s %/% 2
for (i in 0:7) {
msg <- center + i - 3
ct <- bin_encrypt(ctx_s, sk_s, msg,
output = BinFHEOutput$LARGE_DIM,
p = p_s, mod = Q)
ct_sign <- eval_sign(ctx_s, ct)
## Decrypt with p = 2 (sign bit is a single bit).
result <- bin_decrypt(ctx_s, sk_s, ct_sign, p = 2L)
cat(sprintf("msg = center%+d => sign bit %d\n", i - 3, result))
}
#> msg = center-3 => sign bit 0
#> msg = center-2 => sign bit 0
#> msg = center-1 => sign bit 0
#> msg = center+0 => sign bit 1
#> msg = center+1 => sign bit 1
#> msg = center+2 => sign bit 1
#> msg = center+3 => sign bit 1
#> msg = center+4 => sign bit 1The sign flips exactly at i = 3 (msg = center) — inputs
in the lower half of [0, p_s) return 0, inputs in the upper
half return 1.
The optional scheme_switch argument to
eval_sign() controls whether the output encoding is
compatible with the CKKS<->FHEW scheme-switching pipeline. Most
users can ignore it (default FALSE).
Floor: bit-level rounding
eval_floor(ctx, ct, roundbits) performs the LWE
equivalent of floor(ct / 2^roundbits) via functional
bootstrapping. Used as a primitive in bit-extraction or quantization
pipelines. Like eval_func, eval_floor works
with an arb_func = TRUE context and the
LARGE_DIM encryption mode — so the previous
ctx_f from the LUT section is ready to use.
ct_five <- bin_encrypt(ctx_f, sk_f, 5L,
output = BinFHEOutput$LARGE_DIM,
p = p)
ct_floor <- eval_floor(ctx_f, ct_five, roundbits = 2L)
class(ct_floor)
#> [1] "openfhe::LWECiphertext" "openfhe::OpenFHEObject" "S7_object"
## Returns an LWECiphertext holding floor(5 / 2^2) = 1 in
## the rounded encoding.When to use BinFHE vs CKKS
The two schemes solve different problems.
| Property | CKKS | BinFHE |
|---|---|---|
| Data type | real numbers (approximate) | single bits |
| Batching | yes (thousands of slots per ciphertext) | no (one bit per ciphertext) |
| Per-op cost | cheap multiply / add, expensive bootstrap | every gate is a bootstrap internally |
| Depth budget | yes — bootstrap refreshes, see ckks-bootstrapping | no — gates self-refresh |
| Good at | polynomial arithmetic, statistics, batched linear algebra | comparisons, branching, per-bit logic, arbitrary univariate functions |
| Audience-A fit in this package | primary — cox / cvxr / mle / encrypted-regression etc. | secondary — comparators, thresholding, custom LUT evaluation |
In practice the two schemes are complementary. A hybrid workflow —
use CKKS for the heavy lifting, then scheme-switch to BinFHE for a
comparison or a LUT evaluation — exists in the OpenFHE C++ library (via
eval_sign(scheme_switch = TRUE) and the
EvalSchemeSwitching* family). The scheme-switching pipeline
itself is not yet exposed in openfhe’s R interface but a
future version could if a concrete use case demands it.
Further reading
Besides the package help, more details can be found in the OpenFHE BinFHE documentation.