diff --git a/.cursor/rules/modules.mdc b/.cursor/rules/modules.mdc index e551fe7..a2a6736 100644 --- a/.cursor/rules/modules.mdc +++ b/.cursor/rules/modules.mdc @@ -8,9 +8,9 @@ alwaysApply: false | Module | File | Responsibility | |---|---|---| -| `context` | `src/context.rs` | Single user entry point. Owns `Module`, scratch arena, `Params`. All public API flows through `Context`. | +| `context` | `src/context.rs` | Single user entry point. Owns `Module` and `Params`. Allocates `scratch::Arena` per operation using Poulpy `*_tmp_bytes`. All public API flows through `Context`. | | `backend` | `src/backend.rs` | Compile-time `BE` alias (`FFT64Ref` or `FFT64Avx` via `backend-avx`). `pub(crate)` only; not re-exported from `lib.rs`. | -| `scratch` | `src/scratch.rs` | Scratch arena (`Arena`): `new_arena` and `borrow` over `ScratchOwned`. Required byte count is computed in `Context` (Poulpy `*_tmp_bytes`, `compute_arena_bytes`), not in this module. `pub(crate)`; not part of the supported public API. | +| `scratch` | `src/scratch.rs` | Scratch arena (`Arena`): `new_arena` and `borrow` over `ScratchOwned`. `Context` sizes each allocation from Poulpy `*_tmp_bytes` at call sites. `pub(crate)`; not part of the supported public API. | | `keys` | `src/keys.rs` | `SecretKey` and `EvaluationKey` types. Holds standard-form and prepared-form key material. Hides all lifecycle transitions. | | `ciphertext` | `src/ciphertext.rs` | `Ciphertext` newtype over `FheUint, T>`. The only ciphertext form users ever see. | @@ -20,7 +20,7 @@ alwaysApply: false ## Ownership model -- `Context` owns the `Module` and `ScratchOwned` (via `scratch::Arena`). +- `Context` owns the `Module`; it does not hold a long-lived scratch buffer (each method allocates `scratch::Arena` as needed). - `SecretKey` owns `GLWESecret>` and `LWESecret>` (standard) plus `GLWESecretPrepared, BE>` (encrypt/decrypt). - `EvaluationKey` owns both `BDDKey, CGGI>` (standard) and `BDDKeyPrepared, CGGI, BE>` (prepared). - `Ciphertext` owns `FheUint, T>` (standard form only). diff --git a/.cursor/rules/poulpy-concepts.mdc b/.cursor/rules/poulpy-concepts.mdc index 7a937b3..f1def41 100644 --- a/.cursor/rules/poulpy-concepts.mdc +++ b/.cursor/rules/poulpy-concepts.mdc @@ -10,7 +10,7 @@ Read this before touching any non-trivial code. ## 1. Scratch arenas -Poulpy operations never heap-allocate on the hot path. Every operation accepts `&mut Scratch` and a companion `*_tmp_bytes` method tells the caller how many bytes are needed. `ScratchOwned` is the owned allocation; `.borrow()` yields `&mut Scratch`. squid sizes and owns the arena inside `Context`. +Poulpy operations never heap-allocate on the hot path. Every operation accepts `&mut Scratch` and a companion `*_tmp_bytes` method tells the caller how many bytes are needed. `ScratchOwned` is the owned allocation; `.borrow()` yields `&mut Scratch`. squid allocates a `ScratchOwned` per call site from the matching `*_tmp_bytes` (same idea as Poulpy’s examples/tests), without exposing scratch in the public API. ## 2. Three-layer lifecycle diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc index ff2ca52..9024f19 100644 --- a/.cursor/rules/project.mdc +++ b/.cursor/rules/project.mdc @@ -25,7 +25,7 @@ Everything flows through `Context`. The user-facing types are: | Type | Role | |---|---| -| `Context` | Owns `Module`, scratch arena, and `Params`. Entry point for all operations. | +| `Context` | Owns `Module` and `Params`. Allocates Poulpy scratch per operation from `*_tmp_bytes` (not exposed). Entry point for all operations. | | `Params` | Layout parameter bundle. Use `Params::unsecure()` (demo / non-production) or construct custom. | | `SecretKey` | Private key material: standard GLWE + LWE secrets and prepared GLWE for encrypt/decrypt. Returned by `Context::keygen`. | | `EvaluationKey` | Public evaluation key bundle (`BDDKey` standard + prepared). Returned by `Context::keygen`. | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c7f737..222d55a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: - name: cargo clippy run: cargo clippy --all-targets -- -D warnings - name: cargo clippy (all features) + env: + RUSTFLAGS: -C target-cpu=native run: cargo clippy --all-targets --all-features -- -D warnings - name: cargo test run: cargo test --verbose diff --git a/Cargo.lock b/Cargo.lock index 7ad8acd..a78cba7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -518,8 +518,9 @@ dependencies = [ [[package]] name = "poulpy-core" version = "0.5.0" -source = "git+https://github.com/phantomzone-org/poulpy?branch=main#08f73f43a59af4def1dd4183a07b171ab18f191b" +source = "git+https://github.com/poulpy-fhe/poulpy.git?rev=b598566cef299a20ac9b159eef61aeadbf66f968#b598566cef299a20ac9b159eef61aeadbf66f968" dependencies = [ + "anyhow", "bytemuck", "byteorder", "criterion", @@ -527,7 +528,6 @@ dependencies = [ "itertools 0.14.0", "once_cell", "paste", - "poulpy-cpu-ref", "poulpy-hal", "rand", ] @@ -535,13 +535,14 @@ dependencies = [ [[package]] name = "poulpy-cpu-avx" version = "0.5.0" -source = "git+https://github.com/phantomzone-org/poulpy?branch=main#08f73f43a59af4def1dd4183a07b171ab18f191b" +source = "git+https://github.com/poulpy-fhe/poulpy.git?rev=b598566cef299a20ac9b159eef61aeadbf66f968#b598566cef299a20ac9b159eef61aeadbf66f968" dependencies = [ "bytemuck", "byteorder", "itertools 0.14.0", "once_cell", "paste", + "poulpy-core", "poulpy-cpu-ref", "poulpy-hal", "rand", @@ -553,12 +554,15 @@ dependencies = [ [[package]] name = "poulpy-cpu-ref" version = "0.5.0" -source = "git+https://github.com/phantomzone-org/poulpy?branch=main#08f73f43a59af4def1dd4183a07b171ab18f191b" +source = "git+https://github.com/poulpy-fhe/poulpy.git?rev=b598566cef299a20ac9b159eef61aeadbf66f968#b598566cef299a20ac9b159eef61aeadbf66f968" dependencies = [ "bytemuck", "byteorder", + "criterion", + "dashu-float", "itertools 0.14.0", "once_cell", + "poulpy-core", "poulpy-hal", "rand", "rand_chacha", @@ -569,8 +573,9 @@ dependencies = [ [[package]] name = "poulpy-hal" version = "0.5.0" -source = "git+https://github.com/phantomzone-org/poulpy?branch=main#08f73f43a59af4def1dd4183a07b171ab18f191b" +source = "git+https://github.com/poulpy-fhe/poulpy.git?rev=b598566cef299a20ac9b159eef61aeadbf66f968#b598566cef299a20ac9b159eef61aeadbf66f968" dependencies = [ + "anyhow", "bytemuck", "byteorder", "cmake", @@ -588,8 +593,9 @@ dependencies = [ [[package]] name = "poulpy-schemes" version = "0.5.0" -source = "git+https://github.com/phantomzone-org/poulpy?branch=main#08f73f43a59af4def1dd4183a07b171ab18f191b" +source = "git+https://github.com/poulpy-fhe/poulpy.git?rev=b598566cef299a20ac9b159eef61aeadbf66f968#b598566cef299a20ac9b159eef61aeadbf66f968" dependencies = [ + "anyhow", "byteorder", "criterion", "itertools 0.14.0", @@ -651,9 +657,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -672,9 +678,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" diff --git a/Cargo.toml b/Cargo.toml index 2245e24..67f3164 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,12 @@ license = "MIT" backend-avx = ["dep:poulpy-cpu-avx"] [dependencies] -poulpy-core = { git = "https://github.com/phantomzone-org/poulpy", branch = "main" } -poulpy-schemes = { git = "https://github.com/phantomzone-org/poulpy", branch = "main" } -poulpy-hal = { git = "https://github.com/phantomzone-org/poulpy", branch = "main" } -poulpy-cpu-ref = { git = "https://github.com/phantomzone-org/poulpy", branch = "main" } -poulpy-cpu-avx = { git = "https://github.com/phantomzone-org/poulpy", branch = "main", optional = true } +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 } 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"] } diff --git a/README.md b/README.md index 5162b8c..2bc120b 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ fn main() { let (sk, ek) = ctx.keygen(); // Encrypt two 32-bit integers - let a = ctx.encrypt::(255, &sk); - let b = ctx.encrypt::(30, &sk); + let a = ctx.encrypt::(255, &sk, &ek); + let b = ctx.encrypt::(30, &sk, &ek); // Homomorphic addition: computes (a + b) under encryption let c = ctx.add(&a, &b, &ek); @@ -53,12 +53,10 @@ All operations currently require `T = u32` (the only width with compiled BDD cir ## Backends -Squid defaults to a portable scalar CPU backend. On x86-64 machines with AVX2 and FMA, you can opt into a faster backend: - -| Feature flag | Backend | Requirements | Typical speedup | -|---------------|------------|---------------------------|-----------------| -| *(default)* | `FFT64Ref` | Any CPU | baseline | -| `backend-avx` | `FFT64Avx` | x86-64 with AVX2 + FMA | ~3–5× | +| Feature | Backend | Notes | +|---------------|------------|--------------------------------| +| *(default)* | `FFT64Ref` | Portable | +| `backend-avx` | `FFT64Avx` | x86-64, AVX2+FMA (~3–5× vs ref) | ```sh RUSTFLAGS="-C target-cpu=native" cargo build --release --features backend-avx @@ -76,7 +74,7 @@ The public API is identical regardless of which backend is selected. - [x] Add at least one runnable example in examples/: [#7](https://github.com/cedoor/squid/issues/7) - [ ] Add tests for all existing ops: [#4](https://github.com/cedoor/squid/issues/4) - [ ] Add rustdoc comments to all public items: [#6](https://github.com/cedoor/squid/issues/6) -- [ ] Faster tests via fixtures or deterministic keygen: [#19](https://github.com/cedoor/squid/issues/19) +- [x] Faster tests via fixtures or deterministic keygen: [#19](https://github.com/cedoor/squid/issues/19) ### Milestone 2 — Full bin_fhe Coverage: [#2](https://github.com/cedoor/squid/milestone/2) @@ -85,7 +83,8 @@ The public API is identical regardless of which backend is selected. - [ ] Sub-word operations: [#10](https://github.com/cedoor/squid/issues/10) - [ ] Identity / noise refresh: [#11](https://github.com/cedoor/squid/issues/11) - [ ] NTT backend: [#12](https://github.com/cedoor/squid/issues/12) -- [ ] Key serialization: [#13](https://github.com/cedoor/squid/issues/13) +- [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) ### Milestone 3 — Developer Experience & Optimization: [#3](https://github.com/cedoor/squid/milestone/3) @@ -95,12 +94,12 @@ The public API is identical regardless of which backend is selected. - [ ] Benchmarks: [#17](https://github.com/cedoor/squid/issues/17) - [ ] Vetted Params presets: [#18](https://github.com/cedoor/squid/issues/18) - [ ] Refactor `context.rs`: [#20](https://github.com/cedoor/squid/issues/20) -- [ ] Split keygen scratch from runtime arena to reduce persistent memory: [#22](https://github.com/cedoor/squid/issues/22) +- [x] [#22](https://github.com/cedoor/squid/issues/22) — closed: `Context` no longer keeps a persistent max-sized arena; scratch is allocated per operation from Poulpy’s `*_tmp_bytes` (supersedes the issue’s “split keygen vs runtime” split). ## Design goals - **Hide scratch management.** Callers never allocate or thread scratch buffers. - **Hide lifecycle transitions.** The Standard → Prepared → BDD-eval pipeline is handled internally; users see one coherent `Ciphertext` type. - **Explicitly non-production defaults.** `Params::unsecure()` matches Poulpy's `bdd_arithmetic` example for demos; treat it as unaudited unless you analyse parameters yourself. -- **No magic.** Every abstraction is traceable to the underlying Poulpy call. No hidden global state, no surprising allocations beyond the initial `Context::new`. +- **No magic.** Every abstraction is traceable to the underlying Poulpy call. No hidden global state; scratch is sized with Poulpy’s `*_tmp_bytes` at each operation. - **Safe defaults.** Every user-facing choice has a default that works without configuration. Alternatives are documented with their trade-offs and the conditions under which they should be preferred. diff --git a/examples/add_u32.rs b/examples/add_u32.rs index 6a987b6..3f0607c 100644 --- a/examples/add_u32.rs +++ b/examples/add_u32.rs @@ -20,8 +20,8 @@ fn main() { let b: u32 = 30; println!("Encrypting {a} and {b}..."); - let ct_a = ctx.encrypt::(a, &sk); - let ct_b = ctx.encrypt::(b, &sk); + let ct_a = ctx.encrypt::(a, &sk, &ek); + let ct_b = ctx.encrypt::(b, &sk, &ek); println!("Computing homomorphic addition..."); let ct_c = ctx.add(&ct_a, &ct_b, &ek); diff --git a/examples/serialize_keys.rs b/examples/serialize_keys.rs new file mode 100644 index 0000000..7cdd128 --- /dev/null +++ b/examples/serialize_keys.rs @@ -0,0 +1,93 @@ +//! Write the standard-form [`squid::EvaluationKey`] blob from one OS-random keygen. +//! +//! Secret key material is not written: Poulpy no longer exposes binary I/O for +//! LWE/GLWE secrets ([poulpy#147](https://github.com/poulpy-fhe/poulpy/pull/147)); +//! persist seeds or handle secrets at the app level if you need portability. +//! +//! Output: `params_test_evaluation_key.bin` under `--output-dir`. +//! +//! ```sh +//! cargo run --example serialize_keys -- --output-dir ./out +//! ``` + +use std::path::PathBuf; + +use squid::{Context, Params}; + +const EVALUATION_KEY_FILE: &str = "params_test_evaluation_key.bin"; + +struct Args { + output_dir: PathBuf, +} + +fn print_usage() { + eprintln!( + "\ +Usage: serialize_keys --output-dir + +Write the standard-form evaluation key blob from one OS-random keygen (Params::test()). + +File written (fixed name): + {EVALUATION_KEY_FILE} + +Options: + -o, --output-dir Directory to write the file into + -h, --help Show this help +" + ); +} + +fn parse_args() -> Result, String> { + let mut args = std::env::args().skip(1); + let mut output_dir: Option = None; + + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => return Ok(None), + "-o" | "--output-dir" => { + let path = args + .next() + .ok_or_else(|| "--output-dir requires a directory".to_string())?; + output_dir = Some(PathBuf::from(path)); + } + other => return Err(format!("unknown argument: {other}")), + } + } + + Ok(Some(Args { + output_dir: output_dir + .ok_or_else(|| "missing --output-dir (or -o )".to_string())?, + })) +} + +fn main() -> std::io::Result<()> { + let args = match parse_args() { + Ok(None) => { + print_usage(); + return Ok(()); + } + Ok(Some(a)) => a, + Err(e) => { + eprintln!("Error: {e}"); + print_usage(); + std::process::exit(1); + } + }; + + std::fs::create_dir_all(&args.output_dir)?; + + let evaluation_key = args.output_dir.join(EVALUATION_KEY_FILE); + + let params = Params::test(); + let mut ctx = Context::new(params); + let (_sk, ek) = ctx.keygen(); + + let ek_blob = ctx + .serialize_evaluation_key(&ek) + .expect("serialize evaluation key"); + + std::fs::write(&evaluation_key, ek_blob)?; + + eprintln!("Wrote {}.", evaluation_key.display()); + Ok(()) +} diff --git a/src/backend.rs b/src/backend.rs index 07f99ac..8f32481 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -10,23 +10,25 @@ //! | *(default)* | `FFT64Ref` | Any CPU | baseline | //! | `backend-avx` | `FFT64Avx` | x86-64 with AVX2 + FMA | ~3–5× | //! -//! Enable the AVX backend by passing `--features backend-avx` to Cargo, or adding -//! it to the `[features]` section of your `Cargo.toml`. You must also compile -//! with the required target features: +//! With `backend-avx` on a non-x86_64 host, the optional AVX crate may still be +//! linked for feature resolution, but [`BE`] stays `FFT64Ref` (e.g. `cargo clippy --all-features`). +//! +//! Poulpy’s AVX code must be compiled with AVX2+FMA available to rustc (`cfg(target_feature)`). +//! Example (matches README / typical CI): //! //! ```text //! RUSTFLAGS="-C target-cpu=native" cargo build --release --features backend-avx //! ``` +//! +//! Alternatively: `RUSTFLAGS="-C target-feature=+avx2,+fma"`. Runtime CPU checks are still +//! inside Poulpy. -#[cfg(all(feature = "backend-avx", not(target_arch = "x86_64")))] -compile_error!("feature `backend-avx` requires target_arch = \"x86_64\"."); - -// ── Default: portable scalar f64 FFT backend ──────────────────────────────── +// ── AVX2/FMA-accelerated backend (x86_64 only) ───────────────────────────── -#[cfg(not(feature = "backend-avx"))] -pub(crate) use poulpy_cpu_ref::FFT64Ref as BE; +#[cfg(all(feature = "backend-avx", target_arch = "x86_64"))] +pub(crate) use poulpy_cpu_avx::FFT64Avx as BE; -// ── Optional: AVX2/FMA-accelerated backend ─────────────────────────────────── +// ── Portable scalar f64 FFT backend (default and non-x86 with `backend-avx`) ─ -#[cfg(feature = "backend-avx")] -pub(crate) use poulpy_cpu_avx::FFT64Avx as BE; +#[cfg(not(all(feature = "backend-avx", target_arch = "x86_64")))] +pub(crate) use poulpy_cpu_ref::FFT64Ref as BE; diff --git a/src/ciphertext.rs b/src/ciphertext.rs index aed2043..03300f9 100644 --- a/src/ciphertext.rs +++ b/src/ciphertext.rs @@ -1,11 +1,38 @@ //! The user-facing ciphertext type. //! -//! [`Ciphertext`] is a thin newtype over Poulpy's `FheUint, T>`. -//! It is the only ciphertext form users ever see. The prepared (DFT-domain) -//! form `FheUintPrepared` is an implementation detail that lives temporarily -//! inside [`crate::context::Context`] during operation evaluation. +//! [`Ciphertext`] wraps Poulpy's packed `FheUint, 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. +//! +//! Standard-form wire encoding is [`Ciphertext::serialize`] / +//! [`Ciphertext::deserialize`] / [`crate::context::Context::serialize_ciphertext`] / +//! [`crate::context::Context::deserialize_ciphertext`] (versioned little-endian blob; +//! must be loaded with the same [`crate::context::Params`] as encryption). The blob embeds the +//! GLWE layout so mismatched parameters are rejected before ciphertext bytes are read. + +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_schemes::bin_fhe::bdd_arithmetic::{FheUint, UnsignedInteger}; +use crate::context::Context; + +/// Leading byte of [`Ciphertext::serialize`] / [`crate::context::Context::serialize_ciphertext`] blobs. +/// After the plaintext bit width, the blob includes `n`, `base2k`, `k`, and `rank` (each `u32` +/// little-endian), then the packed GLWE payload. +pub(crate) const CIPHERTEXT_BLOB_VERSION: u8 = 1; /// An encrypted unsigned integer of type `T`. /// @@ -15,13 +42,46 @@ use poulpy_schemes::bin_fhe::bdd_arithmetic::{FheUint, UnsignedInteger}; /// /// ## Lifecycle /// -/// 1. Create with [`crate::Context::encrypt`]. +/// 1. Create with [`crate::Context::encrypt`] (caches the prepared form for ops). /// 2. Pass to homomorphic operations (`ctx.add`, `ctx.xor`, …). /// 3. Recover the plaintext with [`crate::Context::decrypt`]. -/// -/// The ciphertext is in standard (serializable) form at all times from the -/// caller's perspective. Conversion to the prepared DFT-domain form happens -/// internally inside each operation call and is not visible here. pub struct Ciphertext { pub(crate) inner: FheUint, T>, + pub(crate) prepared: + Option, T, crate::backend::BE>>, +} + +impl Ciphertext { + /// 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> { + let mut out = Vec::new(); + out.push(CIPHERTEXT_BLOB_VERSION); + out.extend_from_slice(&T::BITS.to_le_bytes()); + let gl = self.inner.glwe_layout(); + out.extend_from_slice(&gl.n.as_u32().to_le_bytes()); + out.extend_from_slice(&gl.base2k.as_u32().to_le_bytes()); + out.extend_from_slice(&gl.k.as_u32().to_le_bytes()); + out.extend_from_slice(&gl.rank.as_u32().to_le_bytes()); + self.inner.to_ref().write_to(&mut out)?; + Ok(out) + } + + /// Restores a [`Ciphertext`] from [`Ciphertext::serialize`] output for the same [`Context`] + /// [`Params`](crate::context::Params). + /// + /// Same as [`crate::context::Context::deserialize_ciphertext`]. + /// + /// # Errors + /// + /// Returns [`std::io::Error`] with kind [`InvalidData`](io::ErrorKind::InvalidData) if the + /// blob does not match `T` or this context's [`GLWE`](poulpy_core::layouts::GLWE) layout. + pub fn deserialize(ctx: &mut Context, bytes: &[u8]) -> io::Result { + ctx.deserialize_ciphertext(bytes) + } } diff --git a/src/context.rs b/src/context.rs index d2642d5..e0ed6ec 100644 --- a/src/context.rs +++ b/src/context.rs @@ -11,30 +11,40 @@ //! let mut ctx = Context::new(Params::unsecure()).with_options(ContextOptions::default()); //! let (sk, ek) = ctx.keygen(); //! -//! let a = ctx.encrypt::(42, &sk); -//! let b = ctx.encrypt::(7, &sk); +//! let a = ctx.encrypt::(42, &sk, &ek); +//! let b = ctx.encrypt::(7, &sk, &ek); //! let c = ctx.add(&a, &b, &ek); //! let result: u32 = ctx.decrypt(&c, &sk); //! ``` -use poulpy_core::layouts::{ - Base2K, Degree, Dnum, Dsize, GGLWEToGGSWKeyLayout, GGSWLayout, GLWEAutomorphismKeyLayout, - GLWELayout, GLWESecret, GLWESecretPrepared, GLWESwitchingKeyLayout, GLWEToLWEKeyLayout, - LWESecret, Rank, TorusPrecision, +use std::io::{self, Cursor}; + +use poulpy_core::{ + layouts::{ + prepared::GLWESecretPreparedFactory, Base2K, Degree, Dnum, Dsize, GGLWEToGGSWKeyLayout, + GGSWLayout, GLWEAutomorphismKeyLayout, GLWELayout, GLWESecret, GLWESwitchingKeyLayout, + GLWEToLWEKeyLayout, GLWEToMut, LWESecret, Rank, TorusPrecision, + }, + EncryptionLayout, +}; +use poulpy_hal::{ + api::ModuleNew, + layouts::{DeviceBuf, Module, ReaderFrom}, + source::Source, }; -use poulpy_hal::{api::ModuleNew, layouts::Module, source::Source}; use poulpy_schemes::bin_fhe::{ bdd_arithmetic::{ - Add, And, BDDKey, BDDKeyEncryptSk, BDDKeyLayout, BDDKeyPrepared, BDDKeyPreparedFactory, - FheUint, FheUintPrepare, FheUintPrepared, FromBits, Or, Sll, Slt, Sltu, Sra, Srl, Sub, - ToBits, UnsignedInteger, Xor, + Add, And, BDDEncryptionInfos, BDDKey, BDDKeyEncryptSk, BDDKeyLayout, BDDKeyPrepared, + BDDKeyPreparedFactory, FheUint, FheUintPrepared, FromBits, Or, Sll, Slt, Sltu, Sra, Srl, + Sub, ToBits, UnsignedInteger, Xor, }, blind_rotation::{BlindRotationKeyLayout, CGGI}, circuit_bootstrapping::CircuitBootstrappingKeyLayout, }; use crate::{ - keys::{EvaluationKey, SecretKey}, + ciphertext::CIPHERTEXT_BLOB_VERSION, + keys::{EvaluationKey, KeygenSeeds, SecretKey, EVALUATION_KEY_BLOB_VERSION}, scratch, Ciphertext, }; @@ -82,7 +92,9 @@ impl Default for ContextOptions { /// /// Wraps all layout descriptors needed for key generation, encryption, and /// evaluation. [`Params::unsecure`] matches Poulpy's `bdd_arithmetic` example -/// (n = 1024) and is **not** presented as a vetted security level. +/// (n = 1024); [`Params::test`] matches Poulpy's `bdd_arithmetic` +/// `test_suite` layouts (smaller ring, faster tests). Neither is a vetted +/// security level. /// /// Advanced users may construct custom `Params` directly (often with struct /// update syntax, e.g. `Params { n_glwe: 2048, ..Params::unsecure() }`), but @@ -187,14 +199,108 @@ impl Params { bdd_layout, } } + + /// Same layout bundle as Poulpy's `bdd_arithmetic` **`test_suite`** module + /// (`poulpy-schemes/src/bin_fhe/bdd_arithmetic/tests/test_suite/mod.rs`): + /// ring degree 256, rank 2, smaller keys than [`Params::unsecure`]. + /// + /// Use in tests or local dev when you want parity with Poulpy's BDD tests and + /// faster runs than the `bdd_arithmetic` example parameters. **Not** a + /// production security target. If Poulpy changes its test layouts, update + /// this constructor to match. + pub fn test() -> Self { + // Keep in sync with poulpy-schemes `bdd_arithmetic::tests::test_suite` constants. + const N_GLWE: u32 = 256; + const N_LWE: u32 = 77; + const FHEUINT_BASE2K: u32 = 13; + const BRK_BASE2K: u32 = 12; + const ATK_BASE2K: u32 = 11; + const TSK_BASE2K: u32 = 10; + const LWE_KS_BASE2K: u32 = 4; + const K_GLWE: u32 = 26; + const K_GGSW: u32 = 39; + const BINARY_BLOCK_SIZE: u32 = 7; + const RANK: u32 = 2; + + let glwe_layout = GLWELayout { + n: Degree(N_GLWE), + base2k: Base2K(FHEUINT_BASE2K), + k: TorusPrecision(K_GLWE), + rank: Rank(RANK), + }; + + let ggsw_layout = GGSWLayout { + n: Degree(N_GLWE), + base2k: Base2K(FHEUINT_BASE2K), + k: TorusPrecision(K_GGSW), + rank: Rank(RANK), + dnum: Dnum(2), + dsize: Dsize(1), + }; + + let bdd_layout = BDDKeyLayout { + cbt_layout: CircuitBootstrappingKeyLayout { + brk_layout: BlindRotationKeyLayout { + n_glwe: Degree(N_GLWE), + n_lwe: Degree(N_LWE), + base2k: Base2K(BRK_BASE2K), + k: TorusPrecision(52), + dnum: Dnum(4), + rank: Rank(RANK), + }, + atk_layout: GLWEAutomorphismKeyLayout { + n: Degree(N_GLWE), + base2k: Base2K(ATK_BASE2K), + k: TorusPrecision(52), + rank: Rank(RANK), + dnum: Dnum(4), + dsize: Dsize(1), + }, + tsk_layout: GGLWEToGGSWKeyLayout { + n: Degree(N_GLWE), + base2k: Base2K(TSK_BASE2K), + k: TorusPrecision(52), + rank: Rank(RANK), + dnum: Dnum(4), + dsize: Dsize(1), + }, + }, + ks_glwe_layout: Some(GLWESwitchingKeyLayout { + n: Degree(N_GLWE), + base2k: Base2K(LWE_KS_BASE2K), + k: TorusPrecision(20), + rank_in: Rank(RANK), + rank_out: Rank(1), + dnum: Dnum(3), + dsize: Dsize(1), + }), + ks_lwe_layout: GLWEToLWEKeyLayout { + n: Degree(N_GLWE), + base2k: Base2K(LWE_KS_BASE2K), + k: TorusPrecision(16), + rank_in: Rank(1), + dnum: Dnum(3), + }, + }; + + Params { + n_glwe: N_GLWE, + binary_block_size: BINARY_BLOCK_SIZE, + glwe_layout, + ggsw_layout, + bdd_layout, + } + } } // ── Context ────────────────────────────────────────────────────────────────── /// The main entry point for all FHE operations. /// -/// `Context` owns the Poulpy [`Module`] (precomputed FFT tables), the scratch -/// arena, and the chosen [`Params`]. It does **not** own any key material; +/// `Context` owns the Poulpy [`Module`] (precomputed FFT tables) and the chosen +/// [`Params`]. Scratch space is allocated per operation using Poulpy’s +/// `*_tmp_bytes` helpers (same pattern as Poulpy’s examples and tests). It does +/// **not** own any key material; /// keys are returned from [`Context::keygen`] and passed back into each /// operation so callers control their lifecycle. /// @@ -205,7 +311,6 @@ impl Params { pub struct Context { params: Params, module: Mod, - arena: scratch::Arena, options: ContextOptions, } @@ -216,35 +321,27 @@ impl Context { /// [`Context::with_options`] to change that, e.g. /// `Context::new(params).with_options(ContextOptions { eval_threads: 4 })`. /// - /// Allocates the FFT tables and scratch arena. This is the most expensive - /// one-time setup cost; key generation and evaluation are the runtime costs. + /// Allocates the FFT tables. Scratch is allocated per-operation on the + /// encrypt/decrypt/keygen/eval paths; see Poulpy’s `*_tmp_bytes` sizing. pub fn new(params: Params) -> Self { let options = ContextOptions::default(); assert_eval_threads(options.eval_threads); let module = Mod::new(params.n_glwe as u64); - let bytes = compute_arena_bytes(&module, ¶ms, options.eval_threads); - let arena = scratch::new_arena(bytes); Context { params, module, - arena, options, } } /// Applies runtime options, replacing any previous [`ContextOptions`]. /// - /// Reallocates the scratch arena for this [`Params`] and the new - /// [`ContextOptions::eval_threads`] (worst-case size from Poulpy’s scratch helpers). - /// /// # Panics /// /// If `options.eval_threads` is zero. pub fn with_options(mut self, options: ContextOptions) -> Self { assert_eval_threads(options.eval_threads); self.options = options; - let bytes = compute_arena_bytes(&self.module, &self.params, self.options.eval_threads); - self.arena = scratch::new_arena(bytes); self } @@ -266,12 +363,9 @@ impl Context { pub fn set_options(&mut self, options: ContextOptions) { assert_eval_threads(options.eval_threads); self.options = options; - let bytes = compute_arena_bytes(&self.module, &self.params, self.options.eval_threads); - self.arena = scratch::new_arena(bytes); } - /// Sets only [`ContextOptions::eval_threads`]. Reallocates the scratch arena - /// like [`Context::with_options`]. + /// Sets only [`ContextOptions::eval_threads`] (same rules as [`Context::with_options`]). /// /// # Panics /// @@ -295,81 +389,316 @@ impl Context { /// Generate a fresh secret key and the corresponding evaluation key. /// - /// Uses OS randomness to seed the three CSPRNG streams required by Poulpy - /// (secret key, public mask, and error noise). + /// Uses OS randomness to seed the three ChaCha8 streams required by Poulpy + /// (lattice secrets, BDD public masks, BDD noise). Does not return the seeds; + /// use [`Context::keygen_with_seeds`] if you need [`KeygenSeeds`] for persistence. /// /// # Panics /// /// Panics if the OS cannot supply enough random bytes (extremely unlikely). pub fn keygen(&mut self) -> (SecretKey, EvaluationKey) { - let mut source_xs = random_source(); - let mut source_xa = random_source(); - let mut source_xe = random_source(); + let (sk, ek, _) = self.keygen_with_seeds(); + (sk, ek) + } - // GLWE secret key - let mut sk_glwe = GLWESecret::alloc_from_infos(&self.params.glwe_layout); - sk_glwe.fill_ternary_prob(0.5, &mut source_xs); + /// Like [`Context::keygen`], but also returns the [`KeygenSeeds`] for replay via + /// [`Context::keygen_from_seeds`]. + /// + /// # Panics + /// + /// Panics if the OS cannot supply enough random bytes (extremely unlikely). + pub fn keygen_with_seeds(&mut self) -> (SecretKey, EvaluationKey, KeygenSeeds) { + let mut lattice = [0u8; 32]; + let mut bdd_mask = [0u8; 32]; + let mut bdd_noise = [0u8; 32]; - // LWE secret key (block-binary) - let mut sk_lwe = LWESecret::alloc(self.params.bdd_layout.cbt_layout.brk_layout.n_lwe); - sk_lwe.fill_binary_block(self.params.binary_block_size as usize, &mut source_xs); + getrandom::fill(&mut lattice).expect("OS random number generator unavailable"); + getrandom::fill(&mut bdd_mask).expect("OS random number generator unavailable"); + getrandom::fill(&mut bdd_noise).expect("OS random number generator unavailable"); - // Prepared GLWE secret (DFT domain) — needed for encryption/decryption - let mut sk_glwe_prepared = - GLWESecretPrepared::alloc_from_infos(&self.module, &self.params.glwe_layout); - sk_glwe_prepared.prepare(&self.module, &sk_glwe); + let seeds = KeygenSeeds { + lattice, + bdd_mask, + bdd_noise, + }; + let (sk, ek) = self.keygen_from_seeds(seeds); + + (sk, ek, seeds) + } + + /// Deterministic key generation from stored [`KeygenSeeds`] for the same [`Params`] and backend. + pub fn keygen_from_seeds(&mut self, seeds: KeygenSeeds) -> (SecretKey, EvaluationKey) { + let mut source_xs = Source::new(seeds.lattice); + let sk = self.secret_key_material_from_lattice_source(&mut source_xs); + let mut source_xa = Source::new(seeds.bdd_mask); + let mut source_xe = Source::new(seeds.bdd_noise); // BDD evaluation key (standard form) + let bdd_enc_infos = BDDEncryptionInfos::from_default_sigma(&self.params.bdd_layout) + .expect("default BDD encryption sigma"); let mut bdd_key: BDDKey, CGGI> = BDDKey::alloc_from_infos(&self.params.bdd_layout); + let keygen_bytes = self + .module + .bdd_key_encrypt_sk_tmp_bytes(&self.params.bdd_layout) + .max( + self.module + .prepare_bdd_key_tmp_bytes(&self.params.bdd_layout), + ); + let mut keygen_arena = scratch::new_arena(keygen_bytes); + let scratch = scratch::borrow(&mut keygen_arena); bdd_key.encrypt_sk( &self.module, - &sk_lwe, - &sk_glwe, - &mut source_xa, + &sk.sk_lwe, + &sk.sk_glwe, + &bdd_enc_infos, &mut source_xe, - scratch::borrow(&mut self.arena), + &mut source_xa, + scratch, ); // BDD evaluation key (prepared / DFT form) - let mut bdd_key_prepared: BDDKeyPrepared, CGGI, crate::backend::BE> = - BDDKeyPrepared::alloc_from_infos(&self.module, &self.params.bdd_layout); - bdd_key_prepared.prepare(&self.module, &bdd_key, scratch::borrow(&mut self.arena)); + let mut bdd_key_prepared: BDDKeyPrepared< + DeviceBuf, + CGGI, + crate::backend::BE, + > = BDDKeyPrepared::alloc_from_infos(&self.module, &self.params.bdd_layout); + bdd_key_prepared.prepare(&self.module, &bdd_key, scratch); + + let ek = EvaluationKey { + bdd_key, + bdd_key_prepared, + }; + (sk, ek) + } + + /// Secret key material (encrypt/decrypt) from the **lattice** ChaCha seed only — the same + /// [`KeygenSeeds::lattice`] field used in [`Context::keygen_from_seeds`]. + /// + /// Does not use [`KeygenSeeds::bdd_mask`] or [`KeygenSeeds::bdd_noise`]; you must obtain an + /// [`EvaluationKey`] separately (e.g. full [`Context::keygen_from_seeds`] or + /// [`Context::deserialize_evaluation_key`]). + pub fn secret_key_from_lattice_seed(&mut self, lattice_seed: [u8; 32]) -> SecretKey { + let mut source_xs = Source::new(lattice_seed); + self.secret_key_material_from_lattice_source(&mut source_xs) + } - let sk = SecretKey { + fn secret_key_material_from_lattice_source(&mut self, source_xs: &mut Source) -> SecretKey { + let mut sk_glwe = GLWESecret::alloc_from_infos(&self.params.glwe_layout); + sk_glwe.fill_ternary_prob(0.5, source_xs); + + let mut sk_lwe = LWESecret::alloc(self.params.bdd_layout.cbt_layout.brk_layout.n_lwe); + sk_lwe.fill_binary_block(self.params.binary_block_size as usize, source_xs); + + let mut sk_glwe_prepared = self + .module + .glwe_secret_prepared_alloc_from_infos(&self.params.glwe_layout); + self.module + .glwe_secret_prepare(&mut sk_glwe_prepared, &sk_glwe); + + SecretKey { sk_glwe, sk_glwe_prepared, sk_lwe, + } + } + + /// Serializes the standard-form BDD evaluation key (little-endian, versioned). + /// The prepared key is not stored; use [`Context::deserialize_evaluation_key`]. + /// + /// Same as [`EvaluationKey::serialize`]. + pub fn serialize_evaluation_key(&self, ek: &EvaluationKey) -> io::Result> { + ek.serialize() + } + + /// Restores an [`EvaluationKey`] from [`EvaluationKey::serialize`] / [`Context::serialize_evaluation_key`] for the same [`Params`]. + /// + /// # Errors + /// + /// Returns [`std::io::Error`] with kind [`InvalidData`](io::ErrorKind::InvalidData) if the + /// blob does not match this context's [`Params`] layouts. + pub fn deserialize_evaluation_key(&mut self, bytes: &[u8]) -> io::Result { + let Some((&ver, rest)) = bytes.split_first() else { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "empty evaluation key blob", + )); }; - let ek = EvaluationKey { + if ver != EVALUATION_KEY_BLOB_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported evaluation key blob version {ver}"), + )); + } + let mut r = Cursor::new(rest); + let mut bdd_key = BDDKey::alloc_from_infos(&self.params.bdd_layout); + bdd_key.read_from(&mut r)?; + if (r.position() as usize) != rest.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trailing bytes in evaluation key blob", + )); + } + let mut bdd_key_prepared: BDDKeyPrepared< + DeviceBuf, + CGGI, + crate::backend::BE, + > = BDDKeyPrepared::alloc_from_infos(&self.module, &self.params.bdd_layout); + let mut scratch_p = scratch::new_arena( + self.module + .prepare_bdd_key_tmp_bytes(&self.params.bdd_layout), + ); + bdd_key_prepared.prepare(&self.module, &bdd_key, scratch::borrow(&mut scratch_p)); + Ok(EvaluationKey { bdd_key, bdd_key_prepared, + }) + } + + /// Serializes a [`Ciphertext`] (little-endian, versioned). + /// + /// Same as [`Ciphertext::serialize`]. + pub fn serialize_ciphertext( + &self, + ct: &Ciphertext, + ) -> io::Result> { + ct.serialize() + } + + /// Restores a [`Ciphertext`] from [`Ciphertext::serialize`] / [`Context::serialize_ciphertext`] + /// for the same [`Params`]. + /// + /// # Errors + /// + /// Returns [`std::io::Error`] with kind [`InvalidData`](io::ErrorKind::InvalidData) if the + /// blob does not match `T` or this context's [`GLWELayout`]. + pub fn deserialize_ciphertext(&mut self, bytes: &[u8]) -> io::Result> + where + T: UnsignedInteger, + { + let Some((&ver, rest)) = bytes.split_first() else { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "empty ciphertext blob", + )); }; - (sk, ek) + if ver != CIPHERTEXT_BLOB_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported ciphertext blob version {ver}"), + )); + } + const GLWE_LAYOUT_HEAD: usize = 4 * 4; + if rest.len() < 4 + GLWE_LAYOUT_HEAD { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "ciphertext blob truncated (header)", + )); + } + let bits = u32::from_le_bytes(rest[..4].try_into().unwrap()); + if bits != T::BITS { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "ciphertext blob declares {bits} bit plaintext width but {} was requested", + T::BITS + ), + )); + } + let blob_glwe = GLWELayout { + n: Degree(u32::from_le_bytes(rest[4..8].try_into().unwrap())), + base2k: Base2K(u32::from_le_bytes(rest[8..12].try_into().unwrap())), + k: TorusPrecision(u32::from_le_bytes(rest[12..16].try_into().unwrap())), + rank: Rank(u32::from_le_bytes(rest[16..20].try_into().unwrap())), + }; + if blob_glwe != self.params.glwe_layout { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ciphertext GLWE parameters in blob do not match context Params", + )); + } + let payload = &rest[4 + GLWE_LAYOUT_HEAD..]; + let mut r = Cursor::new(payload); + let mut fhe_uint = FheUint::alloc_from_infos(&self.params.glwe_layout); + fhe_uint.to_mut().read_from(&mut r)?; + if (r.position() as usize) != payload.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trailing bytes in ciphertext blob", + )); + } + Ok(Ciphertext { + inner: fhe_uint, + prepared: None, + }) } // ── 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. + /// /// `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(&mut self, value: T, sk: &SecretKey) -> Ciphertext + pub fn encrypt(&mut self, value: T, sk: &SecretKey, ek: &EvaluationKey) -> Ciphertext where - T: UnsignedInteger + ToBits, + T: UnsignedInteger + ToBits + FromBits, { let mut source_xa = random_source(); let mut source_xe = random_source(); - let mut fhe_uint = FheUint::alloc_from_infos(&self.params.glwe_layout); - fhe_uint.encrypt_sk( + 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, T, crate::backend::BE> = + FheUintPrepared::alloc_from_infos(&self.module, &self.params.ggsw_layout); + prepared.encrypt_sk( &self.module, value, &sk.sk_glwe_prepared, - &mut source_xa, + &ggsw_enc_infos, &mut source_xe, - scratch::borrow(&mut self.arena), + &mut source_xa, + scratch::borrow(&mut scratch_arena), ); - Ciphertext { inner: fhe_uint } + + let mut packed: FheUint, 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), + } } /// Decrypt a ciphertext and return the plaintext value. @@ -377,28 +706,29 @@ impl Context { where T: UnsignedInteger + FromBits, { + let dec_bytes = ct.inner.decrypt_tmp_bytes(&self.module); + let mut scratch_d = scratch::new_arena(dec_bytes); ct.inner.decrypt( &self.module, &sk.sk_glwe_prepared, - scratch::borrow(&mut self.arena), + scratch::borrow(&mut scratch_d), ) } // ── Internal helper ─────────────────────────────────────────────────────── - /// Prepare two ciphertexts, run `op`, and return the result. - /// - /// All arithmetic operations share this pattern: - /// 1. Allocate and populate `FheUintPrepared` for `a` and `b`. - /// 2. Allocate output `FheUint`. - /// 3. Invoke `op` on it. + /// Run a binary op on the prepared form of `a` and `b`. /// - /// Uses [`ContextOptions::eval_threads`] for Poulpy's `*_multi_thread` BDD evaluators. + /// 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. fn eval_binary( &mut self, a: &Ciphertext, b: &Ciphertext, ek: &EvaluationKey, + eval_scratch_bytes: usize, op: F, ) -> Ciphertext where @@ -407,42 +737,33 @@ impl Context { &Mod, usize, &mut FheUint, T>, - &FheUintPrepared, T, crate::backend::BE>, - &FheUintPrepared, T, crate::backend::BE>, - &BDDKeyPrepared, CGGI, crate::backend::BE>, + &FheUintPrepared, T, crate::backend::BE>, + &FheUintPrepared, T, crate::backend::BE>, + &BDDKeyPrepared, CGGI, crate::backend::BE>, &mut poulpy_hal::layouts::Scratch, ), { - let eval_threads = self.options.eval_threads; - let mut a_prep: FheUintPrepared, T, crate::backend::BE> = - FheUintPrepared::alloc_from_infos(&self.module, &self.params.ggsw_layout); - a_prep.prepare::( - &self.module, - &a.inner, - &ek.bdd_key_prepared, - scratch::borrow(&mut self.arena), - ); - - let mut b_prep: FheUintPrepared, T, crate::backend::BE> = - FheUintPrepared::alloc_from_infos(&self.module, &self.params.ggsw_layout); - b_prep.prepare::( - &self.module, - &b.inner, - &ek.bdd_key_prepared, - scratch::borrow(&mut self.arena), - ); + 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 out: FheUint, T> = FheUint::alloc_from_infos(&self.params.glwe_layout); + let mut scratch_eval = scratch::new_arena(eval_scratch_bytes); op( &self.module, - eval_threads, + self.options.eval_threads, &mut out, - &a_prep, - &b_prep, + a_prep, + b_prep, &ek.bdd_key_prepared, - scratch::borrow(&mut self.arena), + scratch::borrow(&mut scratch_eval), ); - Ciphertext { inner: out } + Ciphertext { + inner: out, + prepared: None, + } } // ── Arithmetic and logical operations ──────────────────────────────────── @@ -458,9 +779,36 @@ impl Context { T: UnsignedInteger, FheUint, T>: Add, { - self.eval_binary(a, b, ek, |module, threads, out, ap, bp, key, scratch| { - out.add_multi_thread(threads, module, ap, bp, key, scratch); - }) + let eval_threads = self.options.eval_threads; + let eval_bytes = if eval_threads == 1 { + a.inner.add_tmp_bytes( + &self.module, + &self.params.glwe_layout, + &self.params.ggsw_layout, + &ek.bdd_key_prepared, + ) + } else { + a.inner.add_multi_thread_tmp_bytes( + &self.module, + eval_threads, + &self.params.glwe_layout, + &self.params.ggsw_layout, + &ek.bdd_key_prepared, + ) + }; + self.eval_binary( + a, + b, + ek, + eval_bytes, + |module, _threads, out, ap, bp, key, scratch| { + if eval_threads == 1 { + out.add(module, ap, bp, key, scratch); + } else { + out.add_multi_thread(eval_threads, module, ap, bp, key, scratch); + } + }, + ) } /// Homomorphic wrapping subtraction: `a - b`. @@ -474,9 +822,22 @@ impl Context { T: UnsignedInteger, FheUint, T>: Sub, { - self.eval_binary(a, b, ek, |module, threads, out, ap, bp, key, scratch| { - out.sub_multi_thread(threads, module, ap, bp, key, scratch); - }) + let eval_bytes = a.inner.sub_multi_thread_tmp_bytes( + &self.module, + self.options.eval_threads, + &self.params.glwe_layout, + &self.params.ggsw_layout, + &ek.bdd_key_prepared, + ); + self.eval_binary( + a, + b, + ek, + eval_bytes, + |module, threads, out, ap, bp, key, scratch| { + out.sub_multi_thread(threads, module, ap, bp, key, scratch); + }, + ) } /// Homomorphic bitwise AND: `a & b`. @@ -490,9 +851,22 @@ impl Context { T: UnsignedInteger, FheUint, T>: And, { - self.eval_binary(a, b, ek, |module, threads, out, ap, bp, key, scratch| { - out.and_multi_thread(threads, module, ap, bp, key, scratch); - }) + let eval_bytes = a.inner.and_multi_thread_tmp_bytes( + &self.module, + self.options.eval_threads, + &self.params.glwe_layout, + &self.params.ggsw_layout, + &ek.bdd_key_prepared, + ); + self.eval_binary( + a, + b, + ek, + eval_bytes, + |module, threads, out, ap, bp, key, scratch| { + out.and_multi_thread(threads, module, ap, bp, key, scratch); + }, + ) } /// Homomorphic bitwise OR: `a | b`. @@ -506,9 +880,22 @@ impl Context { T: UnsignedInteger, FheUint, T>: Or, { - self.eval_binary(a, b, ek, |module, threads, out, ap, bp, key, scratch| { - out.or_multi_thread(threads, module, ap, bp, key, scratch); - }) + let eval_bytes = a.inner.or_multi_thread_tmp_bytes( + &self.module, + self.options.eval_threads, + &self.params.glwe_layout, + &self.params.ggsw_layout, + &ek.bdd_key_prepared, + ); + self.eval_binary( + a, + b, + ek, + eval_bytes, + |module, threads, out, ap, bp, key, scratch| { + out.or_multi_thread(threads, module, ap, bp, key, scratch); + }, + ) } /// Homomorphic bitwise XOR: `a ^ b`. @@ -522,9 +909,22 @@ impl Context { T: UnsignedInteger, FheUint, T>: Xor, { - self.eval_binary(a, b, ek, |module, threads, out, ap, bp, key, scratch| { - out.xor_multi_thread(threads, module, ap, bp, key, scratch); - }) + let eval_bytes = a.inner.xor_multi_thread_tmp_bytes( + &self.module, + self.options.eval_threads, + &self.params.glwe_layout, + &self.params.ggsw_layout, + &ek.bdd_key_prepared, + ); + self.eval_binary( + a, + b, + ek, + eval_bytes, + |module, threads, out, ap, bp, key, scratch| { + out.xor_multi_thread(threads, module, ap, bp, key, scratch); + }, + ) } /// Homomorphic logical left shift: `a << b`. @@ -538,9 +938,22 @@ impl Context { T: UnsignedInteger, FheUint, T>: Sll, { - self.eval_binary(a, b, ek, |module, threads, out, ap, bp, key, scratch| { - out.sll_multi_thread(threads, module, ap, bp, key, scratch); - }) + let eval_bytes = a.inner.sll_multi_thread_tmp_bytes( + &self.module, + self.options.eval_threads, + &self.params.glwe_layout, + &self.params.ggsw_layout, + &ek.bdd_key_prepared, + ); + self.eval_binary( + a, + b, + ek, + eval_bytes, + |module, threads, out, ap, bp, key, scratch| { + out.sll_multi_thread(threads, module, ap, bp, key, scratch); + }, + ) } /// Homomorphic logical right shift: `a >> b` (zero-extending). @@ -554,9 +967,22 @@ impl Context { T: UnsignedInteger, FheUint, T>: Srl, { - self.eval_binary(a, b, ek, |module, threads, out, ap, bp, key, scratch| { - out.srl_multi_thread(threads, module, ap, bp, key, scratch); - }) + let eval_bytes = a.inner.srl_multi_thread_tmp_bytes( + &self.module, + self.options.eval_threads, + &self.params.glwe_layout, + &self.params.ggsw_layout, + &ek.bdd_key_prepared, + ); + self.eval_binary( + a, + b, + ek, + eval_bytes, + |module, threads, out, ap, bp, key, scratch| { + out.srl_multi_thread(threads, module, ap, bp, key, scratch); + }, + ) } /// Homomorphic arithmetic right shift: `a >> b` (sign-extending). @@ -570,9 +996,22 @@ impl Context { T: UnsignedInteger, FheUint, T>: Sra, { - self.eval_binary(a, b, ek, |module, threads, out, ap, bp, key, scratch| { - out.sra_multi_thread(threads, module, ap, bp, key, scratch); - }) + let eval_bytes = a.inner.sra_multi_thread_tmp_bytes( + &self.module, + self.options.eval_threads, + &self.params.glwe_layout, + &self.params.ggsw_layout, + &ek.bdd_key_prepared, + ); + self.eval_binary( + a, + b, + ek, + eval_bytes, + |module, threads, out, ap, bp, key, scratch| { + out.sra_multi_thread(threads, module, ap, bp, key, scratch); + }, + ) } /// Homomorphic signed less-than: result is `1` if `(a as signed) < (b as signed)`, else `0`. @@ -586,9 +1025,22 @@ impl Context { T: UnsignedInteger, FheUint, T>: Slt, { - self.eval_binary(a, b, ek, |module, threads, out, ap, bp, key, scratch| { - out.slt_multi_thread(threads, module, ap, bp, key, scratch); - }) + let eval_bytes = a.inner.slt_multi_thread_tmp_bytes( + &self.module, + self.options.eval_threads, + &self.params.glwe_layout, + &self.params.ggsw_layout, + &ek.bdd_key_prepared, + ); + self.eval_binary( + a, + b, + ek, + eval_bytes, + |module, threads, out, ap, bp, key, scratch| { + out.slt_multi_thread(threads, module, ap, bp, key, scratch); + }, + ) } /// Homomorphic unsigned less-than: result is `1` if `a < b`, else `0`. @@ -602,73 +1054,27 @@ impl Context { T: UnsignedInteger, FheUint, T>: Sltu, { - self.eval_binary(a, b, ek, |module, threads, out, ap, bp, key, scratch| { - out.sltu_multi_thread(threads, module, ap, bp, key, scratch); - }) + let eval_bytes = a.inner.sltu_multi_thread_tmp_bytes( + &self.module, + self.options.eval_threads, + &self.params.glwe_layout, + &self.params.ggsw_layout, + &ek.bdd_key_prepared, + ); + self.eval_binary( + a, + b, + ek, + eval_bytes, + |module, threads, out, ap, bp, key, scratch| { + out.sltu_multi_thread(threads, module, ap, bp, key, scratch); + }, + ) } } // ── Internal utilities ──────────────────────────────────────────────────────── -/// Compute the exact scratch arena size required for all operations in the -/// pipeline by taking the `max` across every scratch-taking category. -/// The arena is reused sequentially, so the worst-case single operation -/// determines the required size. -fn compute_arena_bytes(module: &Mod, params: &Params, eval_threads: usize) -> usize { - let keygen_encrypt = module.bdd_key_encrypt_sk_tmp_bytes(¶ms.bdd_layout); - let keygen_prepare = module.prepare_bdd_key_tmp_bytes(¶ms.bdd_layout); - let fhe_prepare = module.fhe_uint_prepare_tmp_bytes( - params.binary_block_size as usize, - 1usize, - ¶ms.ggsw_layout, - ¶ms.glwe_layout, - ¶ms.bdd_layout, - ); - - // encrypt_sk_tmp_bytes / decrypt_tmp_bytes are &self instance methods on - // FheUint — a dummy is needed for dispatch. It's a one-time ~32 KB - // allocation freed immediately after sizing. - let dummy: FheUint, u32> = FheUint::alloc_from_infos(¶ms.glwe_layout); - let encrypt = dummy.encrypt_sk_tmp_bytes(module); - let decrypt = dummy.decrypt_tmp_bytes(module); - - // Each BDD circuit has a different max_state_size, so all 10 ops are - // queried and the max is taken. Poulpy's `*_tmp_bytes` helpers require a - // prepared evaluation key (`GLWEAutomorphismKeyHelper`), not raw layouts. - let bdd_key_prepared: BDDKeyPrepared, CGGI, crate::backend::BE> = - BDDKeyPrepared::alloc_from_infos(module, ¶ms.bdd_layout); - let g = ¶ms.ggsw_layout; - let r = ¶ms.glwe_layout; - let t = eval_threads; - let eval = [ - dummy.add_multi_thread_tmp_bytes(module, t, r, g, &bdd_key_prepared), - dummy.sub_multi_thread_tmp_bytes(module, t, r, g, &bdd_key_prepared), - dummy.and_multi_thread_tmp_bytes(module, t, r, g, &bdd_key_prepared), - dummy.or_multi_thread_tmp_bytes(module, t, r, g, &bdd_key_prepared), - dummy.xor_multi_thread_tmp_bytes(module, t, r, g, &bdd_key_prepared), - dummy.sll_multi_thread_tmp_bytes(module, t, r, g, &bdd_key_prepared), - dummy.srl_multi_thread_tmp_bytes(module, t, r, g, &bdd_key_prepared), - dummy.sra_multi_thread_tmp_bytes(module, t, r, g, &bdd_key_prepared), - dummy.slt_multi_thread_tmp_bytes(module, t, r, g, &bdd_key_prepared), - dummy.sltu_multi_thread_tmp_bytes(module, t, r, g, &bdd_key_prepared), - ] - .into_iter() - .max() - .unwrap_or(0); - - [ - keygen_encrypt, - keygen_prepare, - fhe_prepare, - encrypt, - decrypt, - eval, - ] - .into_iter() - .max() - .unwrap_or(0) -} - /// Construct a [`Source`] seeded from OS randomness. fn random_source() -> Source { let mut seed = [0u8; 32]; diff --git a/src/keys.rs b/src/keys.rs index 7dd3b75..7b7f5a7 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -1,51 +1,147 @@ //! Key generation and lifecycle management. //! -//! Exposes two opaque types: +//! Exposes two types that are **opaque to dependents**: their fields are +//! `pub(crate)` only, so other crates see the type plus public methods, not a +//! public field layout. //! //! - [`SecretKey`] — the private GLWE + LWE secret pair plus the prepared //! (DFT-domain) GLWE secret needed for encryption and decryption. -//! Keep this secret; never serialize it in production. +//! Keep this secret; this crate does not offer binary serialization for it. //! - [`EvaluationKey`] — the public evaluation key bundle needed for all //! homomorphic operations. Wraps Poulpy's `BDDKey` in standard and prepared //! forms. //! -//! Both types are created by [`crate::context::Context::keygen`] and are -//! opaque: no fields are public. +//! Persist [`KeygenSeeds`] using [`crate::context::Context::keygen_with_seeds`], then rebuild +//! with [`crate::context::Context::keygen_from_seeds`] instead of serializing raw polynomials. +//! [`crate::context::Context::keygen`] returns keys only (no seeds). Standard-form serialization +//! for the evaluation key only is exposed +//! via [`EvaluationKey::serialize`] / [`crate::context::Context::serialize_evaluation_key`] and +//! [`EvaluationKey::deserialize`] / [`crate::context::Context::deserialize_evaluation_key`]. +//! +//! For application-level use of the standard-form GLWE/LWE polynomials (not +//! supported as a stable wire format by Poulpy), see [`SecretKey::glwe_standard`] +//! and [`SecretKey::lwe_standard`]. For the standard-form BDD key material (same +//! blob as [`EvaluationKey::serialize`]), see [`EvaluationKey::bdd_standard`], +//! [`EvaluationKey::serialize`], and [`EvaluationKey::deserialize`]. + +use std::fmt; +use std::io; -use poulpy_core::layouts::{GLWESecret, GLWESecretPrepared, LWESecret}; +use poulpy_core::layouts::{prepared::GLWESecretPrepared, GLWESecret, LWESecret}; +use poulpy_hal::layouts::{DeviceBuf, WriterTo}; use poulpy_schemes::bin_fhe::{ bdd_arithmetic::{BDDKey, BDDKeyPrepared}, blind_rotation::CGGI, }; +use crate::context::Context; + +/// Leading byte of [`EvaluationKey::serialize`] / [`crate::context::Context::serialize_evaluation_key`] blobs. +pub(crate) const EVALUATION_KEY_BLOB_VERSION: u8 = 1; + +/// Three 32-byte ChaCha8 seeds used by [`crate::context::Context::keygen_from_seeds`]. +/// +/// These match the three independent [`poulpy_hal::source::Source`] streams in +/// Poulpy key generation: lattice secrets (GLWE + LWE), BDD public-mask +/// randomness, and BDD error randomness. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct KeygenSeeds { + pub lattice: [u8; 32], + pub bdd_mask: [u8; 32], + pub bdd_noise: [u8; 32], +} + +impl fmt::Debug for KeygenSeeds { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("KeygenSeeds") + .field("lattice", &"") + .field("bdd_mask", &"") + .field("bdd_noise", &"") + .finish() + } +} + /// The private key material. /// -/// Contains the GLWE and LWE secret keys (standard form for storage) plus the -/// prepared GLWE secret (DFT-domain, needed by encrypt/decrypt). +/// Holds the GLWE and LWE secrets in standard form (same values produced during +/// [`crate::Context::keygen`]) plus the prepared GLWE secret used on the +/// encrypt/decrypt path. /// -/// Created by [`crate::Context::keygen`]. +/// Created by [`crate::context::Context::keygen`], [`crate::context::Context::keygen_with_seeds`], +/// [`crate::context::Context::keygen_from_seeds`], or [`SecretKey::from_lattice_seed`] / +/// [`crate::context::Context::secret_key_from_lattice_seed`] (lattice seed only). pub struct SecretKey { - // TODO: read when adding load/serialize, key rotation, or re-prepare-from-standard APIs. - #[allow(dead_code)] pub(crate) sk_glwe: GLWESecret>, - pub(crate) sk_glwe_prepared: GLWESecretPrepared, crate::backend::BE>, - // TODO: same as `sk_glwe` — needed for any path that reconstructs or exports the full secret. - #[allow(dead_code)] + pub(crate) sk_glwe_prepared: + GLWESecretPrepared, crate::backend::BE>, pub(crate) sk_lwe: LWESecret>, } +impl SecretKey { + /// GLWE + LWE + prepared GLWE from the lattice ChaCha seed only ([`KeygenSeeds::lattice`]). + /// Same as [`crate::context::Context::secret_key_from_lattice_seed`]. No [`EvaluationKey`] is produced. + pub fn from_lattice_seed(ctx: &mut Context, lattice_seed: [u8; 32]) -> Self { + ctx.secret_key_from_lattice_seed(lattice_seed) + } + + /// Standard-form GLWE secret (polynomial masks). Encrypt/decrypt uses the + /// prepared copy; this is the raw material from key generation. + pub fn glwe_standard(&self) -> &GLWESecret> { + &self.sk_glwe + } + + /// Standard-form LWE secret used when building the BDD evaluation key. + pub fn lwe_standard(&self) -> &LWESecret> { + &self.sk_lwe + } +} + /// The public evaluation key bundle. /// /// Wraps a [`BDDKey`] in both standard (serializable) and prepared /// (DFT-domain) forms. Passed to every homomorphic operation on /// [`crate::Ciphertext`]. /// -/// Created by [`crate::Context::keygen`]. +/// Created by [`crate::context::Context::keygen`], [`crate::context::Context::keygen_with_seeds`], +/// [`crate::context::Context::keygen_from_seeds`], or [`EvaluationKey::deserialize`] / +/// [`crate::context::Context::deserialize_evaluation_key`]. pub struct EvaluationKey { /// Standard-form BDD key (circuit bootstrapping + switching keys). - // TODO: read when adding serialization, portable storage, or re-prepare after load (hot path uses `bdd_key_prepared` only). - #[allow(dead_code)] pub(crate) bdd_key: BDDKey, CGGI>, /// Prepared (DFT-domain) copy used on the hot path. - pub(crate) bdd_key_prepared: BDDKeyPrepared, CGGI, crate::backend::BE>, + pub(crate) bdd_key_prepared: + BDDKeyPrepared, CGGI, crate::backend::BE>, +} + +impl EvaluationKey { + /// Serializes the standard-form BDD key (little-endian, versioned). The prepared key is not stored; + /// reload with [`EvaluationKey::deserialize`]. + /// + /// Same as [`crate::context::Context::serialize_evaluation_key`] with this key as argument. + pub fn serialize(&self) -> io::Result> { + let mut out = Vec::new(); + out.push(EVALUATION_KEY_BLOB_VERSION); + self.bdd_key.write_to(&mut out)?; + Ok(out) + } + + /// Restores an [`EvaluationKey`] from [`EvaluationKey::serialize`] output for the same [`Context`] + /// [`Params`](crate::context::Params). + /// + /// Same as [`crate::context::Context::deserialize_evaluation_key`]. + /// + /// # Errors + /// + /// Returns [`std::io::Error`] with kind [`InvalidData`](io::ErrorKind::InvalidData) if the + /// blob does not match this context's layouts. + pub fn deserialize(ctx: &mut Context, bytes: &[u8]) -> io::Result { + ctx.deserialize_evaluation_key(bytes) + } + + /// Standard-form BDD key (circuit bootstrapping + switching keys). This is + /// what [`EvaluationKey::serialize`] writes; the prepared copy is used only on the + /// homomorphic path inside [`crate::context::Context`]. + pub fn bdd_standard(&self) -> &BDDKey, CGGI> { + &self.bdd_key + } } diff --git a/src/lib.rs b/src/lib.rs index 075c1c9..5b0e15a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,4 +6,7 @@ pub mod scratch; pub use ciphertext::Ciphertext; pub use context::{Context, ContextOptions, Params}; -pub use keys::{EvaluationKey, SecretKey}; +pub use keys::{EvaluationKey, KeygenSeeds, SecretKey}; +pub use poulpy_core::layouts::{GLWESecret, LWESecret}; +pub use poulpy_schemes::bin_fhe::bdd_arithmetic::BDDKey; +pub use poulpy_schemes::bin_fhe::blind_rotation::CGGI; diff --git a/tests/bdd_parallel.rs b/tests/bdd_parallel.rs index ca2c05d..c64ce02 100644 --- a/tests/bdd_parallel.rs +++ b/tests/bdd_parallel.rs @@ -2,18 +2,25 @@ use squid::{Context, Params}; #[test] fn eval_threads_two_matches_one() { - let params = Params::unsecure(); + let params = Params::test(); let mut ctx1 = Context::new(params.clone()); let mut ctx2 = Context::new(params).with_eval_threads(2); - let (sk, ek) = ctx1.keygen(); + let (sk1, ek1) = ctx1.keygen(); + let (sk2, ek2) = ctx2.keygen(); - let ct_a = ctx1.encrypt(7u32, &sk); - let ct_b = ctx1.encrypt(5u32, &sk); + let ct_a1 = ctx1.encrypt(7u32, &sk1, &ek1); + let ct_b1 = ctx1.encrypt(5u32, &sk1, &ek1); + let c1 = ctx1.add(&ct_a1, &ct_b1, &ek1); - let c1 = ctx1.add(&ct_a, &ct_b, &ek); - let c2 = ctx2.add(&ct_a, &ct_b, &ek); + let ct_a2 = ctx2.encrypt(7u32, &sk2, &ek2); + let ct_b2 = ctx2.encrypt(5u32, &sk2, &ek2); + let c2 = ctx2.add(&ct_a2, &ct_b2, &ek2); - assert_eq!(ctx1.decrypt(&c1, &sk), ctx2.decrypt(&c2, &sk)); + let r1 = ctx1.decrypt(&c1, &sk1); + let r2 = ctx2.decrypt(&c2, &sk2); + + assert_eq!(r1, 12u32); + assert_eq!(r2, 12u32); } diff --git a/tests/ciphertext_serialization.rs b/tests/ciphertext_serialization.rs new file mode 100644 index 0000000..526c205 --- /dev/null +++ b/tests/ciphertext_serialization.rs @@ -0,0 +1,42 @@ +use squid::{Ciphertext, Context, Params}; + +#[test] +fn ciphertext_serialize_roundtrip_decrypts() { + let mut ctx = Context::new(Params::test()); + let (sk, ek) = ctx.keygen(); + + let ct = ctx.encrypt::(0xdead_beef, &sk, &ek); + let blob = ct.serialize().expect("serialize ciphertext"); + let ct2 = Ciphertext::::deserialize(&mut ctx, &blob).expect("deserialize ciphertext"); + + assert_eq!(ctx.decrypt(&ct2, &sk), 0xdead_beef); +} + +#[test] +fn ciphertext_wrong_type_parameter_is_rejected() { + let mut ctx = Context::new(Params::test()); + let (sk, ek) = ctx.keygen(); + + let ct = ctx.encrypt::(1, &sk, &ek); + let blob = ct.serialize().expect("serialize"); + + match Ciphertext::::deserialize(&mut ctx, &blob) { + Ok(_) => panic!("expected InvalidData for wrong bit width"), + Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidData), + } +} + +#[test] +fn ciphertext_rejects_mismatched_params_glwe_layout() { + let mut ctx_encrypt = Context::new(Params::test()); + let mut ctx_other = Context::new(Params::unsecure()); + let (sk, ek) = ctx_encrypt.keygen(); + + let ct = ctx_encrypt.encrypt::(1, &sk, &ek); + let blob = ct.serialize().expect("serialize"); + + match Ciphertext::::deserialize(&mut ctx_other, &blob) { + Ok(_) => panic!("expected InvalidData for mismatched GLWE layout"), + Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidData), + } +} diff --git a/tests/evaluation_key_serialization.rs b/tests/evaluation_key_serialization.rs new file mode 100644 index 0000000..408438d --- /dev/null +++ b/tests/evaluation_key_serialization.rs @@ -0,0 +1,16 @@ +use squid::{Context, EvaluationKey, Params}; + +/// Evaluation key serialize → deserialize; same in-memory secret key for encrypt/decrypt. +#[test] +fn evaluation_key_serialize_roundtrip_from_os_random() { + let mut ctx = Context::new(Params::test()); + let (sk, ek) = ctx.keygen(); + + let ek_blob = ek.serialize().expect("serialize ek"); + let ek2 = EvaluationKey::deserialize(&mut ctx, &ek_blob).expect("deserialize ek"); + + let a = ctx.encrypt::(11, &sk, &ek); + let b = ctx.encrypt::(22, &sk, &ek); + let c = ctx.add(&a, &b, &ek2); + assert_eq!(ctx.decrypt(&c, &sk), 33); +} diff --git a/tests/keygen_seeds.rs b/tests/keygen_seeds.rs new file mode 100644 index 0000000..c2eb155 --- /dev/null +++ b/tests/keygen_seeds.rs @@ -0,0 +1,52 @@ +use squid::{Context, KeygenSeeds, Params, SecretKey}; + +const SEEDS: KeygenSeeds = KeygenSeeds { + lattice: [7u8; 32], + bdd_mask: [11u8; 32], + bdd_noise: [13u8; 32], +}; + +#[test] +fn keygen_from_seeds_is_deterministic_across_contexts() { + let mut a = Context::new(Params::test()); + let mut b = Context::new(Params::test()); + let (sk_a, _) = a.keygen_from_seeds(SEEDS); + let (sk_b, _) = b.keygen_from_seeds(SEEDS); + + assert!(sk_a.glwe_standard() == sk_b.glwe_standard()); + assert_eq!(sk_a.lwe_standard().raw(), sk_b.lwe_standard().raw()); +} + +#[test] +fn keygen_seeds_roundtrip_through_keygen_from_seeds() { + let mut gen = Context::new(Params::test()); + let (sk_w, _, seeds) = gen.keygen_with_seeds(); + + let mut replay = Context::new(Params::test()); + let (sk_r, _) = replay.keygen_from_seeds(seeds); + + assert!(sk_w.glwe_standard() == sk_r.glwe_standard()); + assert_eq!(sk_w.lwe_standard().raw(), sk_r.lwe_standard().raw()); +} + +#[test] +fn secret_key_from_lattice_seed_matches_full_keygen_lattice_part() { + let mut ctx = Context::new(Params::test()); + let (sk_full, _, seeds) = ctx.keygen_with_seeds(); + let sk_lattice = SecretKey::from_lattice_seed(&mut ctx, seeds.lattice); + assert!(sk_full.glwe_standard() == sk_lattice.glwe_standard()); + assert_eq!( + sk_full.lwe_standard().raw(), + sk_lattice.lwe_standard().raw() + ); +} + +#[test] +fn keygen_from_seeds_homomorphic_smoke() { + let mut ctx = Context::new(Params::test()); + let (sk, ek) = ctx.keygen_from_seeds(SEEDS); + let x = ctx.encrypt::(10, &sk, &ek); + let y = ctx.encrypt::(20, &sk, &ek); + let z = ctx.add(&x, &y, &ek); + assert_eq!(ctx.decrypt(&z, &sk), 30); +}