# cuhepy, homomorphic encryption in Python with the attacks written down

> cuhepy implements Paillier and BFV from scratch in Python, with optional CUDA and C++ backends, and uses encrypted nearest-neighbour search as its worked example. The repository states plainly that it is experimental, unaudited, not constant-time and unsuitable for real data, and it publishes the attacks its authors found against their own code.

**XTraceAI/cuhepy** — GPU-accelerated homomorphic encryption in Python: Paillier and BFV, with encrypted nearest-neighbour search. Experimental research code.

- Repository: https://github.com/XTraceAI/cuhepy
- Stars: 904 · Forks: 233
- Language: Python
- License: Apache-2.0
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/xtraceai-cuhepy

## An explicit warning before anything else

The README opens with a warning that is unusual enough to be worth quoting rather than paraphrasing. The project states that it is experimental research code, that it is not audited, not constant-time, and not suitable for protecting real data. Both schemes are implemented from scratch for study and measurement.

The last sentence is the one that tells you the intent. This is a laboratory. The value of the repository is that you can read the implementation, change it, run the benchmarks and, when you find a problem in it, publish the problem alongside the code.

That last part is what makes the project unusual. The README says the authors publish the attacks they found against their own code, in the attacks/ directory and in docs/research, and tells readers to read those before trusting anything there. The three named attacks are PL-01, PL-04 and BFV-01, and they are described in the same document as the scheme they break.

The packaging reflects the status. The project is on PyPI as cuhepy, requires Python 3.11 or newer, is classified as Development Status 3 - Alpha, and its intended audience is Science and Research. The licence is Apache-2.0, and the last push to the repository was on 2026-10-06 with no GitHub releases.

The CPU path depends on four packages, gmpy2, msgpack, numpy and pycryptodome, and needs no GPU at all. Compiled backends are opt-in, which is the right default for a project whose primary build has to work on any machine.

## Two schemes with very different capabilities

The library is organised as primitives under two schemes plus one application on top.

cuhepy.paillier is Paillier, plus an alpha-subgroup variant with precomputed tables. Its backends are pure Python with GMP, or CUDA. It is homomorphic over addition only. cuhepy.bfv is leveled BFV over the ring of polynomials Z_q[X]/(X^N+1), with SIMD batching. Its backends are pure Python with GMP, a cached GMP variant, and a C++ RNS/NTT implementation. It is homomorphic over addition, multiplication and rotation, which is the reason BFV can compute rather than merely count.

Both are the primitives layer: key generation, encryption, decryption and the homomorphic operations, over arbitrary integers. The design keeps the schemes independent of each other, which is stated in the README, and the application is kept separate so that neither depends on the other.

The scheme modules are usable directly, without any of the search machinery on top:

```python
from cuhepy.paillier.scheme import Paillier

kp = Paillier.key_gen(1024)
c = Paillier.add(Paillier.encrypt(7, kp["pk"]), Paillier.encrypt(35, kp["pk"]), kp["pk"])
assert Paillier.decrypt(c, kp) == 42
```

The rotate operation in BFV is the one to note. Rotation of plaintext slots is what makes packed SIMD arithmetic possible, and it is also what lets many distances be packed into a single ciphertext rather than one response per vector.

## Encrypted nearest-neighbour search, and who learns what

The application layer, cuhepy.hamming, is encrypted k-nearest-neighbour search. An evaluator holding only the public key ranks encrypted vectors by Hamming distance without learning the vectors, the query or the distances. The README is explicit that this is the workload the compiled backends were originally written for.

The quick start shows the three-party split clearly. The client encrypts two vectors and only those ciphertexts leave. The evaluator, with the public key and one modular multiply per chunk, computes the distance on the ciphertexts. The client decrypts the result and checks it against the plain answer:

