Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ license = "MIT"
backend-avx = ["dep:poulpy-cpu-avx"]

[dependencies]
poulpy-core = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968" }
poulpy-schemes = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968" }
poulpy-hal = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968" }
poulpy-cpu-ref = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968" }
poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968", optional = true }
poulpy-core = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f" }
poulpy-schemes = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f" }
poulpy-hal = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f" }
poulpy-cpu-ref = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f" }
poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f", optional = true }
getrandom = "0.3"

[target.'cfg(target_arch = "x86_64")'.dependencies]
poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "b598566cef299a20ac9b159eef61aeadbf66f968", optional = true, features = ["enable-avx"] }
poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "067fd785a1d9087f7d9fa437a8503a2d74ac737f", optional = true, features = ["enable-avx"] }
40 changes: 20 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 🦑 Squid

**An ergonomic Rust wrapper for [Poulpy](https://github.com/phantomzone-org/poulpy), making Fully Homomorphic Encryption accessible without sacrificing control.**

[![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)

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.
Expand All @@ -21,8 +21,8 @@ fn main() {
let (sk, ek) = ctx.keygen();

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

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

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

| Method | Description |
|---|---|
| `ctx.add(a, b, ek)` | Wrapping addition |
| `ctx.sub(a, b, ek)` | Wrapping subtraction |
| `ctx.and(a, b, ek)` | Bitwise AND |
| `ctx.or(a, b, ek)` | Bitwise OR |
| `ctx.xor(a, b, ek)` | Bitwise XOR |
| `ctx.sll(a, b, ek)` | Logical left shift |
| `ctx.srl(a, b, ek)` | Logical right shift |
| `ctx.sra(a, b, ek)` | Arithmetic right shift |
| `ctx.slt(a, b, ek)` | Signed less-than |
| `ctx.sltu(a, b, ek)` | Unsigned less-than |
| Method | Description |
| -------------------- | ---------------------- |
| `ctx.add(a, b, ek)` | Wrapping addition |
| `ctx.sub(a, b, ek)` | Wrapping subtraction |
| `ctx.and(a, b, ek)` | Bitwise AND |
| `ctx.or(a, b, ek)` | Bitwise OR |
| `ctx.xor(a, b, ek)` | Bitwise XOR |
| `ctx.sll(a, b, ek)` | Logical left shift |
| `ctx.srl(a, b, ek)` | Logical right shift |
| `ctx.sra(a, b, ek)` | Arithmetic right shift |
| `ctx.slt(a, b, ek)` | Signed less-than |
| `ctx.sltu(a, b, ek)` | Unsigned less-than |

## Backends

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

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

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

Expand Down
4 changes: 2 additions & 2 deletions examples/add_u32.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ fn main() {
let b: u32 = 30;

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

println!("Computing homomorphic addition...");
let ct_c = ctx.add(&ct_a, &ct_b, &ek);
Expand Down
29 changes: 7 additions & 22 deletions src/ciphertext.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
//! The user-facing ciphertext type.
//!
//! [`Ciphertext<T>`] wraps Poulpy's packed `FheUint<Vec<u8>, T>` (the wire
//! format) and additionally caches the prepared (DFT-domain) `FheUintPrepared`
//! produced at encryption time. Homomorphic ops consume the prepared cache;
//! [`Context::decrypt`](crate::context::Context::decrypt) and
//! [`Ciphertext::serialize`] use the packed form only.
//!
//! ## Chaining limitation
//!
//! In the currently pinned Poulpy revision, the `FheUint -> FheUintPrepared`
//! re-prepare path produces incorrect results, so a ciphertext that has lost
//! its prepared cache (an op result, or a freshly deserialized blob) cannot be
//! used as input to another homomorphic op. Doing so panics with a descriptive
//! message. This restriction will lift as upstream Poulpy stabilizes that
//! pipeline.
//! [`Ciphertext<T>`] is a thin wrapper over Poulpy's packed
//! `FheUint<Vec<u8>, T>`. Homomorphic ops re-prepare each input on demand
//! inside [`crate::context::Context`]; the DFT-domain form is never cached on
//! the user-visible type and never surfaces in the public API.
//!
//! Standard-form wire encoding is [`Ciphertext::serialize`] /
//! [`Ciphertext::deserialize`] / [`crate::context::Context::serialize_ciphertext`] /
Expand All @@ -24,8 +14,8 @@
use std::io;

use poulpy_core::layouts::{GLWEInfos, GLWEToRef};
use poulpy_hal::layouts::{DeviceBuf, WriterTo};
use poulpy_schemes::bin_fhe::bdd_arithmetic::{FheUint, FheUintPrepared, UnsignedInteger};
use poulpy_hal::layouts::WriterTo;
use poulpy_schemes::bin_fhe::bdd_arithmetic::{FheUint, UnsignedInteger};

use crate::context::Context;

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

impl<T: UnsignedInteger> Ciphertext<T> {
/// Serializes the packed GLWE ciphertext (little-endian, versioned). The plaintext type `T`
/// is recorded in the blob; use the same `T` with [`Ciphertext::deserialize`].
///
/// The prepared cache is **not** serialized; deserialized ciphertexts can only be
/// decrypted (see module-level note about chaining).
///
/// Same as [`crate::context::Context::serialize_ciphertext`] with this value.
pub fn serialize(&self) -> io::Result<Vec<u8>> {
let mut out = Vec::new();
Expand Down
128 changes: 54 additions & 74 deletions src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
//! let mut ctx = Context::new(Params::unsecure()).with_options(ContextOptions::default());
//! let (sk, ek) = ctx.keygen();
//!
//! let a = ctx.encrypt::<u32>(42, &sk, &ek);
//! let b = ctx.encrypt::<u32>(7, &sk, &ek);
//! let a = ctx.encrypt::<u32>(42, &sk);
//! let b = ctx.encrypt::<u32>(7, &sk);
//! let c = ctx.add(&a, &b, &ek);
//! let result: u32 = ctx.decrypt(&c, &sk);
//! ```
Expand All @@ -35,8 +35,8 @@ use poulpy_hal::{
use poulpy_schemes::bin_fhe::{
bdd_arithmetic::{
Add, And, BDDEncryptionInfos, BDDKey, BDDKeyEncryptSk, BDDKeyLayout, BDDKeyPrepared,
BDDKeyPreparedFactory, FheUint, FheUintPrepared, FromBits, Or, Sll, Slt, Sltu, Sra, Srl,
Sub, ToBits, UnsignedInteger, Xor,
BDDKeyPreparedFactory, FheUint, FheUintPrepare, FheUintPrepared, FromBits, Or, Sll, Slt,
Sltu, Sra, Srl, Sub, ToBits, UnsignedInteger, Xor,
},
blind_rotation::{BlindRotationKeyLayout, CGGI},
circuit_bootstrapping::CircuitBootstrappingKeyLayout,
Expand Down Expand Up @@ -626,79 +626,44 @@ impl Context {
"trailing bytes in ciphertext blob",
));
}
Ok(Ciphertext {
inner: fhe_uint,
prepared: None,
})
Ok(Ciphertext { inner: fhe_uint })
}

// ── Encrypt / Decrypt ────────────────────────────────────────────────────

/// Encrypt a plaintext value under the given secret key.
///
/// Internally encrypts directly to the prepared (DFT-domain) form via
/// `FheUintPrepared::encrypt_sk`, then packs to a standard `FheUint` via
/// `from_fhe_uint_prepared` (which is why an [`EvaluationKey`] is required).
/// This matches the path validated by Poulpy's `test_bdd_add` and avoids the
/// `FheUint::encrypt_sk -> FheUintPrepared::prepare` pipeline, which is
/// currently broken upstream (`b598566`).
///
/// The cached prepared form is consumed by homomorphic ops; the packed
/// inner form is used for [`Context::decrypt`] and serialization.
/// Packs the bits of `value` into a single standard-form GLWE ciphertext
/// via `FheUint::encrypt_sk`. The DFT-domain prepared form is rebuilt
/// on demand inside [`Context::eval_binary`] when the value is used as an
/// operand.
///
/// `T` must be one of `u8`, `u16`, `u32`, `u64`, `u128`. Note that
/// homomorphic arithmetic operations are currently only implemented for
/// `u32` (the only type with compiled BDD circuits in `poulpy-schemes`).
pub fn encrypt<T>(&mut self, value: T, sk: &SecretKey, ek: &EvaluationKey) -> Ciphertext<T>
pub fn encrypt<T>(&mut self, value: T, sk: &SecretKey) -> Ciphertext<T>
where
T: UnsignedInteger + ToBits + FromBits,
T: UnsignedInteger + ToBits,
{
let mut source_xa = random_source();
let mut source_xe = random_source();
let ggsw_enc_infos = EncryptionLayout::new_from_default_sigma(self.params.ggsw_layout)
.expect("default GGSW encryption sigma");

// TODO(poulpy-bug): switch to dynamic sizing once poulpy fixes the
// upstream `FheUint::encrypt_sk -> FheUintPrepared::prepare` bug
// (see `crate::ciphertext` module docs). Once fixed, encrypt should
// route through that path and use `FheUint::encrypt_sk_tmp_bytes` +
// `Module::fhe_uint_prepare_tmp_bytes` for exact scratch sizing.
//
// Until then we work around the bug via `FheUintPrepared::encrypt_sk`
// followed by `FheUint::from_fhe_uint_prepared`. Poulpy exposes no
// wrapper-level `*_tmp_bytes` helpers for either, and hand-composing
// from primitives is fragile (both wrappers call into deeper helpers
// like `glwe_pack -> glwe_trace` whose runtime scratch checks don't
// match a naive sum of public `_tmp_bytes`). Poulpy's own
// `bdd_arithmetic` example/tests use a single 4 MiB arena for the
// whole pipeline; we do the same here for these two sequential ops.
const ENCRYPT_SCRATCH_BYTES: usize = 1 << 22;
let mut scratch_arena = scratch::new_arena(ENCRYPT_SCRATCH_BYTES);

let mut prepared: FheUintPrepared<DeviceBuf<crate::backend::BE>, T, crate::backend::BE> =
FheUintPrepared::alloc_from_infos(&self.module, &self.params.ggsw_layout);
prepared.encrypt_sk(
let glwe_enc_infos = EncryptionLayout::new_from_default_sigma(self.params.glwe_layout)
.expect("default GLWE encryption sigma");

let mut fhe_uint: FheUint<Vec<u8>, T> = FheUint::alloc_from_infos(&self.params.glwe_layout);
let enc_bytes = fhe_uint.encrypt_sk_tmp_bytes(&self.module);
let mut scratch_e = scratch::new_arena(enc_bytes);
fhe_uint.encrypt_sk(
&self.module,
value,
&sk.sk_glwe_prepared,
&ggsw_enc_infos,
&glwe_enc_infos,
&mut source_xe,
&mut source_xa,
scratch::borrow(&mut scratch_arena),
scratch::borrow(&mut scratch_e),
);

let mut packed: FheUint<Vec<u8>, T> = FheUint::alloc_from_infos(&self.params.glwe_layout);
packed.from_fhe_uint_prepared(
&self.module,
&prepared,
&ek.bdd_key_prepared,
scratch::borrow(&mut scratch_arena),
);

Ciphertext {
inner: packed,
prepared: Some(prepared),
}
Ciphertext { inner: fhe_uint }
}

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

// ── Internal helper ───────────────────────────────────────────────────────

/// Run a binary op on the prepared form of `a` and `b`.
/// Prepare `a` and `b`, run `op`, and return the result.
///
/// Both inputs must carry their prepared cache (i.e. come straight from
/// [`Context::encrypt`]). Op outputs and deserialized ciphertexts have
/// no cache and panic with a clear message — see the [`crate::ciphertext`]
/// module docs for the upstream limitation.
/// Builds a fresh `FheUintPrepared` for each input on every call, then invokes `op` with both prepared operands and a
/// scratch region sized to whichever of prepare / op is larger.
fn eval_binary<T, F>(
&mut self,
a: &Ciphertext<T>,
Expand All @@ -743,27 +706,44 @@ impl Context {
&mut poulpy_hal::layouts::Scratch<crate::backend::BE>,
),
{
const NO_PREPARED_CACHE: &str =
"ciphertext lacks prepared cache; only freshly encrypted ciphertexts can be operated \
on in this Poulpy revision (see ciphertext module docs)";
let a_prep = a.prepared.as_ref().expect(NO_PREPARED_CACHE);
let b_prep = b.prepared.as_ref().expect(NO_PREPARED_CACHE);
let mut a_prep: FheUintPrepared<DeviceBuf<crate::backend::BE>, T, crate::backend::BE> =
FheUintPrepared::alloc_from_infos(&self.module, &self.params.ggsw_layout);
let mut b_prep: FheUintPrepared<DeviceBuf<crate::backend::BE>, T, crate::backend::BE> =
FheUintPrepared::alloc_from_infos(&self.module, &self.params.ggsw_layout);

let prepare_bytes = self.module.fhe_uint_prepare_tmp_bytes(
self.params.binary_block_size as usize,
1,
&self.params.ggsw_layout,
&self.params.glwe_layout,
&ek.bdd_key_prepared,
);
let mut scratch_arena = scratch::new_arena(prepare_bytes.max(eval_scratch_bytes));

a_prep.prepare::<CGGI, _, _, _, _>(
&self.module,
&a.inner,
&ek.bdd_key_prepared,
scratch::borrow(&mut scratch_arena),
);
b_prep.prepare::<CGGI, _, _, _, _>(
&self.module,
&b.inner,
&ek.bdd_key_prepared,
scratch::borrow(&mut scratch_arena),
);

let mut out: FheUint<Vec<u8>, T> = FheUint::alloc_from_infos(&self.params.glwe_layout);
let mut scratch_eval = scratch::new_arena(eval_scratch_bytes);
op(
&self.module,
self.options.eval_threads,
&mut out,
a_prep,
b_prep,
&a_prep,
&b_prep,
&ek.bdd_key_prepared,
scratch::borrow(&mut scratch_eval),
scratch::borrow(&mut scratch_arena),
);
Ciphertext {
inner: out,
prepared: None,
}
Ciphertext { inner: out }
}

// ── Arithmetic and logical operations ────────────────────────────────────
Expand Down
Loading
Loading