Complete FE family coverage
SIFE, MIFE, MCFE, decentralized MCFE, threshold MIFE/MCFE, function-hiding IPE / Multi-IPE, and quadratic FE (SGP, Quad, multi-input MIQFE) — 42 (family, variant) combinations under one roof.
PyFE4AI is a research-oriented Python library of functional encryption (FE) schemes for AI security and privacy — from single-input inner products to multi-client, threshold, decentralized and quadratic constructions, with an optional Rust core for the hot paths.
pyfe4ai.create("family/variant", config) returns a scheme's key generator and
crypto class; then setup / get_decryption_keys / encrypt /
decrypt — across every family, built for ML workloads.
SIFE, MIFE, MCFE, decentralized MCFE, threshold MIFE/MCFE, function-hiding IPE / Multi-IPE, and quadratic FE (SGP, Quad, multi-input MIQFE) — 42 (family, variant) combinations under one roof.
DDH (3072-bit groups or the ristretto255 curve) and its matrix form MDDH (Damgård), Paillier/DCR, LWE and Ring-LWE (lattice lines), and pairings (BLS12-381) — pick the assumption that fits your deployment.
pyfe4ai.ml runs SIFE and MCFE schemes for encrypted linear inference and
gradient aggregation, including threshold MCFE over Ring-LWE with its decryption servers
in-process. Its wrapper holds the master key and plays every party (one trust domain, for
experiments) and by default refuses MIFE aggregation, partial aggregates and label reuse;
the other threshold and decentralized variants go through the scheme classes. A
quantization toolkit handles float ↔ integer precision.
An optional Rust extension, built by pip install when cargo is
available, runs the lattice hot paths, ristretto255 and BLS12-381 pairings with the outputs
of the Python reference: an MCFE Ring-LWE round of 10 000 gradient elements under
standard takes about 18 ms per client and 7.5 ms to aggregate, against 345 / 157 ms on the
Python backend (JSON ciphertexts, one thread).
About 4,100 tests, run in CI with and without the Rust extension (the native code is
checked bit for bit against the Python reference), including status-table sync,
security-profile and crypto-review regression tests, plus a structured
FEError exception hierarchy.
Every scheme cites its source construction (PKC'15, CRYPTO'16/'18, ASIACRYPT'18, SCN'20, TCC'21/'22, PKC'22, IACR CiC'24, IEEE TDSC TAPFed, …) in the module docstring. Deviations from the paper are listed in the Scheme Status table.
Functional encryption gives the holder of a functional key exactly f(x), in the clear, and nothing else about x. Other techniques draw the line elsewhere. A simplified, qualitative picture of typical deployments:
| Technique | Functions | Interaction | Must be trusted | Output | Post-quantum options |
|---|---|---|---|---|---|
| FE | Specific classes: here inner products and quadratic functions | None to compute: one ciphertext per client, then decryption with a functional key from the authority; threshold variants add one partial decryption per server | The key authority (setup and functional keys); the hardness assumption | f(x) in the clear, to the key holder | Yes: the LWE and Ring-LWE schemes (dMCFE-LWE derives its keys under DCR); not the DDH, DCR or pairing ones |
| HE | Arbitrary circuits with fully homomorphic schemes; narrower classes with additive ones | None while computing; the key holder decrypts the result | The secret-key holder (or a threshold of them); the hardness assumption | Encrypted until the key holder decrypts | Yes: the common FHE schemes are lattice-based |
| MPC | Arbitrary functions | Parties exchange messages during the computation | That no more than a threshold of parties collude, in the protocol's adversary model | In the clear, to the designated parties | Yes: information-theoretic protocols exist; others depend on their primitives |
| DP | Statistics and learning that tolerate noise | None | The curator who sees the raw data (central model; local DP removes it at a cost in accuracy) | In the clear, with calibrated noise | Not applicable: a statistical guarantee, not a hardness assumption |
| TEE | Arbitrary code | Remote attestation, then a secure channel to the enclave | The hardware vendor, attestation, and the absence of side channels | As the enclave code releases it | Rests on hardware isolation, not on a hardness assumption |
Simplified on purpose: each row stands for a family of techniques with many variants (threshold and multi-key HE, MPC with a dealer, local and central DP, …), and they combine — noise can be added before FE encryption, or MPC can replace a trusted setup. No performance comparison is implied.
Clients encrypt their gradients under the round's label; whoever holds the functional key for the weights learns the weighted sum — not a single client's gradient. Step through it, or let it play.
setup() and gives every client its encryption key skᵢ.mcfe/ring_lwe also bound to ℓ).share_decrypt; the LWE layers flood them with noise. Server 3 can stay offline.combine_decrypt and decodes Σ yᵢ·xᵢ. Fewer than t servers learn nothing beyond the result.Threshold servers are assumed honest-but-curious: a partial decryption carries no proof of
correctness. pyfe4ai.ml runs MCFE — and the servers of mcfe/ring_lwe_threshold — in one
process for experiments. Details: Threshold decryption and the ML adapter.
Python on top, one backend interface in the middle, and two implementations of it: the Python reference and the optional Rust extension, which computes the same values. Follow a call down to the Rust core and back.
pyfe4ai.create("mcfe/ring_lwe", cfg) — the registry finds the scheme by id and builds its key generator, the config completed by the security profile; you get kg and the class MCFERingLWE.client.encrypt_lst_ndarray([grad], label=ℓ) — the scheme packs the tensor into chunks: one Ring-LWE ciphertext per chunk, each with its own label pad.backend.ring(q, n).encrypt_tensor(…) — under auto the extension is built and q < 2253 has the root of unity it needs, so the native implementation runs.pyfe4ai._rust_native runs the rust/core kernels: one call per tensor with the GIL released, chunks in parallel with PYFE4AI_THREADS, one ChaCha20 stream per chunk from one OS seed, overwritten when the call ends.mcfe/ring_lwe, or binary with ct_format="binary".encrypt_gradient(grad, wrapper, nid="c1", label=ℓ) — one client's step of a federated round.pyfe4ai.ml reserves (client, ℓ) — a second encryption under ℓ is refused — and takes the client's crypto object, which the wrapper built from the registry.EncryptedGradient comes back, ready to serialise for the aggregator (to_dict()).PYFE4AI_BACKEND=python — or the extension is not built, or the parameters are outside what it handles (moduli of 2253 or more, rings without a 2n-th root of unity, wider Gaussians).backend/python_impl: gmpy2, NumPy).FEWarning; not under an explicit python).Simplified: the lanes on the left carry the call down (amber) and the result back up (teal); the
layers above the backend also share pyfe4ai.core. What runs natively is listed in the docstring of
pyfe4ai.backend and in Threads, processes and copies.
The extension computes the reference's values — same NTT roots and tables, samplers and hash reductions — and tests compare the two.
A tensor call expands one 32-byte OS seed with ChaCha20, one stream per chunk; seed and states are overwritten when it ends.
One native call per tensor; PYFE4AI_THREADS=k lets it use k threads (default 1).
Inputs it does not handle go to the reference: moduli ≥ 2253, rings without the root of unity, wider Gaussians.
A scheme is named "family/variant": the family says who encrypts and who decrypts, the variant
which assumption and construction it uses. pyfe4ai.list_schemes() and
pyfe4ai.get_scheme_status("family/variant") answer the same questions at runtime.
single-input
One encryptor; the holder of a key for y learns ⟨x, y⟩ — one party's vector scored by another party's linear model.
multi-input
n clients each encrypt a slot; the key holder learns Σ⟨xᵢ, yᵢ⟩. No labels: for one-shot joint inputs, not for repeated rounds.
multi-client, labelled
Clients encrypt under a shared label, and only ciphertexts of one label combine — federated aggregation, one label per round.
SGP · Quad · MIQFE
The key holder learns xᵀFy (sgp, quad), or the cross-user quadratic terms of several clients (miqfe).
standard| Family | DDH | Damgård | Paillier | LWE | Ring-LWE | Pairing |
|---|---|---|---|---|---|---|
| SIFE single-input | ||||||
| MIFE multi-input | ||||||
| MCFE multi-client, labelled | ||||||
| dMCFE decentralized | none | |||||
| tMIFE threshold | none | none | none | none | ||
| tMCFE threshold | none | none | ||||
| Quadratic SGP · Quad · MIQFE | none | none | none | none | none |
Select a cell to list its schemes, their review status and whether standard refuses them.
One dot per registered scheme, from the registry (pyfe4ai/core/registry.py). The DDH column
includes the ddh_ristretto255 schemes; pairing-based and quadratic schemes run on BLS12-381 from the Rust
extension, other curves need charm-crypto. Per-scheme papers, fidelity and deviations:
Security & status and Choosing a scheme.
Functional encryption reveals a chosen function of the data — not the data itself.
Clients encrypt local gradients with MCFE-DDH (CDGPP18), the DCR, LWE and MDDH constructions of ABMPSW20,
or MCFE-Ring-LWE (BMMS21), which under standard encrypts 10 000 gradient elements in about
18 ms per client with the Rust extension (0.35 s in pure Python); the aggregator's functional key decrypts
only the weighted sum. dMCFE variants need no authority for functional keys (their setup, which samples every
client's keys in-process, is still trusted).
Score features with a linear model (SIFE), or evaluate a bilinear form xTFy with quadratic FE (BLS12-381 from the Rust extension), directly over ciphertexts — the model owner learns the result, never the raw features.
examples/ml_encrypted_inference.py examples/quadratic_quad_minimal.py
tMCFE variants (TAPFed-style) split decryption across t-of-s servers that are assumed honest-but-curious;
their LWE, Ring-LWE and pairing threshold layers are experimental. For mcfe/ring_lwe_threshold
the ML adapter runs the servers in-process — for experiments — and shows the messages of a deployment
(partial_decrypt_gradients, combine_partial_decryptions).
examples/mcfe_ring_lwe_threshold_demo.py Threshold decryption and the ML adapter
The same 0.4.0 code on its two backends — the Rust extension and the plain Python implementation — on one
machine, one thread, under standard. Ciphertext formats are labelled: JSON is the default of
mcfe/ring_lwe, binary that of the threshold MCFE.
mcfe/ring_lwe, 104 elements by ciphertext formatmcfe/lwe, 200 elements JSON ciphertexts (default)ddh_ristretto255, unreviewed), per element| Workload | Measure | Python backend | Rust backend |
|---|---|---|---|
| MCFE Ring-LWE, 104 elements | client, JSON (default) | 345 ms | 18.2 ms |
| aggregator, JSON (default) | 157 ms | 7.5 ms | |
| client, binary | 360 ms | 17.95 ms | |
| aggregator, binary | 164 ms | 6.24 ms | |
| online, after the offline phase (binary) | — | 0.25 ms client · 3.16 ms aggregator | |
| 4 threads (binary) | — | 6.21 ms client · 3.62 ms aggregator | |
| Threshold MCFE, 104 elements, s = 3, t = 2, defaults (binary) | client | 493 ms | 26.4 ms |
| partial decryption, per server | 274 ms | 8.0 ms | |
| aggregation (key shares, t partial decryptions, combine) | 686 ms | 27.8 ms | |
| Dense-LWE MCFE, 200 elements (JSON) | client | 543 ms | 92.6 ms |
| aggregator | 551 ms | 100.1 ms | |
| Pairing round, BLS12-381 | SGP | no Python implementation | 30 ms |
| Quad | 48 ms | ||
| MCFE FH-Multi-IPE | 47 ms | ||
| DDH MCFE over ristretto255, per element | client | not measured | 0.069 ms |
| aggregator | 0.139 ms | ||
| ciphertext | 75 B |
Apple M2 Max, Python 3.13, one thread unless stated, security_profile="standard", three clients,
medians of interleaved runs; timings vary with the machine and its load. Bars are to scale within each group.
Source and commands: benchmarks/PERFORMANCE.md.
Encrypt a vector, hand out a functional key, learn only the inner product. Demo parameters are toy-sized:
sec_param is a modulus bit length, not a security level; "security_profile": "standard"
selects 128-bit-oriented parameters, except for pairings (BLS12-381, about 120–126-bit) and the schemes it refuses
(Security Profiles).
# from source
git clone https://github.com/spire-studio/pyfe4ai.git
cd pyfe4ai
conda env create -f environment.yml
conda activate pyfe4ai
# with cargo on the PATH this also builds the optional Rust
# extension (BLS12-381 for the pairing & quadratic FE)
pip install -e ".[test]"
# other pairing curves (charm-crypto): GMP, OpenSSL, PBC first
pip install -e ".[test,pairing]"
# run the core test suite
python -m pytest -m core -q
sife_ddh.py
import pyfe4ai
x, y = [2, 1, 3], [4, 5, 6]
# sec_param = 128 is a toy modulus size: fast, NOT secure. For a
# 3072-bit group pass {"security_profile": "standard", "eta": 3}.
kg, SIFEDDH = pyfe4ai.create("sife/ddh", {"sec_param": 128, "eta": 3})
kg.setup()
pp, sk = kg.get_public_parameters(), kg.get_private_keys()
dk = kg.get_decryption_keys("sid_0",
credentials={"fusion_weight": y})
ct = SIFEDDH({"precision": 3, "keys": {"pp": pp, "sk": sk}}).encrypt(x)
result = SIFEDDH({"precision": 3, "keys": {"pp": pp}}).decrypt(ct, dk, y)
print(result) # 31 = ⟨x, y⟩
mcfe_ddh.py
import pyfe4ai
x = {"alice": [1, 2], "bob": [3, -1], "carol": [0, 4]}
y = {"alice": [2, 1], "bob": [1, 1], "carol": [-1, 3]}
kg, MCFEDDH = pyfe4ai.create("mcfe/ddh", {"eta": 2, "n": 3, "lst_nid": list(x)})
kg.setup()
pp = kg.get_public_parameters()
# every client encrypts its own vector under the round's label
label = "round-1"
cts = {}
for nid in x:
client = MCFEDDH({"id": nid, "keys": {"pp": pp, "sk": kg.get_private_keys(nid)}})
cts[nid] = client.encrypt(x[nid], label)
# the functional key reveals only the weighted sum, for one label
dk = kg.get_decryption_keys("aggregator", credentials={"fusion_weight": y})
aggregator = MCFEDDH({"id": "aggregator", "keys": {"pp": pp}})
print(aggregator.decrypt(cts, dk, y, label)) # 18
fl_round.py
import numpy as np
from pyfe4ai.ml import FESchemeWrapper, aggregate_gradients, encrypt_gradient
clients = ["c0", "c1", "c2"]
# the wrapper runs the key generator and plays every party: experiments only
wrapper = FESchemeWrapper("mcfe", "ring_lwe",
{"eta": 1, "n": 3, "lst_nid": clients,
"bound_x": 10**4, "bound_y": 10},
precision=3)
grads = {nid: np.random.default_rng(i).uniform(-1, 1, 1000)
for i, nid in enumerate(clients)}
enc = {nid: encrypt_gradient(g, wrapper, nid=nid, label="round-1")
for nid, g in grads.items()}
total = aggregate_gradients(enc, wrapper, {nid: 1 for nid in clients},
label="round-1")
print(np.allclose(total, sum(grads.values()), atol=1e-2)) # True
registry.py
import pyfe4ai
print(len(pyfe4ai.list_schemes())) # 42
print(pyfe4ai.list_schemes("mcfe", capabilities="threshold")) # the threshold MCFEs
scheme = pyfe4ai.get_scheme("mcfe/ring_lwe")
print(scheme.kind, sorted(scheme.capabilities), scheme.status.status)
print(pyfe4ai.get_scheme_status("mcfe/ring_lwe_threshold").status) # experimental
Every registered scheme carries a review status in the registry, reported at runtime by
pyfe4ai.get_scheme_status("family/variant").
"toy" default
Fast and not secure: sec_param is a modulus bit length, LWE dimensions default to 32–64 and the Ring-LWE dimension to 32.
"standard"
Aims at 128-bit security: 3072-bit discrete-log groups (RFC 3526) and DCR moduli, lattice-estimator-sized LWE and Ring-LWE dimensions, stat_param 128. Pairings run on BLS12-381, about 120–126-bit (a FESecurityWarning says so).
Refused under standard — no parameter set both secure and usable:
sife/fullysec_lwe mife/fullysec_lwe mife/lwe_threshold mcfe/paillier_decentralized
If you use PyFE4AI in your research, please cite the software. A technical report is in preparation coming soon
@software{pyfe4ai2026,
author = {{Spire Studio}},
title = {{PyFE4AI}: Python-based Functional Encryption
for {AI} Security and Privacy},
year = {2026},
version = {0.4.0},
license = {Apache-2.0},
url = {https://github.com/spire-studio/pyfe4ai}
}
Citation metadata is also in CITATION.cff — GitHub's “Cite this repository” button gives APA and BibTeX.