```python
from cuhepy.hamming.paillier_lookup import PaillierLookupClient

client = PaillierLookupClient(embed_len=512, key_len=1024)

a = [i % 2 for i in range(512)]
b = [(i // 2) % 2 for i in range(512)]

# Client side: encrypt. Only these ciphertexts leave.
ct_a = client.encrypt_vec_one(a)
ct_b = client.encrypt_vec_one(b)

# Evaluator side: public key only, one modular multiply per chunk.
ct_distance = client.encode_hamming_server(ct_a, ct_b)

# Client side: decrypt.
assert client.decode_hamming_client_one(ct_distance) == sum(x ^ y for x, y in zip(a, b))
```

The threat model is stated with the same precision. The evaluator receives the public key, the encrypted index and the encrypted queries, and never receives the secret key. Hidden are the vector contents, the query contents and the computed distances. Visible are how many vectors exist, which one was queried, when, and any plaintext metadata the caller chooses to attach.

That visible list is the part to think about. A scheme that hides the vectors still leaks the access pattern of the search, and in a nearest-neighbour workload the access pattern can be informative on its own.

Key handling is deliberately left out of scope. The cuhepy.keys helper saves the keypair with the secret key written in plaintext at mode 0600, and the README says plainly that key management is out of scope:

```python
from cuhepy import keys

keys.save(client, "key.json", include_tables=True)
restored = keys.load("key.json")
```

## The cost sits in different places in each scheme

The README gives per-operation costs for a 512-dimensional binary vector, single CPU thread, key_len 1024, and the table is the fastest way to understand why both schemes exist.

For Paillier: encrypt is 6.9 ms, evaluate is 3 microseconds, decrypt is 4.6 ms, and the response is 512 bytes per vector. The Paillier-Lookup variant with alpha 280 does better on the client side: 0.5 ms to encrypt and 0.66 ms to decrypt, with the same 3 microsecond evaluation and the same 512 bytes per vector. For BFV in packed mode, evaluation is about 2.4 ms, decryption is roughly constant per query, and the response is around 100 to 200 KB in total.

The README draws the conclusion from those numbers. Paillier is cheap for the evaluator and expensive for the client, whose work grows linearly with the corpus. BFV inverts that: the evaluator does real work, but packs many distances into one response ciphertext, so the client decrypts once and the response stops growing.

That is the whole trade. With Paillier the client pays per vector it holds, which is the wrong shape when the client owns a large index. With BFV the evaluator pays, which is the wrong shape when the evaluator is untrusted and unpersuasive. Which one you want depends entirely on who is paying for the computation, and the raw numbers and the measurement harness are in benchmarks/ and docs/research.

## The attacks the project publishes against itself

The security section is the most valuable part of the repository, because the authors document how their own schemes fail against a malicious evaluator.

Both schemes are secure against an honest-but-curious evaluator, one that follows the protocol and only observes. Against a malicious evaluator that crafts ciphertexts and watches how the client reacts, both are broken, and three attacks are given.

PL-01 is a key recovery against the alpha-subgroup variant: the secret key is recoverable from the public key if alpha is too short. At the old 50-bit default, the README says that took about 3.5 minutes on a laptop. It has been fixed, with alpha of at least 256 now enforced, and the demonstration is kept as a regression test.

PL-04 is a different kind of attack. A malicious evaluator recovers stored bits by watching which result the client fetches. No cryptanalysis of the scheme is needed, only the observation that a client asking for the top result leaks something about it.

BFV-01 is a decryption oracle: accept and reject feedback from the client is enough to recover the BFV secret key. The modules cuhepy.hamming.bfv_guarded, bfv_verified and bfv_attested are experimental protocol layers that address BFV-01, at a cost in trust and in computation.

The full write-ups are in docs/research/paillier-security.md and docs/research/native-bfv-security.md. The README's instruction is to read those before trusting anything, and the reason it can say that is that it ships them.

## Optional CUDA and C++ backends, and how to reproduce the results

The compiled backends are the performance story, and both are optional.

