Skip to content

Commit 346ca90

Browse files
committed
fix: revert encrypt workaround after Poulpy prepare fix
- Bump poulpy to rev 067fd785; encrypt via FheUint::encrypt_sk + glwe enc infos - Drop prepared cache on Ciphertext; eval_binary prepares operands with fhe_uint_prepare_tmp_bytes - Size scratch from Poulpy *_tmp_bytes for encrypt, decrypt, keygen, and each homomorphic op - encrypt now takes (value, sk) only; update README, example, and tests - Mark milestone issue #24 done in README
1 parent 20015a7 commit 346ca90

10 files changed

Lines changed: 108 additions & 143 deletions

Cargo.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ license = "MIT"
99
backend-avx = ["dep:poulpy-cpu-avx"]
1010

1111
[dependencies]
12-
poulpy-core = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968" }
13-
poulpy-schemes = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968" }
14-
poulpy-hal = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968" }
15-
poulpy-cpu-ref = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968" }
16-
poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968", optional = true }
12+
poulpy-core = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f" }
13+
poulpy-schemes = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f" }
14+
poulpy-hal = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f" }
15+
poulpy-cpu-ref = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f" }
16+
poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f", optional = true }
1717
getrandom = "0.3"
1818

1919
[target.'cfg(target_arch = "x86_64")'.dependencies]
20-
poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968", optional = true, features = ["enable-avx"] }
20+
poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f", optional = true, features = ["enable-avx"] }