The CUDA backend for Paillier builds inside a pinned nvidia/cuda container, so the build host needs only Docker rather than a GPU or a local CUDA toolkit:

```bash
./build_gpu_binaries.sh      # -> src/cuhepy/paillier/_{,lookup_}gpu_ext/*.so
```

At runtime it needs an NVIDIA driver of at least 550 and a GPU of compute capability 7.0 to 9.0. Clients probe for the extension when they are constructed, and device="cpu" or device="gpu" forces a backend.

The C++ RNS and NTT backend for BFV is built with make -C src/cuhepy/bfv/_cpu_ext and selected through BFVClient with a server_backend argument of rns, native or residue.

Verification is the part most projects skip and this one makes easy. Everything runs offline, with no account or service required:

```bash
uv sync --all-groups
uv run pytest tests/                                  # full offline suite
uv run python attacks/pl01_alpha_recovery.py          # recover a key from a public key
```

Running the attack script is the striking one. You are not asked to trust the claim that PL-01 works, you are handed a command that reproduces it.

The repository layout is conventional for the language, with src/cuhepy holding the primitives and the application, an attacks/ directory, a benchmarks/ directory, docs/, experiments/, tests/, a build_gpu_binaries.sh script at the root, and a hatch_build.py whose job is to flip the wheel from a pure-Python tag to a platform-specific one when compiled extensions are bundled.

## Conclusion

cuhepy suits a researcher who wants to read, measure or break homomorphic encryption implementations rather than deploy one, and who needs the code and the attack scripts in the same repository. It does not suit an application that needs to protect real data, because the README says so directly and also because it is not constant-time and not audited. Before you experiment with it, read the attacks directory and the two research write-ups, then decide whether the alpha-subgroup recovery, the stored-bit recovery or the decryption-oracle attack matters for the property you care about, since a scheme that is broken against a malicious evaluator is a research result rather than a product.

## FAQ

### Is cuhepy safe to use for real data?

No, and the README says so directly. The project is experimental research code, not audited, not constant-time, and not suitable for protecting real data, with both schemes implemented from scratch for study and measurement. The authors publish the attacks they found against their own code in the attacks/ directory.

### Which homomorphic encryption schemes does cuhepy implement?

Paillier, including an alpha-subgroup variant with precomputed tables, which is homomorphic over addition, and leveled BFV over Z_q[X]/(X^N+1) with SIMD batching, which is homomorphic over addition, multiplication and rotation. Both have pure Python and GMP paths, with a CUDA backend for Paillier and a C++ RNS/NTT backend for BFV.

### What does cuhepy.hamming do?

It is encrypted k-nearest-neighbour search. An evaluator holding only the public key ranks encrypted vectors by Hamming distance without learning the vectors, the query or the distances. The threat model hides the vector contents, query contents and computed distances, while how many vectors exist, which one was queried and when remain visible.

### Which attacks against cuhepy does the project publish?

PL-01 recovers the secret key from the public key in the alpha-subgroup variant when alpha is too short, fixed by enforcing alpha of at least 256 and kept as a regression. PL-04 has a malicious evaluator recover stored bits by watching which result the client fetches. BFV-01 is a decryption oracle that recovers the BFV secret key from accept and reject feedback.

### How do I install cuhepy and run its tests?

Install it with pip install cuhepy, which needs Python 3.11 or newer and the gmpy2, msgpack, numpy and pycryptodome dependencies for the CPU path. For the full offline suite and the published attacks, the README uses uv sync --all-groups followed by uv run pytest tests/ and uv run python attacks/pl01_alpha_recovery.py.

## Sources

- [Issues](https://github.com/XTraceAI/cuhepy/issues)
- [License: Apache-2.0](https://github.com/XTraceAI/cuhepy/blob/main/LICENSE)
- [README](https://github.com/XTraceAI/cuhepy/blob/main/README.md)
- [XTraceAI/cuhepy on GitHub](https://github.com/XTraceAI/cuhepy)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/xtraceai-cuhepy