README.md

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# 🦑 Squid
2-
2+
33
**An ergonomic Rust wrapper for [Poulpy](https://github.com/phantomzone-org/poulpy), making Fully Homomorphic Encryption accessible without sacrificing control.**
4-
4+
55
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![CI](https://github.com/cedoor/squid/actions/workflows/ci.yml/badge.svg)](https://github.com/cedoor/squid/actions) ![Status](https://img.shields.io/badge/status-early%20development-orange)
66

77
Poulpy is a low-level, modular toolkit exposing the full machinery of lattice-based homomorphic encryption. That power comes with sharp edges: manual scratch arenas, explicit lifecycle transitions, trait-heavy APIs. `squid` wraps Poulpy with a smaller, opinionated surface so you can write FHE programs without managing every byte of workspace memory or tracking which representation a ciphertext currently lives in.
@@ -21,8 +21,8 @@ fn main() {
2121
let (sk, ek) = ctx.keygen();
2222

2323
// Encrypt two 32-bit integers
24-
let a = ctx.encrypt::<u32>(255, &sk, &ek);
25-
let b = ctx.encrypt::<u32>(30, &sk, &ek);
24+
let a = ctx.encrypt::<u32>(255, &sk);
25+
let b = ctx.encrypt::<u32>(30, &sk);
2626

2727
// Homomorphic addition: computes (a + b) under encryption
2828
let c = ctx.add(&a, &b, &ek);
@@ -38,24 +38,24 @@ fn main() {
3838

3939
All operations currently require `T = u32` (the only width with compiled BDD circuits in Poulpy). Encrypt and decrypt work for `u8`, `u16`, and `u32`.
4040

41-
| Method | Description |
42-
|---|---|
43-
| `ctx.add(a, b, ek)` | Wrapping addition |
44-
| `ctx.sub(a, b, ek)` | Wrapping subtraction |
45-
| `ctx.and(a, b, ek)` | Bitwise AND |
46-
| `ctx.or(a, b, ek)` | Bitwise OR |
47-
| `ctx.xor(a, b, ek)` | Bitwise XOR |
48-
| `ctx.sll(a, b, ek)` | Logical left shift |
49-
| `ctx.srl(a, b, ek)` | Logical right shift |
50-
| `ctx.sra(a, b, ek)` | Arithmetic right shift |
51-
| `ctx.slt(a, b, ek)` | Signed less-than |
52-
| `ctx.sltu(a, b, ek)` | Unsigned less-than |
41+
| Method | Description |
42+
| -------------------- | ---------------------- |
43+
| `ctx.add(a, b, ek)` | Wrapping addition |
44+
| `ctx.sub(a, b, ek)` | Wrapping subtraction |
45+
| `ctx.and(a, b, ek)` | Bitwise AND |
46+
| `ctx.or(a, b, ek)` | Bitwise OR |
47+
| `ctx.xor(a, b, ek)` | Bitwise XOR |
48+
| `ctx.sll(a, b, ek)` | Logical left shift |
49+
| `ctx.srl(a, b, ek)` | Logical right shift |
50+
| `ctx.sra(a, b, ek)` | Arithmetic right shift |
51+
| `ctx.slt(a, b, ek)` | Signed less-than |
52+
| `ctx.sltu(a, b, ek)` | Unsigned less-than |
5353

5454
## Backends
5555

56-
| Feature | Backend | Notes |
57-
|---------------|------------|--------------------------------|
58-
| *(default)* | `FFT64Ref` | Portable |
56+
| Feature | Backend | Notes |
57+
| ------------- | ---------- | ------------------------------- |
58+
| _(default)_ | `FFT64Ref` | Portable |
5959
| `backend-avx` | `FFT64Avx` | x86-64, AVX2+FMA (~3–5× vs ref) |
6060

6161
```sh
@@ -84,7 +84,7 @@ The public API is identical regardless of which backend is selected.
8484
- [ ] Identity / noise refresh: [#11](https://github.com/cedoor/squid/issues/11)
8585
- [ ] NTT backend: [#12](https://github.com/cedoor/squid/issues/12)
8686
- [x] Key serialization: [#13](https://github.com/cedoor/squid/issues/13)
87-
- [ ] Revert `encrypt` workaround once upstream poulpy bug is fixed: [#24](https://github.com/cedoor/squid/issues/24)
87+
- [x] Revert `encrypt` workaround once upstream poulpy bug is fixed: [#24](https://github.com/cedoor/squid/issues/24)
8888

8989
### Milestone 3 — Developer Experience & Optimization: [#3](https://github.com/cedoor/squid/milestone/3)
9090

examples/add_u32.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ fn main() {
2020
let b: u32 = 30;
2121

2222
println!("Encrypting {a} and {b}...");
23-
let ct_a = ctx.encrypt::<u32>(a, &sk, &ek);
24-
let ct_b = ctx.encrypt::<u32>(b, &sk, &ek);
23+
let ct_a = ctx.encrypt::<u32>(a, &sk);
24+
let ct_b = ctx.encrypt::<u32>(b, &sk);
2525

2626
println!("Computing homomorphic addition...");
2727
let ct_c = ctx.add(&ct_a, &ct_b, &ek);

src/ciphertext.rs

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,9 @@
11
//! The user-facing ciphertext type.
22
//!
3-
//! [`Ciphertext<T>`] wraps Poulpy's packed `FheUint<Vec<u8>, T>` (the wire
4-
//! format) and additionally caches the prepared (DFT-domain) `FheUintPrepared`
5-
//! produced at encryption time. Homomorphic ops consume the prepared cache;
6-
//! [`Context::decrypt`](crate::context::Context::decrypt) and
7-
//! [`Ciphertext::serialize`] use the packed form only.
8-
//!
9-
//! ## Chaining limitation
10-
//!
11-
//! In the currently pinned Poulpy revision, the `FheUint -> FheUintPrepared`
12-
//! re-prepare path produces incorrect results, so a ciphertext that has lost
13-
//! its prepared cache (an op result, or a freshly deserialized blob) cannot be
14-
//! used as input to another homomorphic op. Doing so panics with a descriptive
15-
//! message. This restriction will lift as upstream Poulpy stabilizes that
16-
//! pipeline.
3+
//! [`Ciphertext<T>`] is a thin wrapper over Poulpy's packed
4+
//! `FheUint<Vec<u8>, T>`. Homomorphic ops re-prepare each input on demand
5+
//! inside [`crate::context::Context`]; the DFT-domain form is never cached on
6+
//! the user-visible type and never surfaces in the public API.
177
//!
188
//! Standard-form wire encoding is [`Ciphertext::serialize`] /
199
//! [`Ciphertext::deserialize`] / [`crate::context::Context::serialize_ciphertext`] /
@@ -24,8 +14,8 @@
2414
use std::io;
2515

2616
use poulpy_core::layouts::{GLWEInfos, GLWEToRef};
27-
use poulpy_hal::layouts::{DeviceBuf, WriterTo};
28-
use poulpy_schemes::bin_fhe::bdd_arithmetic::{FheUint, FheUintPrepared, UnsignedInteger};
17+
use poulpy_hal::layouts::WriterTo;
18+
use poulpy_schemes::bin_fhe::bdd_arithmetic::{FheUint, UnsignedInteger};
2919

3020
use crate::context::Context;
3121

@@ -42,22 +32,17 @@ pub(crate) const CIPHERTEXT_BLOB_VERSION: u8 = 1;
4232
///
4333
/// ## Lifecycle
4434
///
45-
/// 1. Create with [`crate::Context::encrypt`] (caches the prepared form for ops).
35+
/// 1. Create with [`crate::Context::encrypt`].
4636
/// 2. Pass to homomorphic operations (`ctx.add`, `ctx.xor`, …).
4737
/// 3. Recover the plaintext with [`crate::Context::decrypt`].
4838
pub struct Ciphertext<T: UnsignedInteger> {
4939
pub(crate) inner: FheUint<Vec<u8>, T>,
50-
pub(crate) prepared:
51-
Option<FheUintPrepared<DeviceBuf<crate::backend::BE>, T, crate::backend::BE>>,
5240
}
5341

5442
impl<T: UnsignedInteger> Ciphertext<T> {
5543
/// Serializes the packed GLWE ciphertext (little-endian, versioned). The plaintext type `T`
5644
/// is recorded in the blob; use the same `T` with [`Ciphertext::deserialize`].
5745
///
58-
/// The prepared cache is **not** serialized; deserialized ciphertexts can only be
59-
/// decrypted (see module-level note about chaining).
60-
///
6146
/// Same as [`crate::context::Context::serialize_ciphertext`] with this value.
6247
pub fn serialize(&self) -> io::Result<Vec<u8>> {
6348
let mut out = Vec::new();

src/context.rs

Lines changed: 54 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
//! let mut ctx = Context::new(Params::unsecure()).with_options(ContextOptions::default());
1212
//! let (sk, ek) = ctx.keygen();
1313
//!
14-
//! let a = ctx.encrypt::<u32>(42, &sk, &ek);
15-
//! let b = ctx.encrypt::<u32>(7, &sk, &ek);
14+
//! let a = ctx.encrypt::<u32>(42, &sk);
15+
//! let b = ctx.encrypt::<u32>(7, &sk);
1616
//! let c = ctx.add(&a, &b, &ek);
1717
//! let result: u32 = ctx.decrypt(&c, &sk);
1818
//! ```
@@ -35,8 +35,8 @@ use poulpy_hal::{
3535
use poulpy_schemes::bin_fhe::{
3636
bdd_arithmetic::{
3737
Add, And, BDDEncryptionInfos, BDDKey, BDDKeyEncryptSk, BDDKeyLayout, BDDKeyPrepared,
38-
BDDKeyPreparedFactory, FheUint, FheUintPrepared, FromBits, Or, Sll, Slt, Sltu, Sra, Srl,
39-
Sub, ToBits, UnsignedInteger, Xor,
38+
BDDKeyPreparedFactory, FheUint, FheUintPrepare, FheUintPrepared, FromBits, Or, Sll, Slt,
39+
Sltu, Sra, Srl, Sub, ToBits, UnsignedInteger, Xor,
4040
},
4141
blind_rotation::{BlindRotationKeyLayout, CGGI},
4242
circuit_bootstrapping::CircuitBootstrappingKeyLayout,
@@ -626,79 +626,44 @@ impl Context {
626626
"trailing bytes in ciphertext blob",
627627
));
628628
}
629-
Ok(Ciphertext {
630-
inner: fhe_uint,
631-
prepared: None,
632-
})
629+
Ok(Ciphertext { inner: fhe_uint })
633630
}
634631

635632
// ── Encrypt / Decrypt ────────────────────────────────────────────────────
636633

637634
/// Encrypt a plaintext value under the given secret key.
638635
///
639-
/// Internally encrypts directly to the prepared (DFT-domain) form via
640-
/// `FheUintPrepared::encrypt_sk`, then packs to a standard `FheUint` via
641-
/// `from_fhe_uint_prepared` (which is why an [`EvaluationKey`] is required).
642-
/// This matches the path validated by Poulpy's `test_bdd_add` and avoids the
643-
/// `FheUint::encrypt_sk -> FheUintPrepared::prepare` pipeline, which is
644-
/// currently broken upstream (`b598566`).
645-
///
646-
/// The cached prepared form is consumed by homomorphic ops; the packed
647-
/// inner form is used for [`Context::decrypt`] and serialization.
636+
/// Packs the bits of `value` into a single standard-form GLWE ciphertext
637+
/// via `FheUint::encrypt_sk`. The DFT-domain prepared form is rebuilt
638+
/// on demand inside [`Context::eval_binary`] when the value is used as an
639+
/// operand.
648640
///
649641
/// `T` must be one of `u8`, `u16`, `u32`, `u64`, `u128`. Note that
650642
/// homomorphic arithmetic operations are currently only implemented for
651643
/// `u32` (the only type with compiled BDD circuits in `poulpy-schemes`).
652-
pub fn encrypt<T>(&mut self, value: T, sk: &SecretKey, ek: &EvaluationKey) -> Ciphertext<T>
644+
pub fn encrypt<T>(&mut self, value: T, sk: &SecretKey) -> Ciphertext<T>
653645
where
654-
T: UnsignedInteger + ToBits + FromBits,
646+
T: UnsignedInteger + ToBits,
655647
{
656648
let mut source_xa = random_source();
657649
let mut source_xe = random_source();
658-
let ggsw_enc_infos = EncryptionLayout::new_from_default_sigma(self.params.ggsw_layout)
659-
.expect("default GGSW encryption sigma");
660-
661-
// TODO(poulpy-bug): switch to dynamic sizing once poulpy fixes the
662-
// upstream `FheUint::encrypt_sk -> FheUintPrepared::prepare` bug
663-
// (see `crate::ciphertext` module docs). Once fixed, encrypt should
664-
// route through that path and use `FheUint::encrypt_sk_tmp_bytes` +
665-
// `Module::fhe_uint_prepare_tmp_bytes` for exact scratch sizing.
666-
//
667-
// Until then we work around the bug via `FheUintPrepared::encrypt_sk`
668-
// followed by `FheUint::from_fhe_uint_prepared`. Poulpy exposes no
669-
// wrapper-level `*_tmp_bytes` helpers for either, and hand-composing
670-
// from primitives is fragile (both wrappers call into deeper helpers
671-
// like `glwe_pack -> glwe_trace` whose runtime scratch checks don't
672-
// match a naive sum of public `_tmp_bytes`). Poulpy's own
673-
// `bdd_arithmetic` example/tests use a single 4 MiB arena for the
674-
// whole pipeline; we do the same here for these two sequential ops.
675-
const ENCRYPT_SCRATCH_BYTES: usize = 1 << 22;
676-
let mut scratch_arena = scratch::new_arena(ENCRYPT_SCRATCH_BYTES);
677-
678-
let mut prepared: FheUintPrepared<DeviceBuf<crate::backend::BE>, T, crate::backend::BE> =
679-
FheUintPrepared::alloc_from_infos(&self.module, &self.params.ggsw_layout);
680-
prepared.encrypt_sk(
650+
let glwe_enc_infos = EncryptionLayout::new_from_default_sigma(self.params.glwe_layout)
651+
.expect("default GLWE encryption sigma");
652+
653+
let mut fhe_uint: FheUint<Vec<u8>, T> = FheUint::alloc_from_infos(&self.params.glwe_layout);
654+
let enc_bytes = fhe_uint.encrypt_sk_tmp_bytes(&self.module);
655+
let mut scratch_e = scratch::new_arena(enc_bytes);
656+
fhe_uint.encrypt_sk(
681657
&self.module,
682658
value,
683659
&sk.sk_glwe_prepared,
684-
&ggsw_enc_infos,
660+
&glwe_enc_infos,
685661
&mut source_xe,
686662
&mut source_xa,
687-
scratch::borrow(&mut scratch_arena),
663+
scratch::borrow(&mut scratch_e),
688664
);
689665

690-
let mut packed: FheUint<Vec<u8>, T> = FheUint::alloc_from_infos(&self.params.glwe_layout);
691-
packed.from_fhe_uint_prepared(
692-
&self.module,
693-
&prepared,
694-
&ek.bdd_key_prepared,
695-
scratch::borrow(&mut scratch_arena),
696-
);
697-
698-
Ciphertext {
699-
inner: packed,
700-
prepared: Some(prepared),
701-
}
666+
Ciphertext { inner: fhe_uint }
702667
}
703668

704669
/// Decrypt a ciphertext and return the plaintext value.
@@ -717,12 +682,10 @@ impl Context {
717682

718683
// ── Internal helper ───────────────────────────────────────────────────────
719684

720-
/// Run a binary op on the prepared form of `a` and `b`.
685+
/// Prepare `a` and `b`, run `op`, and return the result.
721686
///
722-
/// Both inputs must carry their prepared cache (i.e. come straight from
723-
/// [`Context::encrypt`]). Op outputs and deserialized ciphertexts have
724-
/// no cache and panic with a clear message — see the [`crate::ciphertext`]
725-
/// module docs for the upstream limitation.
687+
/// Builds a fresh `FheUintPrepared` for each input on every call, then invokes `op` with both prepared operands and a
688+
/// scratch region sized to whichever of prepare / op is larger.
726689
fn eval_binary<T, F>(
727690
&mut self,
728691
a: &Ciphertext<T>,
@@ -743,27 +706,44 @@ impl Context {
743706
&mut poulpy_hal::layouts::Scratch<crate::backend::BE>,
744707
),
745708
{
746-
const NO_PREPARED_CACHE: &str =
747-
"ciphertext lacks prepared cache; only freshly encrypted ciphertexts can be operated \
748-
on in this Poulpy revision (see ciphertext module docs)";
749-
let a_prep = a.prepared.as_ref().expect(NO_PREPARED_CACHE);
750-
let b_prep = b.prepared.as_ref().expect(NO_PREPARED_CACHE);
709+
let mut a_prep: FheUintPrepared<DeviceBuf<crate::backend::BE>, T, crate::backend::BE> =
710+
FheUintPrepared::alloc_from_infos(&self.module, &self.params.ggsw_layout);
711+
let mut b_prep: FheUintPrepared<DeviceBuf<crate::backend::BE>, T, crate::backend::BE> =
712+
FheUintPrepared::alloc_from_infos(&self.module, &self.params.ggsw_layout);
713+
714+
let prepare_bytes = self.module.fhe_uint_prepare_tmp_bytes(
715+
self.params.binary_block_size as usize,
716+
1,
717+
&self.params.ggsw_layout,
718+
&self.params.glwe_layout,
719+
&ek.bdd_key_prepared,
720+
);
721+
let mut scratch_arena = scratch::new_arena(prepare_bytes.max(eval_scratch_bytes));
722+
723+
a_prep.prepare::<CGGI, _, _, _, _>(
724+
&self.module,
725+
&a.inner,
726+
&ek.bdd_key_prepared,
727+
scratch::borrow(&mut scratch_arena),
728+
);
729+
b_prep.prepare::<CGGI, _, _, _, _>(
730+
&self.module,
731+
&b.inner,
732+
&ek.bdd_key_prepared,
733+
scratch::borrow(&mut scratch_arena),
734+
);
751735

752736
let mut out: FheUint<Vec<u8>, T> = FheUint::alloc_from_infos(&self.params.glwe_layout);
753-
let mut scratch_eval = scratch::new_arena(eval_scratch_bytes);
754737
op(
755738
&self.module,
756739
self.options.eval_threads,
757740
&mut out,
758-
a_prep,
759-
b_prep,
741+
&a_prep,
742+
&b_prep,
760743
&ek.bdd_key_prepared,
761-
scratch::borrow(&mut scratch_eval),
744+
scratch::borrow(&mut scratch_arena),
762745
);
763-
Ciphertext {
764-
inner: out,
765-
prepared: None,
766-
}
746+
Ciphertext { inner: out }
767747
}
768748

769749
// ── Arithmetic and logical operations ────────────────────────────────────

0 commit comments

Comments
 (0)