diff --git a/.cursor/rules/constraints.mdc b/.cursor/rules/constraints.mdc index 43c61f6..f1ccf78 100644 --- a/.cursor/rules/constraints.mdc +++ b/.cursor/rules/constraints.mdc @@ -5,6 +5,8 @@ alwaysApply: true # Constraints +These rules apply to the Rust `squid` library in `crates/squid` (and its wasm/napi shims must stay thin). + ## Never re-implement Poulpy internals If a primitive exists in `poulpy-core`, `poulpy-schemes`, `poulpy-hal`, or `poulpy-cpu-ref`, use it. Writing a "simpler" version in squid defeats the purpose and will drift from Poulpy's correctness guarantees. diff --git a/.cursor/rules/conventions.mdc b/.cursor/rules/conventions.mdc index 7633e74..e34674d 100644 --- a/.cursor/rules/conventions.mdc +++ b/.cursor/rules/conventions.mdc @@ -1,20 +1,22 @@ --- description: Coding conventions for squid -globs: src/**/*.rs, examples/**/*.rs +globs: crates/squid/src/**/*.rs, crates/squid/examples/**/*.rs alwaysApply: false --- # Coding conventions +Paths below are under `crates/squid/`. + ## Structure -- `lib.rs` is module declarations and `pub use` re-exports only. No logic ever goes there. +- `src/lib.rs` is module declarations and `pub use` re-exports only. No logic ever goes there. - All public operations are methods on `Context`. If a user needs to import a type from `keys` or `ciphertext` directly, re-export it from `lib.rs` instead. - `scratch` is a `pub mod`, but every item in `scratch.rs` is `pub(crate)` — treat it as internal; it is not re-exported from `lib.rs` and is not part of the supported API. ## Types -- The backend is selected at compile time via `crate::backend::BE` (see `src/backend.rs`). `type Mod = Module` in `context.rs`. Do not add `` generics to any public type. Use `crate::backend::BE` everywhere internally instead of hard-coding `FFT64Ref`. +- The backend is selected at compile time via `crate::backend::BE` (see `src/backend.rs` in `crates/squid`). `type Mod = Module` in `context.rs`. Do not add `` generics to any public type. Use `crate::backend::BE` everywhere internally instead of hard-coding `FFT64Ref`. - Layout fields on `Params` and its nested structs use Poulpy newtypes (`Degree`, `Base2K`, etc.). Prefer `Params::unsecure()` for the Poulpy `bdd_arithmetic` example bundle (not a security claim). For a custom `Params`, keep all layouts self-consistent (same `n_glwe`, `base2k`, `rank`, … across `glwe_layout`, `ggsw_layout`, `bdd_layout`). - `Ciphertext` is always in standard (`Vec`) form. `FheUintPrepared` must never escape `eval_binary`. @@ -29,6 +31,7 @@ Stay close to Poulpy's vocabulary when naming wrapper types so users who read Po ## User-facing choices Every feature that gives the end user a choice must have a sensible default that works without configuration. Alternatives (e.g. feature flags, optional parameters, variant constructors) must be documented with: + - what the alternative does differently from the default - when the user should prefer it over the default - any trade-offs or requirements (e.g. hardware, security implications, performance cost) diff --git a/.cursor/rules/modules.mdc b/.cursor/rules/modules.mdc index a2a6736..4ae0327 100644 --- a/.cursor/rules/modules.mdc +++ b/.cursor/rules/modules.mdc @@ -1,22 +1,24 @@ --- description: Module responsibilities and boundaries for squid -globs: src/**/*.rs +globs: crates/squid/src/**/*.rs alwaysApply: false --- # Module responsibilities -| Module | File | Responsibility | -|---|---|---| -| `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`. `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. | +Rust sources live under `crates/squid/src/`. + +| Module | File | Responsibility | +| ------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `context` | `crates/squid/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` | `crates/squid/src/backend.rs` | Compile-time `BE` alias (`FFT64Ref` or `FFT64Avx` via `backend-avx`). `pub(crate)` only; not re-exported from `lib.rs`. | +| `scratch` | `crates/squid/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` | `crates/squid/src/keys.rs` | `SecretKey` and `EvaluationKey` types. Holds standard-form and prepared-form key material. Hides all lifecycle transitions. | +| `ciphertext` | `crates/squid/src/ciphertext.rs` | `Ciphertext` newtype over `FheUint, T>`. The only ciphertext form users ever see. | ## lib.rs -`lib.rs` is module declarations and `pub use` re-exports only. No logic. Declares `pub mod` for `context`, `scratch`, `keys`, and `ciphertext`, and a private `mod backend`. +`crates/squid/src/lib.rs` is module declarations and `pub use` re-exports only. No logic. Declares `pub mod` for `context`, `scratch`, `keys`, and `ciphertext`, and a private `mod backend`. ## Ownership model diff --git a/.cursor/rules/poulpy-concepts.mdc b/.cursor/rules/poulpy-concepts.mdc index f1def41..8335bb2 100644 --- a/.cursor/rules/poulpy-concepts.mdc +++ b/.cursor/rules/poulpy-concepts.mdc @@ -1,6 +1,6 @@ --- description: Key Poulpy concepts required before modifying squid code -globs: src/**/*.rs +globs: crates/squid/src/**/*.rs alwaysApply: false --- @@ -16,11 +16,11 @@ Poulpy operations never heap-allocate on the hot path. Every operation accepts ` Every ciphertext and key lives in one of three representations: -| Layer | Storage | Use | -|---|---|---| -| Standard | `Vec`, serializable | Encrypt, decrypt, store, send | -| Compressed | seed + body only | Reduced-size storage | -| Prepared | DFT/NTT domain, backend-specific | Fast multiplication, key-switching | +| Layer | Storage | Use | +| ---------- | -------------------------------- | ---------------------------------- | +| Standard | `Vec`, serializable | Encrypt, decrypt, store, send | +| Compressed | seed + body only | Reduced-size storage | +| Prepared | DFT/NTT domain, backend-specific | Fast multiplication, key-switching | squid hides this: users always hold Standard forms. Prepared forms are created transiently inside `Context` operations and never surface in the public API. @@ -41,6 +41,7 @@ All arithmetic is expressed as trait methods on `Module`. The backend `BE` i ## 5. BDD key hierarchy `BDDKey` bundles three sub-keys: + - `CircuitBootstrappingKey` — blind rotation key + automorphism key + tensor switching key - `GLWESwitchingKey` (optional) — rank reduction before LWE extraction - `GLWEToLWESwitchingKey` — GLWE -> LWE extraction diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc index 9024f19..22b3c5c 100644 --- a/.cursor/rules/project.mdc +++ b/.cursor/rules/project.mdc @@ -7,6 +7,21 @@ alwaysApply: true `squid` is an ergonomic Rust wrapper over [Poulpy](https://github.com/phantomzone-org/poulpy), a low-level, modular FHE toolkit. Poulpy exposes the full machinery of lattice-based homomorphic encryption and leaves resource management entirely to the caller. squid wraps it with a smaller, opinionated API. +## Monorepo layout + +This repository is a **Cargo workspace** and a **pnpm workspace**: + +| Path | Role | +| ------------------- | --------------------------------------------------------------------- | +| `crates/squid` | Rust library (package name `squid`); primary focus of the rules below | +| `crates/squid-wasm` | `wasm-bindgen` crate for the browser | +| `crates/squid-napi` | napi-rs native addon for Node | +| `packages/squid-js` | TypeScript package: client (wasm), server (`.node`) | +| `demo` | Next.js walkthrough app (`@squid-js/demo`) | +| `tests` | Playwright e2e (`@squid-js/e2e`) | + +Root scripts: `pnpm run build:squid-js`, `pnpm run build:demo`, `pnpm test`. + ## Current scope squid currently wraps `poulpy-schemes::bin_fhe::bdd_arithmetic` only — the one fully exposed end-to-end capability in `poulpy-schemes`. That means: @@ -23,10 +38,10 @@ The scope will expand as `poulpy-schemes` adds more scheme-level implementations Everything flows through `Context`. The user-facing types are: -| Type | Role | -|---|---| -| `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`. | -| `Ciphertext` | Encrypted `T` value (`u8`, `u16`, `u32`). All ops run through `Context`. | +| Type | Role | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `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`. | +| `Ciphertext` | Encrypted `T` value (`u8`, `u16`, `u32`). All ops run through `Context`. | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 222d55a..7e6ae8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,43 @@ jobs: run: cargo clippy --all-targets --all-features -- -D warnings - name: cargo test run: cargo test --verbose + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - uses: dtolnay/rust-toolchain@v1 + with: + toolchain: nightly + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + + - uses: taiki-e/install-action@v2 + with: + tool: wasm-pack + + - name: Install JS dependencies + run: pnpm install --frozen-lockfile + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Build wasm, napi, and JS packages + run: pnpm run build + + - name: Run E2E tests + run: pnpm test diff --git a/.gitignore b/.gitignore index 5b5a9d6..333ea5b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,29 @@ # macOS .DS_Store + +# Node +node_modules/ +dist/ +pkg/ +*.node +packages/squid-js/wasm/ +packages/squid-js/napi/ + +# Next.js +demo/.next/ +demo/data/*.bin + +# Playwright +tests/playwright-report/ +tests/test-results/ + +# Environment +.env +.env.local +*.log +*.tsbuildinfo + +# pnpm +.pnpm-store/ +pnpm-debug.log* diff --git a/Cargo.lock b/Cargo.lock index 8c9c7e9..7666378 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -20,6 +29,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytemuck" version = "1.25.0" @@ -68,6 +83,25 @@ dependencies = [ "cc", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -77,6 +111,16 @@ dependencies = [ "libc", ] +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "dashu-base" version = "0.4.1" @@ -141,9 +185,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -153,11 +199,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -214,6 +262,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -226,6 +284,16 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -244,6 +312,63 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", +] + +[[package]] +name = "napi-build" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + [[package]] name = "num-modular" version = "0.6.1" @@ -454,6 +579,35 @@ dependencies = [ "rand", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rustversion" version = "1.0.22" @@ -526,6 +680,28 @@ dependencies = [ "poulpy-schemes", ] +[[package]] +name = "squid-napi" +version = "0.1.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "squid", +] + +[[package]] +name = "squid-wasm" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "getrandom 0.3.4", + "getrandom 0.4.2", + "js-sys", + "squid", + "wasm-bindgen", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -549,6 +725,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -573,6 +755,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -607,6 +834,12 @@ dependencies = [ "semver", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 17b44d4..bf0b2c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,21 @@ -[package] -name = "squid" -version = "0.1.0-alpha.1" +[workspace] +resolver = "2" +members = [ + "crates/squid", + "crates/squid-wasm", + "crates/squid-napi", +] + +[workspace.package] +version = "0.1.0" edition = "2021" -description = "An ergonomic Rust wrapper for Poulpy FHE." license = "MIT" +authors = ["Omar Desogus "] -[features] -backend-avx = ["dep:poulpy-cpu-avx"] - -[dependencies] -poulpy-core = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626" } -poulpy-schemes = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626" } -poulpy-hal = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626" } -poulpy-cpu-ref = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626" } -poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626", optional = true } -getrandom = "0.3" +[workspace.dependencies] +squid = { path = "crates/squid", default-features = false } -[target.'cfg(target_arch = "x86_64")'.dependencies] -poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626", optional = true, features = ["enable-avx"] } +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 diff --git a/README.md b/README.md index 23a8282..8b4d4c8 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,29 @@ # 🦑 Squid -**An ergonomic Rust wrapper for [Poulpy](https://github.com/phantomzone-org/poulpy), making Fully Homomorphic Encryption accessible without sacrificing control.** +**An ergonomic Rust wrapper ([`squid`](crates/squid)) for [Poulpy](https://github.com/poulpy-fhe/poulpy) and the [`squid-js`](packages/squid-js) library for browser and Node (WebAssembly + napi-rs), 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) +[![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) [![Live demo](https://img.shields.io/badge/demo-squid.cedoor.dev-blueviolet)](https://squid.cedoor.dev/) ![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. +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`](crates/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. -**Current scope:** `squid` wraps Poulpy's `bin_fhe::bdd_arithmetic` layer: gate-level FHE on encrypted unsigned integers (`u8`, `u16`, `u32`). This is the only fully exposed end-to-end capability in `poulpy-schemes` today. The API will expand as Poulpy adds more scheme-level implementations. +For JavaScript and TypeScript, **[`squid-js`](packages/squid-js)** exposes the same stack: **`squid-js/client`** runs keygen, encrypt, and decrypt in the browser over WebAssembly (typically inside a dedicated worker so crypto stays off the UI thread), while **`squid-js/server`** runs homomorphic evaluation in Node via a native addon (napi-rs). In the usual setup the secret key stays on the client; the server only receives the evaluation key and ciphertexts. -There are also [JavaScript bindings](https://github.com/cedoor/poulpy-js/) (browser and Node, via WebAssembly and napi-rs) built on Squid’s Rust API. +**Current scope:** [`squid`](crates/squid) wraps Poulpy's `bin_fhe::bdd_arithmetic` layer: gate-level FHE on encrypted unsigned integers (`u8`, `u16`, `u32`). This is the only fully exposed end-to-end capability in `poulpy-schemes` today. The API will expand as Poulpy adds more scheme-level implementations. -## Usage +## Monorepo structure + +| Path | Description | +| ---------------------------------------- | ---------------------------------------------------------------- | +| [`crates/squid`](crates/squid) | Rust library — ergonomic Poulpy wrapper (this is the main crate) | +| [`crates/squid-wasm`](crates/squid-wasm) | WebAssembly bindings via `wasm-bindgen` (browser) | +| [`crates/squid-napi`](crates/squid-napi) | Node.js native bindings via `napi-rs` (server) | +| [`packages/squid-js`](packages/squid-js) | npm package — browser client + Node evaluator | +| [`demo`](demo) | Next.js demo: keygen in browser, homomorphic eval on server — **[live at squid.cedoor.dev](https://squid.cedoor.dev/)** | +| [`tests`](tests) | Playwright end-to-end tests for the demo | + +The Cargo workspace ties the three Rust crates together. The pnpm workspace ties [`squid-js`](packages/squid-js), the demo, and the E2E tests together. + +## Usage (Rust) ### Quick start @@ -46,7 +59,7 @@ Built-in bundles can be selected by string: `Params::by_name("unsecure")` and `P The evaluation key is public material needed for every homomorphic op. Persist it once and reload it on the server that runs the circuits. The blob is -versioned and tied to the [`Params`](src/context.rs) used at keygen — loading +versioned and tied to the [`Params`](crates/squid/src/context.rs) used at keygen — loading under different parameters returns an `io::Error`. ```rust @@ -179,12 +192,12 @@ The public API is identical regardless of which backend is selected. - [ ] Vetted Params presets: [#18](https://github.com/cedoor/squid/issues/18) - [ ] Refactor `context.rs`: [#20](https://github.com/cedoor/squid/issues/20) - [ ] Add CHANGELOG file: [#26](https://github.com/cedoor/squid/issues/26) -- [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). +- [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; scratch is sized with Poulpy’s `*_tmp_bytes` at each operation. +- **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/crates/squid-napi/Cargo.toml b/crates/squid-napi/Cargo.toml new file mode 100644 index 0000000..b7aee46 --- /dev/null +++ b/crates/squid-napi/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "squid-napi" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "napi-rs bindings for cedoor/squid (server-side evaluator)." +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +squid = { workspace = true } +napi = { version = "2.16", default-features = false, features = ["napi8"] } +napi-derive = "2.16" + +[build-dependencies] +napi-build = "2" diff --git a/crates/squid-napi/build.rs b/crates/squid-napi/build.rs new file mode 100644 index 0000000..0f1b010 --- /dev/null +++ b/crates/squid-napi/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/crates/squid-napi/src/lib.rs b/crates/squid-napi/src/lib.rs new file mode 100644 index 0000000..2d63991 --- /dev/null +++ b/crates/squid-napi/src/lib.rs @@ -0,0 +1,56 @@ +//! napi-rs bindings over `squid` for server-side evaluation. +//! +//! The server only knows how to load an `EvaluationKey` and run ops on +//! serialized ciphertexts. It never holds any secret key material. + +#![deny(clippy::all)] + +use napi::bindgen_prelude::*; +use napi_derive::napi; +use squid::{Context, EvaluationKey, Params}; + +#[napi] +pub struct Evaluator { + ctx: Context, + ek: EvaluationKey, +} + +#[napi] +impl Evaluator { + /// Rebuild a `Context` for the named parameter set (`"test"` or `"unsecure"`) + /// and load the serialized evaluation key produced by the browser client. + #[napi(factory)] + pub fn load(ek_bytes: Buffer, params_name: String) -> Result { + let params = Params::by_name(¶ms_name).ok_or_else(|| { + Error::from_reason(format!( + "unknown parameter set {params_name:?}; expected \"test\" or \"unsecure\"" + )) + })?; + let mut ctx = Context::new(params); + let ek = ctx + .deserialize_evaluation_key(ek_bytes.as_ref()) + .map_err(io_err)?; + Ok(Self { ctx, ek }) + } + + /// Homomorphic `a + b` over serialized `u32` ciphertexts. Returns the + /// serialized result. + #[napi] + pub fn add_u32(&mut self, a: Buffer, b: Buffer) -> Result { + let a_ct = self + .ctx + .deserialize_ciphertext::(a.as_ref()) + .map_err(io_err)?; + let b_ct = self + .ctx + .deserialize_ciphertext::(b.as_ref()) + .map_err(io_err)?; + let c = self.ctx.add(&a_ct, &b_ct, &self.ek); + let out = c.serialize().map_err(io_err)?; + Ok(out.into()) + } +} + +fn io_err(e: std::io::Error) -> Error { + Error::from_reason(e.to_string()) +} diff --git a/crates/squid-wasm/Cargo.toml b/crates/squid-wasm/Cargo.toml new file mode 100644 index 0000000..9ec07bc --- /dev/null +++ b/crates/squid-wasm/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "squid-wasm" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "wasm-bindgen bindings for cedoor/squid (portable backend)." +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +squid = { workspace = true } +wasm-bindgen = "0.2.95" +js-sys = "0.3.72" +console_error_panic_hook = "0.1.7" + +# On wasm32 we need getrandom's JS backend for every version that ends up +# linked into the dep graph (squid uses 0.3, some transitive Poulpy deps pull +# in 0.4). Cargo unifies features per-semver, so turning `wasm_js` on here is +# enough to make every user of that major version compile. +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom_03 = { package = "getrandom", version = "0.3", features = ["wasm_js"] } +getrandom_04 = { package = "getrandom", version = "0.4", features = ["wasm_js"] } + +[features] +default = [] diff --git a/crates/squid-wasm/src/lib.rs b/crates/squid-wasm/src/lib.rs new file mode 100644 index 0000000..cb80cd0 --- /dev/null +++ b/crates/squid-wasm/src/lib.rs @@ -0,0 +1,113 @@ +//! wasm-bindgen bindings over `squid` for browser use. +//! +//! Public surface mirrors the plan: `Session` owns a `Context` + `(sk, ek)` +//! and exposes byte-oriented helpers. Secret key material never leaves memory +//! — only `KeygenSeeds` (96 bytes) and serialized `ek`/ciphertexts do. + +use squid::{Context, EvaluationKey, KeygenSeeds, Params, SecretKey}; +use wasm_bindgen::prelude::*; + +const SEEDS_LEN: usize = 96; + +fn params_from_name(name: &str) -> Result { + Params::by_name(name).ok_or_else(|| { + JsError::new(&format!( + "unknown parameter set {name:?}; expected \"test\" or \"unsecure\"" + )) + }) +} + +#[wasm_bindgen(start)] +pub fn __start() { + console_error_panic_hook::set_once(); +} + +#[wasm_bindgen] +pub struct Session { + ctx: Context, + sk: SecretKey, + ek: EvaluationKey, + seeds: KeygenSeeds, +} + +#[wasm_bindgen] +impl Session { + /// Fresh keys from OS randomness. Returns a session whose seeds can be + /// exported via [`Session::seeds`] for later restoration. + #[wasm_bindgen(js_name = newRandom)] + pub fn new_random(params_name: &str) -> Result { + let params = params_from_name(params_name)?; + let mut ctx = Context::new(params); + let (sk, ek, seeds) = ctx.keygen_with_seeds(); + Ok(Session { ctx, sk, ek, seeds }) + } + + /// Deterministic rebuild from a 96-byte seed blob produced by + /// [`Session::seeds`]. + #[wasm_bindgen(js_name = fromSeeds)] + pub fn from_seeds(seeds: &[u8], params_name: &str) -> Result { + if seeds.len() != SEEDS_LEN { + return Err(JsError::new(&format!( + "expected {SEEDS_LEN}-byte seeds blob, got {}", + seeds.len() + ))); + } + let mut lattice = [0u8; 32]; + let mut bdd_mask = [0u8; 32]; + let mut bdd_noise = [0u8; 32]; + lattice.copy_from_slice(&seeds[0..32]); + bdd_mask.copy_from_slice(&seeds[32..64]); + bdd_noise.copy_from_slice(&seeds[64..96]); + let ks = KeygenSeeds { + lattice, + bdd_mask, + bdd_noise, + }; + let params = params_from_name(params_name)?; + let mut ctx = Context::new(params); + let (sk, ek) = ctx.keygen_from_seeds(ks); + Ok(Session { + ctx, + sk, + ek, + seeds: ks, + }) + } + + /// 96-byte seed blob: `lattice || bdd_mask || bdd_noise`. + #[wasm_bindgen(js_name = seeds)] + pub fn seeds(&self) -> Vec { + let mut out = Vec::with_capacity(SEEDS_LEN); + out.extend_from_slice(&self.seeds.lattice); + out.extend_from_slice(&self.seeds.bdd_mask); + out.extend_from_slice(&self.seeds.bdd_noise); + out + } + + /// Versioned little-endian evaluation key blob to ship to the server. + #[wasm_bindgen(js_name = evaluationKeyBytes)] + pub fn evaluation_key_bytes(&self) -> Result, JsError> { + self.ek.serialize().map_err(io_err) + } + + /// Encrypt a `u32` value and return the serialized ciphertext. + #[wasm_bindgen(js_name = encryptU32)] + pub fn encrypt_u32(&mut self, value: u32) -> Result, JsError> { + let ct = self.ctx.encrypt::(value, &self.sk); + ct.serialize().map_err(io_err) + } + + /// Deserialize + decrypt a `u32` ciphertext. + #[wasm_bindgen(js_name = decryptU32)] + pub fn decrypt_u32(&mut self, bytes: &[u8]) -> Result { + let ct = self + .ctx + .deserialize_ciphertext::(bytes) + .map_err(io_err)?; + Ok(self.ctx.decrypt::(&ct, &self.sk)) + } +} + +fn io_err(e: std::io::Error) -> JsError { + JsError::new(&e.to_string()) +} diff --git a/crates/squid/Cargo.lock b/crates/squid/Cargo.lock new file mode 100644 index 0000000..8c9c7e9 --- /dev/null +++ b/crates/squid/Cargo.lock @@ -0,0 +1,722 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "dashu-base" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b80bf6b85aa68c58ffea2ddb040109943049ce3fbdf4385d0380aef08ef289" + +[[package]] +name = "dashu-float" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85078445a8dbd2e1bd21f04a816f352db8d333643f0c9b78ca7c3d1df71063e7" +dependencies = [ + "dashu-base", + "dashu-int", + "num-modular", + "num-order", + "rustversion", + "static_assertions", +] + +[[package]] +name = "dashu-int" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee99d08031ca34a4d044efbbb21dff9b8c54bb9d8c82a189187c0651ffdb9fbf" +dependencies = [ + "cfg-if", + "dashu-base", + "num-modular", + "rustversion", + "static_assertions", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "poulpy-core" +version = "0.5.0" +source = "git+https://github.com/poulpy-fhe/poulpy.git?rev=e5450f9a51884082c293c8a13846c8a6fb310626#e5450f9a51884082c293c8a13846c8a6fb310626" +dependencies = [ + "anyhow", + "bytemuck", + "byteorder", + "dashu-float", + "itertools", + "once_cell", + "paste", + "poulpy-hal", + "rand", +] + +[[package]] +name = "poulpy-cpu-avx" +version = "0.5.0" +source = "git+https://github.com/poulpy-fhe/poulpy.git?rev=e5450f9a51884082c293c8a13846c8a6fb310626#e5450f9a51884082c293c8a13846c8a6fb310626" +dependencies = [ + "bytemuck", + "byteorder", + "itertools", + "once_cell", + "paste", + "poulpy-core", + "poulpy-cpu-ref", + "poulpy-hal", + "rand", + "rand_chacha", + "rand_core", + "rand_distr", +] + +[[package]] +name = "poulpy-cpu-ref" +version = "0.5.0" +source = "git+https://github.com/poulpy-fhe/poulpy.git?rev=e5450f9a51884082c293c8a13846c8a6fb310626#e5450f9a51884082c293c8a13846c8a6fb310626" +dependencies = [ + "bytemuck", + "byteorder", + "dashu-float", + "itertools", + "once_cell", + "poulpy-core", + "poulpy-hal", + "rand", + "rand_chacha", + "rand_core", + "rand_distr", +] + +[[package]] +name = "poulpy-hal" +version = "0.5.0" +source = "git+https://github.com/poulpy-fhe/poulpy.git?rev=e5450f9a51884082c293c8a13846c8a6fb310626#e5450f9a51884082c293c8a13846c8a6fb310626" +dependencies = [ + "anyhow", + "bytemuck", + "byteorder", + "cmake", + "dashu-float", + "itertools", + "once_cell", + "paste", + "rand", + "rand_chacha", + "rand_core", + "rand_distr", +] + +[[package]] +name = "poulpy-schemes" +version = "0.5.0" +source = "git+https://github.com/poulpy-fhe/poulpy.git?rev=e5450f9a51884082c293c8a13846c8a6fb310626#e5450f9a51884082c293c8a13846c8a6fb310626" +dependencies = [ + "anyhow", + "byteorder", + "itertools", + "paste", + "poulpy-core", + "poulpy-cpu-ref", + "poulpy-hal", + "rand", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "squid" +version = "0.1.0-alpha.1" +dependencies = [ + "getrandom 0.3.4", + "poulpy-core", + "poulpy-cpu-avx", + "poulpy-cpu-ref", + "poulpy-hal", + "poulpy-schemes", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/squid/Cargo.toml b/crates/squid/Cargo.toml new file mode 100644 index 0000000..23e4f67 --- /dev/null +++ b/crates/squid/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "squid" +version = "0.1.0-alpha.1" +edition = "2021" +description = "An ergonomic Rust wrapper for Poulpy FHE." +license = "MIT" +authors.workspace = true + +[features] +backend-avx = ["dep:poulpy-cpu-avx"] + +[dependencies] +poulpy-core = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626" } +poulpy-schemes = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626" } +poulpy-hal = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626" } +poulpy-cpu-ref = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626" } +poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626", optional = true } +getrandom = "0.3" + +[target.'cfg(target_arch = "x86_64")'.dependencies] +poulpy-cpu-avx = { git = "https://github.com/poulpy-fhe/poulpy.git", rev = "e5450f9a51884082c293c8a13846c8a6fb310626", optional = true, features = ["enable-avx"] } diff --git a/examples/add_u32.rs b/crates/squid/examples/add_u32.rs similarity index 100% rename from examples/add_u32.rs rename to crates/squid/examples/add_u32.rs diff --git a/src/backend.rs b/crates/squid/src/backend.rs similarity index 100% rename from src/backend.rs rename to crates/squid/src/backend.rs diff --git a/src/ciphertext.rs b/crates/squid/src/ciphertext.rs similarity index 100% rename from src/ciphertext.rs rename to crates/squid/src/ciphertext.rs diff --git a/src/context.rs b/crates/squid/src/context.rs similarity index 100% rename from src/context.rs rename to crates/squid/src/context.rs diff --git a/src/keys.rs b/crates/squid/src/keys.rs similarity index 100% rename from src/keys.rs rename to crates/squid/src/keys.rs diff --git a/src/lib.rs b/crates/squid/src/lib.rs similarity index 100% rename from src/lib.rs rename to crates/squid/src/lib.rs diff --git a/src/scratch.rs b/crates/squid/src/scratch.rs similarity index 100% rename from src/scratch.rs rename to crates/squid/src/scratch.rs diff --git a/tests/bdd_parallel.rs b/crates/squid/tests/bdd_parallel.rs similarity index 100% rename from tests/bdd_parallel.rs rename to crates/squid/tests/bdd_parallel.rs diff --git a/tests/ciphertext_serialization.rs b/crates/squid/tests/ciphertext_serialization.rs similarity index 100% rename from tests/ciphertext_serialization.rs rename to crates/squid/tests/ciphertext_serialization.rs diff --git a/tests/common/mod.rs b/crates/squid/tests/common/mod.rs similarity index 100% rename from tests/common/mod.rs rename to crates/squid/tests/common/mod.rs diff --git a/tests/evaluation_key_serialization.rs b/crates/squid/tests/evaluation_key_serialization.rs similarity index 100% rename from tests/evaluation_key_serialization.rs rename to crates/squid/tests/evaluation_key_serialization.rs diff --git a/tests/keygen_seeds.rs b/crates/squid/tests/keygen_seeds.rs similarity index 100% rename from tests/keygen_seeds.rs rename to crates/squid/tests/keygen_seeds.rs diff --git a/demo/next-env.d.ts b/demo/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/demo/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/demo/next.config.ts b/demo/next.config.ts new file mode 100644 index 0000000..31eaf24 --- /dev/null +++ b/demo/next.config.ts @@ -0,0 +1,25 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' }, + { key: 'Cross-Origin-Embedder-Policy', value: 'require-corp' }, + ], + }, + ] + }, + serverExternalPackages: ['squid-js'], + webpack(config, { isServer }) { + if (!isServer) { + config.experiments = { ...config.experiments, asyncWebAssembly: true } + config.module.rules.push({ test: /\.wasm$/, type: 'asset/resource' }) + } + return config + }, +} + +export default nextConfig diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..82a1d65 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,27 @@ +{ + "name": "@squid-js/demo", + "version": "0.1.0", + "private": true, + "description": "Playful Next.js FHE demo — keygen, encrypt, evaluate, decrypt with squid-js.", + "scripts": { + "predev": "node scripts/ensure-demo-ek.mjs", + "dev": "next dev", + "prebuild": "node scripts/generate-demo-ek.mjs", + "gen-demo-ek": "node scripts/generate-demo-ek.mjs", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "^15.3.1", + "squid-js": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5.6.3" + } +} diff --git a/demo/scripts/ensure-demo-ek.mjs b/demo/scripts/ensure-demo-ek.mjs new file mode 100644 index 0000000..bd957a8 --- /dev/null +++ b/demo/scripts/ensure-demo-ek.mjs @@ -0,0 +1,15 @@ +import { existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const f = join(root, 'data', 'demo-ek.test.bin') +if (!existsSync(f)) { + console.log('demo: generating data/demo-ek.test.bin (one-time, ~30 MiB)…') + const r = spawnSync(process.execPath, [join(root, 'scripts', 'generate-demo-ek.mjs')], { + stdio: 'inherit', + cwd: root, + }) + process.exit(r.status ?? 1) +} diff --git a/demo/scripts/generate-demo-ek.mjs b/demo/scripts/generate-demo-ek.mjs new file mode 100644 index 0000000..6ecdf6d --- /dev/null +++ b/demo/scripts/generate-demo-ek.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * Builds `data/demo-ek.test.bin` from the same 96-byte seed as `src/lib/demo-seed.ts`. + * Requires built squid-js wasm (workspace / published package). + */ +import { writeFileSync, mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createRequire } from 'node:module' + +import init, { Session } from 'squid-js/wasm/squid_wasm.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const outDir = join(__dirname, '..', 'data') +const outFile = join(outDir, 'demo-ek.test.bin') + +const require = createRequire(import.meta.url) +const wasmPath = require.resolve('squid-js/wasm/squid_wasm_bg.wasm') + +const seeds = new Uint8Array(96) +for (let i = 0; i < 96; i++) { + seeds[i] = (i * 13 + 1) & 0xff +} + +const paramsSet = process.env.POULPY_PARAMS_SET ?? 'test' + +await init({ module_or_path: readFileSync(wasmPath) }) +const session = Session.fromSeeds(seeds, paramsSet) +const ek = session.evaluationKeyBytes() +mkdirSync(outDir, { recursive: true }) +writeFileSync(outFile, Buffer.from(ek)) +console.log(`wrote ${outFile} (${ek.length} bytes) for paramsSet=${paramsSet}`) diff --git a/demo/src/app/api/add/route.ts b/demo/src/app/api/add/route.ts new file mode 100644 index 0000000..f36aa60 --- /dev/null +++ b/demo/src/app/api/add/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDemoEvaluator } from '@/lib/load-demo-evaluator' + +export async function POST(req: NextRequest) { + const evaluator = getDemoEvaluator() + const body = Buffer.from(await req.arrayBuffer()) + if (body.length < 4) { + return NextResponse.json({ error: 'body must be u32-le(a_len) || a || b' }, { status: 400 }) + } + const aLen = body.readUInt32LE(0) + if (4 + aLen > body.length) { + return NextResponse.json({ error: 'a_len overruns body' }, { status: 400 }) + } + const a = body.subarray(4, 4 + aLen) + const b = body.subarray(4 + aLen) + try { + const out = new Uint8Array(evaluator.addU32(a, b)) + return new NextResponse(out, { + headers: { 'Content-Type': 'application/octet-stream' }, + }) + } catch (err) { + return NextResponse.json({ error: (err as Error).message }, { status: 400 }) + } +} diff --git a/demo/src/app/globals.css b/demo/src/app/globals.css new file mode 100644 index 0000000..883a0c7 --- /dev/null +++ b/demo/src/app/globals.css @@ -0,0 +1,124 @@ +:root { + --bg: oklch(0.975 0.01 80); + --bg-tint: oklch(0.955 0.015 80); + --card: oklch(0.995 0.004 80); + --ink: oklch(0.22 0.015 60); + --ink-soft: oklch(0.42 0.02 60); + --ink-faint: oklch(0.65 0.015 60); + --rule: oklch(0.88 0.012 70); + --rule-strong: oklch(0.78 0.015 70); + + --plain: oklch(0.66 0.15 25); + --plain-soft: oklch(0.92 0.05 25); + --cipher: oklch(0.62 0.13 210); + --cipher-soft: oklch(0.93 0.04 210); + --key: oklch(0.62 0.13 290); + --key-soft: oklch(0.93 0.04 290); + + --ok: oklch(0.62 0.14 150); + --warn: oklch(0.72 0.14 70); + + --term-bg: oklch(0.2 0.015 260); + --term-ink: oklch(0.92 0.01 90); + --term-dim: oklch(0.62 0.015 90); + + font-family: var(--font-sans), ui-sans-serif, system-ui, -apple-system, sans-serif; + font-size: 15px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} + +*, *::before, *::after { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + min-height: 100vh; +} + +button { + font: inherit; + color: inherit; +} + +::selection { + background: var(--plain-soft); +} + +@keyframes poulpy-wiggle { + 0%, 100% { transform: rotate(0deg); } + 50% { transform: rotate(2deg); } +} + +@keyframes parcel { + 0% { transform: translateX(-4px); opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { transform: translateX(68px); opacity: 0; } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +@keyframes blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} + +.demo-github-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + color: var(--ink-soft); + background: oklch(0.98 0.01 80); + border: 1px solid var(--rule); + transition: color 0.15s, background 0.15s, border-color 0.15s; +} + +.demo-github-link:hover { + color: var(--ink); + background: oklch(0.96 0.02 80); + border-color: var(--ink-faint); +} + +.demo-root { padding: 28px 32px 40px; } +.demo-header { flex-direction: row; align-items: center; gap: 18px; } +.demo-header-title { font-size: 36px; } +.demo-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(360px, 1fr); gap: 22px; align-items: start; } +.demo-sidebar { position: sticky; top: 20px; height: calc(100vh - 40px); min-height: 560px; } + +@media (max-width: 960px) { + .demo-root { padding: 20px 18px 32px; } + .demo-grid { grid-template-columns: 1fr; } + .demo-sidebar { position: static; height: auto; min-height: 340px; } + .demo-header-title { font-size: 30px; } +} + +@media (max-width: 600px) { + :root { font-size: 14px; } + .demo-root { padding: 16px 14px 28px; gap: 16px !important; } + .demo-header { flex-wrap: wrap; gap: 12px !important; padding-bottom: 14px !important; justify-content: space-between !important; align-items: center; } + .demo-header-main { flex: 1 1 100% !important; order: 2 !important; } + .demo-header-title { font-size: 24px; letter-spacing: -0.3px; } + .demo-header-sub { font-size: 13px; } + .demo-card-2col { grid-template-columns: 1fr !important; } + .demo-send-grid { grid-template-columns: 1fr !important; } + .demo-send-arrow { transform: rotate(90deg); margin: 4px auto; } + .demo-numpad { grid-template-columns: repeat(8, 1fr) !important; } + .demo-decrypt-row { flex-direction: column !important; align-items: flex-start !important; gap: 14px !important; } + .demo-decrypt-explain { flex: 0 0 auto !important; min-width: 0 !important; } + .demo-decrypt-big { font-size: 56px !important; } + .demo-footer { flex-direction: column; align-items: flex-start; gap: 4px !important; } + .demo-sidebar { min-height: 280px; } + .demo-card { padding: 14px !important; } + .demo-card-accent { padding-left: 18px !important; } +} diff --git a/demo/src/app/layout.tsx b/demo/src/app/layout.tsx new file mode 100644 index 0000000..52423e1 --- /dev/null +++ b/demo/src/app/layout.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from 'next' +import { Newsreader, Geist, Geist_Mono } from 'next/font/google' +import './globals.css' + +const newsreader = Newsreader({ + subsets: ['latin'], + weight: ['400', '500', '600'], + style: ['normal', 'italic'], + variable: '--font-serif', + display: 'swap', +}) + +const geist = Geist({ + subsets: ['latin'], + variable: '--font-sans', + display: 'swap', +}) + +const geistMono = Geist_Mono({ + subsets: ['latin'], + variable: '--font-mono', + display: 'swap', +}) + +export const metadata: Metadata = { + title: 'Poulpy FHE — Interactive Demo', + description: + 'Add two numbers without the server ever seeing them — a CGGI homomorphic encryption walkthrough.', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/demo/src/app/page.tsx b/demo/src/app/page.tsx new file mode 100644 index 0000000..8a1869b --- /dev/null +++ b/demo/src/app/page.tsx @@ -0,0 +1,5 @@ +import { DemoLoader } from '@/components/DemoLoader' + +export default function Page() { + return +} diff --git a/demo/src/components/Demo.tsx b/demo/src/components/Demo.tsx new file mode 100644 index 0000000..94efc8c --- /dev/null +++ b/demo/src/components/Demo.tsx @@ -0,0 +1,273 @@ +'use client' + +import { useCallback } from 'react' +import { Poulpy } from '@/components/mascot/Poulpy' +import { Btn } from '@/components/atoms/Btn' +import { ServerTerminal } from '@/components/terminal/ServerTerminal' +import { Step1Keygen } from '@/components/steps/Step1Keygen' +import { Step2Encrypt } from '@/components/steps/Step2Encrypt' +import { Step3Evaluate } from '@/components/steps/Step3Evaluate' +import { Step4Decrypt } from '@/components/steps/Step4Decrypt' +import { useFhe } from '@/hooks/useFhe' + +const GITHUB_REPO = 'https://github.com/cedoor/squid' + +function GitHubLink() { + return ( + + + + + + ) +} + +function Footer() { + return ( +
+ scheme: CGGI · plaintext: u32 + + legend:{' '} + ● plaintext{' '} + ● ciphertext{' '} + ● keys + + squid-js demo · not for production use +
+ ) +} + +export default function Demo() { + const { state, doKeygen, doEncrypt, doEvaluate, doDecrypt, doReset, setA, setB } = useFhe() + const { phase, skPreview, ekBytes, a, b, ctA, ctB, ctSum, result, logs, error } = state + + const busy = phase !== 'idle' + + // Mood: thinking while busy, brief happy flash after keygen/decrypt, locked on error + // Derive mood from state + let mood: 'idle' | 'thinking' | 'happy' | 'locked' = 'idle' + if (error) mood = 'locked' + else if (busy) mood = 'thinking' + else if (result !== null) mood = 'happy' + + const handleEncrypt = useCallback(() => { + doEncrypt(a, b) + }, [doEncrypt, a, b]) + + const handleEvaluate = useCallback(() => { + if (ctA && ctB) { + doEvaluate(ctA, ctB) + } + }, [doEvaluate, ctA, ctB]) + + const handleDecrypt = useCallback(() => { + if (ctSum) doDecrypt(ctSum) + }, [doDecrypt, ctSum]) + + return ( +
+ {/* Header */} +
+ +
+
+ Poulpy · FHE playground +
+

+ Add two numbers{' '} + without the server + ever seeing them. +

+
+ An end-to-end walkthrough of a CGGI homomorphic addition — keygen, encrypt, evaluate, + decrypt — using the{' '} + + Poulpy + {' '} + library. +
+ {error && ( +
+ ✗ {error} +
+ )} +
+
+ + Reset demo + + +
+
+ + {/* Two-column grid */} +
+ {/* Left column — steps */} +
+ + + + +
+
+ + {/* Right column — server terminal */} +
+
+
+
+ the other side +
+

+ Server log +

+
+ + crypto runs in your browser + +
+ +
+
+
+ ) +} diff --git a/demo/src/components/DemoLoader.tsx b/demo/src/components/DemoLoader.tsx new file mode 100644 index 0000000..9b8ba75 --- /dev/null +++ b/demo/src/components/DemoLoader.tsx @@ -0,0 +1,9 @@ +'use client' + +import dynamic from 'next/dynamic' + +const Demo = dynamic(() => import('./Demo'), { ssr: false }) + +export function DemoLoader() { + return +} diff --git a/demo/src/components/atoms/Btn.tsx b/demo/src/components/atoms/Btn.tsx new file mode 100644 index 0000000..96c1fe2 --- /dev/null +++ b/demo/src/components/atoms/Btn.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useState, type CSSProperties, type ReactNode } from 'react' + +interface BtnProps { + children: ReactNode + onClick?: () => void + disabled?: boolean + primary?: boolean + tone?: 'cipher' | 'plain' | 'key' | 'default' + small?: boolean + style?: CSSProperties +} + +export function Btn({ children, onClick, disabled, primary, tone = 'default', small, style }: BtnProps) { + const [hover, setHover] = useState(false) + + const base: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 8, + border: '1px solid var(--rule-strong)', + borderRadius: 999, + padding: small ? '6px 14px' : '9px 18px', + background: 'var(--card)', + color: 'var(--ink)', + fontWeight: 500, + fontSize: small ? 13 : 14, + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.45 : 1, + transition: 'transform 0.08s ease, background 0.15s ease, border-color 0.15s ease', + transform: hover && !disabled ? 'translateY(-1px)' : 'none', + fontFamily: 'var(--font-sans)', + } + + const primaryStyle: CSSProperties = primary + ? { + background: + tone === 'cipher' + ? 'var(--cipher)' + : tone === 'plain' + ? 'var(--plain)' + : tone === 'key' + ? 'var(--key)' + : 'var(--ink)', + color: 'var(--bg)', + borderColor: 'transparent', + } + : {} + + return ( + + ) +} diff --git a/demo/src/components/atoms/ByteViewer.tsx b/demo/src/components/atoms/ByteViewer.tsx new file mode 100644 index 0000000..380a3ef --- /dev/null +++ b/demo/src/components/atoms/ByteViewer.tsx @@ -0,0 +1,32 @@ +import { bytesToHexPreview } from '@/lib/fingerprint' + +interface ByteViewerProps { + bytes: Uint8Array + label?: string + accent?: string + maxBytes?: number +} + +export function ByteViewer({ bytes, label, accent = 'var(--cipher)', maxBytes = 24 }: ByteViewerProps) { + const hex = bytesToHexPreview(bytes, maxBytes) + return ( +
+ {label && ( + {label} + )} + {hex} +
+ ) +} diff --git a/demo/src/components/atoms/Card.tsx b/demo/src/components/atoms/Card.tsx new file mode 100644 index 0000000..9240d20 --- /dev/null +++ b/demo/src/components/atoms/Card.tsx @@ -0,0 +1,28 @@ +import type { CSSProperties, ReactNode } from 'react' + +interface CardProps { + children: ReactNode + accent?: string + style?: CSSProperties +} + +export function Card({ children, accent, style }: CardProps) { + return ( +
+ {children} +
+ ) +} diff --git a/demo/src/components/atoms/Chip.tsx b/demo/src/components/atoms/Chip.tsx new file mode 100644 index 0000000..dadb144 --- /dev/null +++ b/demo/src/components/atoms/Chip.tsx @@ -0,0 +1,44 @@ +import type { CSSProperties, ReactNode } from 'react' + +type Tone = 'default' | 'plain' | 'cipher' | 'key' | 'ink' | 'ok' | 'warn' + +const toneStyles: Record = { + default: {}, + plain: { background: 'var(--plain-soft)', color: 'oklch(0.35 0.1 25)', borderColor: 'transparent' }, + cipher: { background: 'var(--cipher-soft)', color: 'oklch(0.32 0.08 210)', borderColor: 'transparent' }, + key: { background: 'var(--key-soft)', color: 'oklch(0.32 0.08 290)', borderColor: 'transparent' }, + ink: { background: 'var(--ink)', color: 'var(--bg)', borderColor: 'transparent' }, + ok: { background: 'oklch(0.92 0.06 150)', color: 'oklch(0.28 0.08 150)', borderColor: 'transparent' }, + warn: { background: 'oklch(0.92 0.06 70)', color: 'oklch(0.38 0.1 70)', borderColor: 'transparent' }, +} + +interface ChipProps { + children: ReactNode + tone?: Tone + style?: CSSProperties +} + +export function Chip({ children, tone = 'default', style }: ChipProps) { + return ( + + {children} + + ) +} diff --git a/demo/src/components/atoms/Fingerprint.tsx b/demo/src/components/atoms/Fingerprint.tsx new file mode 100644 index 0000000..23cb972 --- /dev/null +++ b/demo/src/components/atoms/Fingerprint.tsx @@ -0,0 +1,35 @@ +import { useMemo } from 'react' +import { deriveGrid } from '@/lib/fingerprint' + +interface FingerprintProps { + bytes: Uint8Array + size?: number + accent?: string + active?: boolean +} + +export function Fingerprint({ bytes, size = 64, accent = 'var(--cipher)', active = true }: FingerprintProps) { + const cells = useMemo(() => deriveGrid(bytes), [bytes]) + const cell = size / 8 + return ( + + + {cells.map((b, i) => { + const x = (i % 8) * cell + const y = Math.floor(i / 8) * cell + const alpha = (b / 255) * (active ? 1 : 0.4) + return ( + + ) + })} + + ) +} diff --git a/demo/src/components/atoms/NoiseMeter.tsx b/demo/src/components/atoms/NoiseMeter.tsx new file mode 100644 index 0000000..f9b2cf4 --- /dev/null +++ b/demo/src/components/atoms/NoiseMeter.tsx @@ -0,0 +1,62 @@ +import { deriveNoise } from '@/lib/fingerprint' + +interface NoiseMeterProps { + bytes: Uint8Array + opCount?: number + label?: string +} + +export function NoiseMeter({ bytes, opCount = 0, label = 'noise' }: NoiseMeterProps) { + const noise = deriveNoise(bytes, opCount) + const pct = Math.min(1, Math.max(0, noise)) + const danger = pct > 0.4 + const color = danger ? 'var(--warn)' : 'var(--ok)' + return ( +
+
+ {label} + + {(pct * 100).toFixed(1)}%{' '} + / 50% margin + +
+
+
+
+
+
+ ) +} diff --git a/demo/src/components/atoms/NumberPad.tsx b/demo/src/components/atoms/NumberPad.tsx new file mode 100644 index 0000000..6eb9ada --- /dev/null +++ b/demo/src/components/atoms/NumberPad.tsx @@ -0,0 +1,63 @@ +'use client' + +interface NumberPadProps { + value: number + onChange: (n: number) => void + accent?: string + label?: string + disabled?: boolean +} + +export function NumberPad({ value, onChange, accent = 'var(--plain)', label, disabled }: NumberPadProps) { + return ( +
+ {label && ( +
+ {label} +
+ )} +
+ {Array.from({ length: 16 }, (_, i) => i).map((n) => { + const selected = n === value + return ( + + ) + })} +
+
+ ) +} diff --git a/demo/src/components/atoms/StepHeader.tsx b/demo/src/components/atoms/StepHeader.tsx new file mode 100644 index 0000000..d504100 --- /dev/null +++ b/demo/src/components/atoms/StepHeader.tsx @@ -0,0 +1,51 @@ +interface StepHeaderProps { + n: string | number + title: string + subtitle?: string + accent?: string + done?: boolean +} + +export function StepHeader({ n, title, subtitle, accent = 'var(--ink)', done }: StepHeaderProps) { + return ( +
+
+ {done ? '✓' : n} +
+
+

+ {title} +

+ {subtitle && ( +
{subtitle}
+ )} +
+
+ ) +} diff --git a/demo/src/components/atoms/StripePlaceholder.tsx b/demo/src/components/atoms/StripePlaceholder.tsx new file mode 100644 index 0000000..4baef75 --- /dev/null +++ b/demo/src/components/atoms/StripePlaceholder.tsx @@ -0,0 +1,28 @@ +interface StripePlaceholderProps { + label: string + height?: number +} + +export function StripePlaceholder({ label, height = 80 }: StripePlaceholderProps) { + return ( +
+ {label} +
+ ) +} diff --git a/demo/src/components/mascot/Poulpy.tsx b/demo/src/components/mascot/Poulpy.tsx new file mode 100644 index 0000000..652f3ae --- /dev/null +++ b/demo/src/components/mascot/Poulpy.tsx @@ -0,0 +1,93 @@ +import type { CSSProperties } from 'react' +import type { MascotMood } from '@/lib/fhe-types' + +interface PouplypProps { + size?: number + mood?: MascotMood + tint?: string +} + +export function Poulpy({ size = 120, mood = 'idle', tint = 'var(--plain)' }: PouplypProps) { + const eyeShift = mood === 'thinking' ? -1.2 : 0 + const eyeY = mood === 'happy' ? 38 : 40 + const eyeScaleY = mood === 'happy' ? 0.25 : 1 + const mouth = + mood === 'happy' + ? 'M 46 54 Q 60 62 74 54' + : mood === 'thinking' + ? 'M 52 56 Q 60 56 68 56' + : mood === 'locked' + ? 'M 52 56 L 68 56' + : 'M 52 56 Q 60 58 68 56' + + const tentacles: Array<{ d: string; delay: number }> = [ + { d: 'M 22 60 Q 10 76 14 96 Q 20 108 12 116', delay: 0 }, + { d: 'M 32 70 Q 24 92 32 108 Q 38 116 32 122', delay: 0.2 }, + { d: 'M 48 76 Q 46 100 52 116 Q 56 122 50 128', delay: 0.4 }, + { d: 'M 72 76 Q 74 100 68 116 Q 64 122 70 128', delay: 0.3 }, + { d: 'M 88 70 Q 96 92 88 108 Q 82 116 88 122', delay: 0.1 }, + { d: 'M 98 60 Q 110 76 106 96 Q 100 108 108 116', delay: 0.5 }, + ] + + return ( + + + + {tentacles.map((t, i) => ( + + ))} + + + + + + + + {mood !== 'happy' && ( + + + + + )} + + + + {mood === 'locked' && ( + + + + + + )} + + ) +} diff --git a/demo/src/components/steps/Step1Keygen.tsx b/demo/src/components/steps/Step1Keygen.tsx new file mode 100644 index 0000000..20b0666 --- /dev/null +++ b/demo/src/components/steps/Step1Keygen.tsx @@ -0,0 +1,149 @@ +import { Card } from '@/components/atoms/Card' +import { Chip } from '@/components/atoms/Chip' +import { Btn } from '@/components/atoms/Btn' +import { ByteViewer } from '@/components/atoms/ByteViewer' +import { StripePlaceholder } from '@/components/atoms/StripePlaceholder' +import { StepHeader } from '@/components/atoms/StepHeader' +import { formatBytes } from '@/lib/pack' +import type { Phase } from '@/lib/fhe-types' + +interface Step1KeygenProps { + skPreview: Uint8Array | null + ekBytes: Uint8Array | null + phase: Phase + onGenerate: () => void +} + +function KeyIcon({ color, outline }: { color: string; outline?: boolean }) { + return ( + + + + + ) +} + +export function Step1Keygen({ skPreview, ekBytes, phase, onGenerate }: Step1KeygenProps) { + const busy = phase !== 'idle' + const done = !!skPreview && !!ekBytes + + return ( + + + + {/* Shared-seed callout */} +
+ 🔑 + + Both sides share a fixed 96-byte seed committed to this repo. The server derives and + pre-loads EK at deploy time from that same seed — so no EK upload ever + happens (saving ~16 MiB per session). + +
+ +
+ {/* Secret key card */} +
+
+ + Secret key + sk +
+
+ Stays in this browser. Used only for decryption. +
+ {skPreview ? ( +
+ +
+ seed preview · LWE n=630 +
+
+ ) : ( + + )} +
+ + {/* Evaluation key card */} +
+
+ + Evaluation key + ek + {ekBytes && ( + + pre-loaded on server + + )} +
+
+ Enables homomorphic ops. Already on the server — never uploaded. +
+ {ekBytes ? ( +
+ +
+ {formatBytes(ekBytes.byteLength)} · bootstrapping key +
+
+ ) : ( + + )} +
+
+ +
+ + {phase === 'keygen' ? 'Deriving…' : done ? 'Re-derive keys' : 'Derive keys'} + + + CGGI over the torus · σ ≈ 2−15 · plaintext space: 32 bits + +
+
+ ) +} diff --git a/demo/src/components/steps/Step2Encrypt.tsx b/demo/src/components/steps/Step2Encrypt.tsx new file mode 100644 index 0000000..6fc6d1c --- /dev/null +++ b/demo/src/components/steps/Step2Encrypt.tsx @@ -0,0 +1,151 @@ +import { Card } from '@/components/atoms/Card' +import { Chip } from '@/components/atoms/Chip' +import { Btn } from '@/components/atoms/Btn' +import { Fingerprint } from '@/components/atoms/Fingerprint' +import { NoiseMeter } from '@/components/atoms/NoiseMeter' +import { NumberPad } from '@/components/atoms/NumberPad' +import { StripePlaceholder } from '@/components/atoms/StripePlaceholder' +import { StepHeader } from '@/components/atoms/StepHeader' +import { formatBytes } from '@/lib/pack' +import type { Phase } from '@/lib/fhe-types' + +interface Step2EncryptProps { + hasKeys: boolean + a: number + b: number + ctA: Uint8Array | null + ctB: Uint8Array | null + phase: Phase + onSelectA: (n: number) => void + onSelectB: (n: number) => void + onEncrypt: () => void +} + +function EncryptSlot({ + label, + value, + setValue, + ct, + disabled, +}: { + label: string + value: number + setValue: (n: number) => void + ct: Uint8Array | null + disabled: boolean +}) { + return ( +
+ + +
+
+ {value} +
+
+ → Enc → +
+
+ {ct ? ( +
+ +
+ + ct_{label.split(' ')[1]} + +
+ {formatBytes(ct.byteLength)} +
+
+
+ ) : ( + + )} +
+
+ {ct && ( +
+ +
+ )} +
+ ) +} + +export function Step2Encrypt({ + hasKeys, + a, + b, + ctA, + ctB, + phase, + onSelectA, + onSelectB, + onEncrypt, +}: Step2EncryptProps) { + const busy = phase !== 'idle' + const done = !!ctA && !!ctB + + return ( + + + +
+ + +
+ +
+ + {phase === 'encrypting' ? 'Encrypting…' : done ? 'Re-encrypt' : 'Encrypt a & b'} + + {!hasKeys && ( + ↑ Generate keys first + )} +
+
+ ) +} diff --git a/demo/src/components/steps/Step3Evaluate.tsx b/demo/src/components/steps/Step3Evaluate.tsx new file mode 100644 index 0000000..53e9d31 --- /dev/null +++ b/demo/src/components/steps/Step3Evaluate.tsx @@ -0,0 +1,314 @@ +import { Card } from '@/components/atoms/Card' +import { Chip } from '@/components/atoms/Chip' +import { Btn } from '@/components/atoms/Btn' +import { Fingerprint } from '@/components/atoms/Fingerprint' +import { ByteViewer } from '@/components/atoms/ByteViewer' +import { NoiseMeter } from '@/components/atoms/NoiseMeter' +import { StepHeader } from '@/components/atoms/StepHeader' +import { formatBytes } from '@/lib/pack' +import type { Phase } from '@/lib/fhe-types' + +interface Step3EvaluateProps { + ctA: Uint8Array | null + ctB: Uint8Array | null + ctSum: Uint8Array | null + phase: Phase + onEvaluate: () => void +} + +function PayloadRow({ + icon, + label, + value, + tone, +}: { + icon: React.ReactNode + label: string + value: string + tone: 'key' | 'cipher' +}) { + return ( +
+
+ {icon} +
+ + {label} + +
+
+ {value} +
+
+ ) +} + +export function Step3Evaluate({ ctA, ctB, ctSum, phase, onEvaluate }: Step3EvaluateProps) { + const busy = phase !== 'idle' + const ready = !!ctA && !!ctB + const sending = phase === 'sending' || phase === 'evaluating' + const done = !!ctSum + + return ( + + + + {/* Transit diagram */} +
+ {/* Outbound payload */} +
+
+ outbound payload +
+
+ : null} + label="ctₐ" + value={ctA ? formatBytes(ctA.byteLength) : '—'} + tone="cipher" + /> + : null} + label="ct_b" + value={ctB ? formatBytes(ctB.byteLength) : '—'} + tone="cipher" + /> +
+ {/* EK note */} +
+ + + + + ek pre-loaded on server · not sent +
+
+ + {/* Transit arrow + parcel animation */} +
+
+ + + + + {sending && ( +
+
+
+ )} +
+
+ https +
+
+ + {/* Server compute box */} +
+
+ server computes +
+
+ out ←{' '} + ctₐ ⊞ ct_b +
+
+ {ctSum ? 'done · ' + formatBytes(ctSum.byteLength) : 'awaiting input…'} +
+
+
+ + {/* Result ciphertext */} +
+
+
+ {ctSum ? ( + + ) : ( +
+ )} +
+
+
+ + Encrypted result + + {ctSum && ( + + ct_sum + + )} +
+ {ctSum ? ( + <> + +
+ +
+ + ) : ( +
+ The server will return a fresh ciphertext encoding the sum. +
+ )} +
+
+
+ +
+ + {phase === 'sending' || phase === 'evaluating' + ? 'Computing on server…' + : done + ? 'Run again' + : 'Send & evaluate'} + + {!ready && !done && ( + ↑ Encrypt inputs first + )} +
+ + ) +} diff --git a/demo/src/components/steps/Step4Decrypt.tsx b/demo/src/components/steps/Step4Decrypt.tsx new file mode 100644 index 0000000..f92dc97 --- /dev/null +++ b/demo/src/components/steps/Step4Decrypt.tsx @@ -0,0 +1,130 @@ +import { Card } from '@/components/atoms/Card' +import { Chip } from '@/components/atoms/Chip' +import { Btn } from '@/components/atoms/Btn' +import { Fingerprint } from '@/components/atoms/Fingerprint' +import { StepHeader } from '@/components/atoms/StepHeader' +import type { Phase } from '@/lib/fhe-types' + +interface Step4DecryptProps { + ctSum: Uint8Array | null + result: number | null + phase: Phase + a: number + b: number + onDecrypt: () => void +} + +export function Step4Decrypt({ ctSum, result, phase, a, b, onDecrypt }: Step4DecryptProps) { + const busy = phase !== 'idle' + const ready = !!ctSum + const done = result !== null + const expected = a + b + + return ( + + + +
+ {/* The encrypted result */} +
+ {ctSum ? ( + + ) : ( +
+ )} +
+ + ct_sum + +
+ {ctSum ? `${ctSum.byteLength} B` : '—'} +
+
+
+ + {/* Dec arrow */} +
+ ⟶ Dec( + + + + + sk) ⟶ +
+ + {/* Big result number */} +
+ {done ? result : '·'} +
+ + {/* Explanation */} +
+ {done ? ( + <> +
+ {a} + {b} = {result} +
+
+ Matches the plaintext sum ({expected}) — the server never saw either operand. +
+ + ) : ( + 'The server returned ct_sum. Press below to recover the plaintext locally.' + )} +
+
+ +
+ + {phase === 'decrypting' ? 'Decrypting…' : done ? 'Decrypt again' : 'Decrypt result'} + +
+ + ) +} diff --git a/demo/src/components/terminal/ServerTerminal.tsx b/demo/src/components/terminal/ServerTerminal.tsx new file mode 100644 index 0000000..7d240c0 --- /dev/null +++ b/demo/src/components/terminal/ServerTerminal.tsx @@ -0,0 +1,160 @@ +'use client' + +import { useEffect, useRef } from 'react' +import type { LogEntry, LogKind } from '@/lib/fhe-types' + +interface ServerTerminalProps { + logs: LogEntry[] + running: boolean +} + +const colorFor: Record = { + info: 'var(--term-ink)', + dim: 'var(--term-dim)', + ok: 'oklch(0.78 0.14 150)', + warn: 'oklch(0.82 0.14 70)', + err: 'oklch(0.7 0.18 25)', + in: 'oklch(0.78 0.12 210)', + out: 'oklch(0.78 0.12 290)', +} + +const prefixFor: Record = { + info: '·', + dim: ' ', + ok: '✓', + warn: '!', + err: '✗', + in: '←', + out: '→', +} + +function Caret() { + return ( + + ) +} + +function TermLine({ line }: { line: LogEntry }) { + return ( +
+ [{line.ts}] + {prefixFor[line.kind]} + {line.text} +
+ ) +} + +export function ServerTerminal({ logs, running }: ServerTerminalProps) { + const scrollerRef = useRef(null) + + useEffect(() => { + const el = scrollerRef.current + if (el) el.scrollTop = el.scrollHeight + }, [logs]) + + return ( +
+ {/* Terminal chrome */} +
+
+
+
+
+
+
+ server ~ poulpy-eval · cggi +
+
+ + {running ? 'busy' : 'idle'} +
+
+ + {/* Log area */} +
+ {logs.length === 0 && ( +
+ $ ./poulpy-server --scheme cggi --listen :3001 +
+ waiting for client… + +
+ )} + {logs.map((l) => ( + + ))} + {running && ( +
+ +
+ )} +
+
+ ) +} diff --git a/demo/src/hooks/useFhe.ts b/demo/src/hooks/useFhe.ts new file mode 100644 index 0000000..adeb959 --- /dev/null +++ b/demo/src/hooks/useFhe.ts @@ -0,0 +1,218 @@ +'use client' + +import { useCallback, useEffect, useReducer, useRef } from 'react' +import type { FheState, LogEntry, LogKind, Phase } from '@/lib/fhe-types' +import { DEMO_KEY_SEED } from '@/lib/demo-seed' +import { formatBytes, packCiphertexts } from '@/lib/pack' + +const API_BASE = '/api' +const PARAMS_SET = + (process.env.NEXT_PUBLIC_POULPY_PARAMS_SET as string | undefined) ?? 'test' +const OCTET = 'application/octet-stream' + +// --------------------------------------------------------------------------- +// Reducer +// --------------------------------------------------------------------------- + +type Action = + | { type: 'SET_PHASE'; phase: Phase } + | { type: 'KEYGEN_DONE'; skPreview: Uint8Array; ekBytes: Uint8Array } + | { type: 'ENCRYPT_DONE'; ctA: Uint8Array; ctB: Uint8Array } + | { type: 'EVALUATE_DONE'; ctSum: Uint8Array } + | { type: 'DECRYPT_DONE'; result: number } + | { type: 'SET_A'; a: number } + | { type: 'SET_B'; b: number } + | { type: 'LOG'; entry: LogEntry } + | { type: 'ERROR'; message: string } + | { type: 'RESET' } + +const initial: FheState = { + phase: 'idle', + skPreview: null, + ekBytes: null, + a: 7, + b: 5, + ctA: null, + ctB: null, + ctSum: null, + result: null, + logs: [], + error: null, +} + +function reducer(state: FheState, action: Action): FheState { + switch (action.type) { + case 'SET_PHASE': + return { ...state, phase: action.phase, error: null } + case 'KEYGEN_DONE': + return { + ...state, + phase: 'idle', + skPreview: action.skPreview, + ekBytes: action.ekBytes, + ctA: null, + ctB: null, + ctSum: null, + result: null, + error: null, + } + case 'ENCRYPT_DONE': + return { ...state, phase: 'idle', ctA: action.ctA, ctB: action.ctB, ctSum: null, result: null, error: null } + case 'EVALUATE_DONE': + return { ...state, phase: 'idle', ctSum: action.ctSum, result: null, error: null } + case 'DECRYPT_DONE': + return { ...state, phase: 'idle', result: action.result, error: null } + case 'SET_A': + return { ...state, a: action.a, ctA: null, ctB: null, ctSum: null, result: null } + case 'SET_B': + return { ...state, b: action.b, ctA: null, ctB: null, ctSum: null, result: null } + case 'LOG': + return { ...state, logs: [...state.logs, action.entry] } + case 'ERROR': + return { ...state, phase: 'idle', error: action.message } + case 'RESET': + return { ...initial, logs: [] } + default: + return state + } +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useFhe() { + const [state, dispatch] = useReducer(reducer, initial) + const clientRef = useRef(null) + const logIdRef = useRef(0) + const startRef = useRef(Date.now()) + + const log = useCallback((kind: LogKind, text: string) => { + const elapsed = (Date.now() - startRef.current) / 1000 + dispatch({ + type: 'LOG', + entry: { id: logIdRef.current++, ts: elapsed.toFixed(2) + 's', kind, text }, + }) + }, []) + + const doKeygen = useCallback(async () => { + dispatch({ type: 'SET_PHASE', phase: 'keygen' }) + startRef.current = Date.now() + logIdRef.current = 0 + log('info', 'client: building CGGI session from demo seed (deterministic)…') + log('dim', ` scheme=CGGI paramsSet=${PARAMS_SET}`) + try { + const { PoulpyClient } = await import('squid-js/client') + const client = await PoulpyClient.create({ paramsSet: PARAMS_SET, seeds: DEMO_KEY_SEED }) + clientRef.current = client + + const seeds = await client.exportSeeds() + const skPreview = seeds.slice(0, 48) + const ekBytes = client.evaluationKey + + log('ok', `client: sk ready (${formatBytes(seeds.byteLength)}) — stays local`) + log('info', 'client: EK matches server (same seed; server loads demo-ek at deploy)') + log('dim', 'server: stateless /api/add, waiting for ciphertexts…') + + dispatch({ type: 'KEYGEN_DONE', skPreview, ekBytes }) + } catch (err) { + log('err', `client: keygen failed — ${(err as Error).message}`) + dispatch({ type: 'ERROR', message: (err as Error).message }) + } + }, [log]) + + const doEncrypt = useCallback( + async (a: number, b: number) => { + const client = clientRef.current + if (!client) return + dispatch({ type: 'SET_PHASE', phase: 'encrypting' }) + log('info', `client: encrypting a=${a}`) + try { + const ctA = await client.encryptU32(a) + log('ok', `client: ctₐ ready (${formatBytes(ctA.byteLength)})`) + log('info', `client: encrypting b=${b}`) + const ctB = await client.encryptU32(b) + log('ok', `client: ct_b ready (${formatBytes(ctB.byteLength)})`) + dispatch({ type: 'ENCRYPT_DONE', ctA, ctB }) + } catch (err) { + log('err', `client: encrypt failed — ${(err as Error).message}`) + dispatch({ type: 'ERROR', message: (err as Error).message }) + } + }, + [log], + ) + + const doEvaluate = useCallback( + async (ctA: Uint8Array, ctB: Uint8Array) => { + dispatch({ type: 'SET_PHASE', phase: 'sending' }) + const payloadSize = formatBytes(ctA.byteLength + ctB.byteLength) + log('info', `client → server: POST /api/add (${payloadSize})`) + try { + const body = packCiphertexts(ctA, ctB) + dispatch({ type: 'SET_PHASE', phase: 'evaluating' }) + log('in', 'server: received ciphertexts, computing ctₐ ⊞ ct_b') + const resp = await fetch(`${API_BASE}/add`, { + method: 'POST', + headers: { 'Content-Type': OCTET }, + body: body as BodyInit, + }) + if (!resp.ok) { + const body2 = (await resp.json().catch(() => ({}))) as { error?: string } + throw new Error(body2.error ?? `/add failed (${resp.status})`) + } + const ctSum = new Uint8Array(await resp.arrayBuffer()) + log('ok', `server: ct_sum ready (${formatBytes(ctSum.byteLength)})`) + log('out', 'server → client: 200 OK') + dispatch({ type: 'EVALUATE_DONE', ctSum }) + } catch (err) { + log('err', `evaluate failed — ${(err as Error).message}`) + dispatch({ type: 'ERROR', message: (err as Error).message }) + } + }, + [log], + ) + + const doDecrypt = useCallback( + async (ctSum: Uint8Array) => { + const client = clientRef.current + if (!client) return + dispatch({ type: 'SET_PHASE', phase: 'decrypting' }) + log('info', 'client: Dec(sk, ct_sum)…') + try { + const result = await client.decryptU32(ctSum) + log('ok', `client: plaintext = ${result}`) + dispatch({ type: 'DECRYPT_DONE', result }) + } catch (err) { + log('err', `client: decrypt failed — ${(err as Error).message}`) + dispatch({ type: 'ERROR', message: (err as Error).message }) + } + }, + [log], + ) + + const doReset = useCallback(() => { + clientRef.current = null + dispatch({ type: 'RESET' }) + }, []) + + const setA = useCallback((a: number) => dispatch({ type: 'SET_A', a }), []) + const setB = useCallback((b: number) => dispatch({ type: 'SET_B', b }), []) + + // Cleanup worker on unmount + useEffect(() => { + return () => { + clientRef.current = null + } + }, []) + + return { + state, + doKeygen, + doEncrypt, + doEvaluate, + doDecrypt, + doReset, + setA, + setB, + } +} diff --git a/demo/src/lib/demo-seed.ts b/demo/src/lib/demo-seed.ts new file mode 100644 index 0000000..9feaa85 --- /dev/null +++ b/demo/src/lib/demo-seed.ts @@ -0,0 +1,8 @@ +/** 96-byte keygen seeds — must match `scripts/generate-demo-ek.mjs` and `data/demo-ek.test.bin`. */ +export const DEMO_KEY_SEED = ((() => { + const s = new Uint8Array(96) + for (let i = 0; i < 96; i++) { + s[i] = (i * 13 + 1) & 0xff + } + return s +})()) diff --git a/demo/src/lib/fhe-types.ts b/demo/src/lib/fhe-types.ts new file mode 100644 index 0000000..141be4d --- /dev/null +++ b/demo/src/lib/fhe-types.ts @@ -0,0 +1,37 @@ +export type Phase = + | 'idle' + | 'keygen' + | 'encrypting' + | 'sending' + | 'evaluating' + | 'decrypting' + +export type MascotMood = 'idle' | 'thinking' | 'happy' | 'locked' + +export type LogKind = 'info' | 'dim' | 'ok' | 'warn' | 'err' | 'in' | 'out' + +export interface LogEntry { + id: number + ts: string + kind: LogKind + text: string +} + +export interface FheState { + phase: Phase + // Step 1 — Keygen + skPreview: Uint8Array | null + ekBytes: Uint8Array | null + // Step 2 — Encrypt + a: number + b: number + ctA: Uint8Array | null + ctB: Uint8Array | null + // Step 3 — Evaluate + ctSum: Uint8Array | null + // Step 4 — Decrypt + result: number | null + // Terminal + logs: LogEntry[] + error: string | null +} diff --git a/demo/src/lib/fingerprint.ts b/demo/src/lib/fingerprint.ts new file mode 100644 index 0000000..39726a5 --- /dev/null +++ b/demo/src/lib/fingerprint.ts @@ -0,0 +1,37 @@ +/** + * Returns 64 byte values (0–255) deterministically from ciphertext bytes. + * Used to render the 8×8 fingerprint grid. + */ +export function deriveGrid(bytes: Uint8Array): number[] { + const out: number[] = [] + for (let i = 0; i < 64; i++) { + out.push(bytes[(i * 7 + 3) % bytes.length]) + } + return out +} + +/** + * Returns a simulated noise fraction (0–1) from ciphertext bytes. + * Fresh ciphertexts land at ~6–12%. After homomorphic add: ~14–22%. + * opCount=0 → fresh, opCount=1 → after one server add. + */ +export function deriveNoise(bytes: Uint8Array, opCount = 0): number { + let h = 0 + for (let i = 0; i < Math.min(bytes.length, 16); i++) { + h = ((h * 31) ^ bytes[i]) >>> 0 + } + const base = opCount === 0 ? 0.05 : 0.13 + const spread = 0.07 + const frac = (h % 1000) / 1000 + return Math.min(0.48, base + frac * spread) +} + +/** Returns the first maxBytes bytes as a hex string, e.g. "0a 3f c1 …" */ +export function bytesToHexPreview(bytes: Uint8Array, maxBytes = 24): string { + const len = Math.min(bytes.length, maxBytes) + const parts: string[] = [] + for (let i = 0; i < len; i++) { + parts.push(bytes[i].toString(16).padStart(2, '0')) + } + return parts.join(' ') + (bytes.length > maxBytes ? ' …' : '') +} diff --git a/demo/src/lib/load-demo-evaluator.ts b/demo/src/lib/load-demo-evaluator.ts new file mode 100644 index 0000000..6f86265 --- /dev/null +++ b/demo/src/lib/load-demo-evaluator.ts @@ -0,0 +1,21 @@ +import { readFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { Evaluator } from 'squid-js/server' + +const PARAMS_SET = process.env.POULPY_PARAMS_SET ?? 'test' + +const ekPath = + process.env.POULPY_DEMO_EK_PATH ?? join(process.cwd(), 'data', 'demo-ek.test.bin') + +let cached: Evaluator | null = null + +export function getDemoEvaluator(): Evaluator { + if (cached) return cached + if (!existsSync(ekPath)) { + throw new Error( + `Missing demo evaluation key at ${ekPath}. Run: pnpm --filter @squid-js/demo run gen-demo-ek`, + ) + } + cached = Evaluator.load(readFileSync(ekPath), PARAMS_SET) + return cached +} diff --git a/demo/src/lib/pack.ts b/demo/src/lib/pack.ts new file mode 100644 index 0000000..8c53a35 --- /dev/null +++ b/demo/src/lib/pack.ts @@ -0,0 +1,14 @@ +/** Pack two ciphertexts as u32-le(a.length) || a || b. */ +export function packCiphertexts(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(4 + a.length + b.length) + new DataView(out.buffer).setUint32(0, a.length, true) + out.set(a, 4) + out.set(b, 4 + a.length) + return out +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB` + return `${(n / (1024 * 1024)).toFixed(2)} MiB` +} diff --git a/demo/tsconfig.json b/demo/tsconfig.json new file mode 100644 index 0000000..b6bf469 --- /dev/null +++ b/demo/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/demo/vercel.json b/demo/vercel.json new file mode 100644 index 0000000..4f3bb9b --- /dev/null +++ b/demo/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "installCommand": "pnpm install --no-link-workspace-packages --no-lockfile" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..177f4e4 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "squid-monorepo", + "version": "0.1.0", + "private": true, + "description": "Squid FHE monorepo: Rust wrapper, wasm bindings, Node bindings, and a client/server demo.", + "scripts": { + "build:squid-js": "pnpm --filter squid-js build", + "build:demo": "pnpm --filter @squid-js/demo build", + "build": "pnpm run build:squid-js && pnpm run build:demo", + "dev": "pnpm --filter @squid-js/demo dev", + "test": "pnpm --filter @squid-js/e2e test", + "clean": "rimraf target packages/squid-js/wasm packages/squid-js/napi packages/squid-js/dist demo/.next" + }, + "devDependencies": { + "concurrently": "^9.0.1", + "rimraf": "^6.0.1", + "typescript": "^5.6.3" + }, + "packageManager": "pnpm@9.12.0", + "engines": { + "node": ">=20" + } +} diff --git a/packages/squid-js/README.md b/packages/squid-js/README.md new file mode 100644 index 0000000..9008d0a --- /dev/null +++ b/packages/squid-js/README.md @@ -0,0 +1,44 @@ +# Squid JS + +JavaScript bindings for [Squid](https://github.com/cedoor/squid) (ergonomic Rust wrapper for [Poulpy](https://github.com/poulpy-fhe/poulpy) FHE): a **browser** client (WebAssembly in a dedicated worker so keygen and crypto stay off the UI thread) and a **Node** evaluator (napi-rs). The client holds the secret key; the server only receives the evaluation key and ciphertexts. + +## Install + +```sh +npm install squid-js +``` + +Use `pnpm add squid-js` or `yarn add squid-js` if you prefer. The server entry point ships a native addon; install on the platform you run Node on. + +## Usage + +**Browser** — import `squid-js/client`: + +```ts +import { PoulpyClient } from "squid-js/client"; + +const client = await PoulpyClient.create({ paramsSet: "test" }); // or "unsecure" — must match the server +const ct = await client.encryptU32(42); +// Send `client.evaluationKey` and `ct` to the server; decrypt results with `await client.decryptU32(...)`. +``` + +**Node** — import `squid-js/server` (native addon; not for bundlers targeting the browser): + +```ts +import { Evaluator } from "squid-js/server"; + +const ev = Evaluator.load(evaluationKeyBytes, "test"); +const sum = ev.addU32(ctA, ctB); +``` + +The `./wasm/*` export serves the built `.wasm` assets for hosting or custom `init()` URLs. Call `init()` only if you use the wasm `Session` on the main thread (no `Worker`); otherwise wasm loads inside the worker when you call `PoulpyClient.create()`. + +## Build + +Requires Rust (`wasm-pack`), and for the server target, a normal napi build environment. + +```sh +pnpm run build +``` + +This runs `build:wasm`, `build:napi`, and `build:ts` in order (emitting `dist/squid-worker.js` next to `dist/client.js`). Node **≥ 20** is required. diff --git a/packages/squid-js/build-linux-napi.Dockerfile b/packages/squid-js/build-linux-napi.Dockerfile new file mode 100644 index 0000000..3e4f076 --- /dev/null +++ b/packages/squid-js/build-linux-napi.Dockerfile @@ -0,0 +1,43 @@ +# Linux-only glibc napi build for `squid-js` (e.g. `squid.linux-x64-gnu.node` for Vercel / linux/amd64). +# Darwin: run `pnpm run build` in this package on your Mac; this image cannot produce macOS .node files. +# +# Build context: monorepo root (so Cargo.toml, crates/, packages/, pnpm-lock.yaml are available). +# On Apple Silicon, force amd64 to match a typical Vercel Node (x86_64 glibc) runtime, from monorepo root: +# docker build --platform=linux/amd64 -f packages/squid-js/build-linux-napi.Dockerfile -t squid-linux:local . +# Baked build + copy napi/ into this package (runs docker build, then copies napi/ out; see scripts/build-linux-napi.sh): +# pnpm --filter squid-js run build:squid:linux +# (from packages/squid-js: pnpm run build:squid:linux) +# +# Mount workflow (from monorepo root; rewrites the tree under /squid): +# docker run --rm -v "$PWD":/squid -w /squid squid-linux:local \ +# bash -c 'pnpm install --no-frozen-lockfile && pnpm run build:squid-js' +# +# The image is built on Debian bookworm, Node 20, Rust nightly (see /squid/rust-toolchain.toml for wasm), wasm-pack, pnpm@9.12.0. +# Use `docker build --platform=linux/amd64` (do not hardcode platform in FROM; BuildKit warns on constant FromPlatform). + +FROM node:20-bookworm + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Rust (nightly + wasm32 per rust-toolchain.toml; native .node target matches the build platform, e.g. linux/amd64 when using --platform=linux/amd64) +ENV PATH="/root/.cargo/bin:${PATH}" +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +RUN rustup default nightly && rustup target add wasm32-unknown-unknown + +# wasm-pack (uses cargo from the active nightly toolchain) +RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate + +WORKDIR /squid +COPY . . + +# --no-frozen-lockfile: after version bumps, package.json can change before pnpm-lock.yaml; frozen would fail the image build. +RUN pnpm install --no-frozen-lockfile && pnpm run build:squid-js diff --git a/packages/squid-js/package.json b/packages/squid-js/package.json new file mode 100644 index 0000000..63109c0 --- /dev/null +++ b/packages/squid-js/package.json @@ -0,0 +1,47 @@ +{ + "name": "squid-js", + "version": "0.1.2", + "description": "Browser FHE client and Node evaluator over Squid. `squid-js/client` uses wasm; `squid-js/server` uses a napi-rs native addon.", + "type": "module", + "exports": { + "./client": { + "types": "./dist/client.d.ts", + "import": "./dist/client.js" + }, + "./server": { + "browser": "./dist/server-browser-stub.js", + "types": "./dist/server.d.ts", + "import": "./dist/server.js" + }, + "./wasm/*": "./wasm/*" + }, + "files": [ + "dist", + "wasm", + "napi", + "src" + ], + "napi": { + "name": "squid", + "triples": { + "defaults": true + } + }, + "scripts": { + "build:wasm": "wasm-pack build ../../crates/squid-wasm --target web --out-dir ../../packages/squid-js/wasm --out-name squid_wasm && node -e \"try { require('fs').unlinkSync('wasm/.gitignore'); } catch (e) { if (e.code !== 'ENOENT') throw e; }\"", + "build:napi": "napi build --cargo-cwd ../../crates/squid-napi --platform --release --js index.js --dts index.d.ts napi && node -e \"require('fs').writeFileSync('napi/package.json', '{\\\"type\\\":\\\"commonjs\\\"}\\n')\"", + "build:ts": "tsc -p tsconfig.json", + "build": "pnpm run build:wasm && pnpm run build:napi && pnpm run build:ts", + "build:squid:linux": "bash scripts/build-linux-napi.sh", + "prepublishOnly": "pnpm run build && pnpm run build:squid:linux", + "dev": "tsc -p tsconfig.json --watch" + }, + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "@types/node": "^22.7.5", + "typescript": "^5.6.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/squid-js/scripts/build-linux-napi.sh b/packages/squid-js/scripts/build-linux-napi.sh new file mode 100644 index 0000000..31f52e5 --- /dev/null +++ b/packages/squid-js/scripts/build-linux-napi.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Build the Linux Docker image, then copy napi/ from that image into this package (replaces napi/ contents). +# Usage: packages/squid-js/scripts/build-linux-napi.sh [image-tag] default: squid-linux:local +set -euo pipefail +PKG_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +REPO_ROOT="$(cd "$PKG_ROOT/../.." && pwd)" +IMG="${1:-squid-linux:local}" + +echo "docker build: $REPO_ROOT -> $IMG" +docker build --platform=linux/amd64 -f "$PKG_ROOT/build-linux-napi.Dockerfile" -t "$IMG" "$REPO_ROOT" + +echo "docker cp: $IMG napi/ -> $PKG_ROOT/napi/" +cd "$PKG_ROOT" +mkdir -p napi +cid=$(docker create "$IMG") +docker cp "$cid":/squid/packages/squid-js/napi/. napi/ +docker rm "$cid" >/dev/null +echo "Done." diff --git a/packages/squid-js/src/client.ts b/packages/squid-js/src/client.ts new file mode 100644 index 0000000..4b56bf4 --- /dev/null +++ b/packages/squid-js/src/client.ts @@ -0,0 +1,132 @@ +import initWasm, { Session } from "../wasm/squid_wasm.js"; + +let initPromise: Promise | null = null; + +type Pending = Map void; reject: (e: Error) => void }>; + +function rpc( + worker: Worker, + pending: Pending, + id: number, + msg: object, + transfer?: Transferable[], +): Promise { + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + worker.postMessage({ ...msg, id }, transfer ?? []); + }); +} + +/** Main-thread wasm init; no-op when a Worker is used (wasm loads in the worker). */ +export function init(wasmUrl?: string | URL | Request): Promise { + if (typeof Worker !== "undefined") return Promise.resolve(); + return (initPromise ??= initWasm(wasmUrl ? { module_or_path: wasmUrl } : undefined).then(() => {})); +} + +export interface CreateOptions { + seeds?: Uint8Array; + paramsSet: string; + wasmUrl?: string | URL | Request; +} + +/** Browser: wasm + `Session` in a worker so crypto does not block the UI. No Worker: main thread after {@link init}. */ +export class PoulpyClient { + readonly evaluationKey: Uint8Array; + private readonly session: Session | null; + private readonly worker: Worker | null; + private nextId = 1; + private readonly pending: Pending; + + private constructor(ek: Uint8Array, session: Session | null, worker: Worker | null, pending: Pending) { + this.evaluationKey = ek; + this.session = session; + this.worker = worker; + this.pending = pending; + } + + static async create(opts: CreateOptions): Promise { + if (typeof Worker !== "undefined") return PoulpyClient.#workerCreate(opts); + await init(opts.wasmUrl); + const { paramsSet } = opts; + const session = opts.seeds ? Session.fromSeeds(opts.seeds, paramsSet) : Session.newRandom(paramsSet); + return new PoulpyClient(session.evaluationKeyBytes(), session, null, new Map()); + } + + static async #workerCreate(opts: CreateOptions): Promise { + const worker = new Worker(new URL("./squid-worker.js", import.meta.url), { type: "module" }); + const pending: Pending = new Map(); + const fail = (e: Error) => { + pending.forEach((p) => p.reject(e)); + pending.clear(); + }; + worker.onmessage = (ev) => { + const d = ev.data as { id: number; ok: boolean; error?: string }; + const p = pending.get(d.id); + if (!p) return; + pending.delete(d.id); + d.ok === false ? p.reject(new Error(d.error ?? "worker error")) : p.resolve(ev.data); + }; + worker.onmessageerror = () => fail(new Error("squid-js: worker message error")); + worker.onerror = (e) => fail(new Error(e.message)); + + const seeds = opts.seeds?.slice().buffer; + let res: { evaluationKey?: ArrayBuffer }; + try { + res = (await rpc( + worker, + pending, + 0, + { + kind: "create", + paramsSet: opts.paramsSet, + seeds, + wasmUrl: opts.wasmUrl != null ? String(opts.wasmUrl) : undefined, + }, + seeds ? [seeds] : [], + )) as { evaluationKey?: ArrayBuffer }; + } catch (e) { + worker.terminate(); + throw e; + } + const ek = res.evaluationKey; + if (!ek) { + worker.terminate(); + throw new Error("squid-js: worker create missing evaluationKey"); + } + return new PoulpyClient(new Uint8Array(ek), null, worker, pending); + } + + #rpc(msg: object, transfer?: Transferable[]): Promise { + const w = this.worker; + if (!w) return Promise.reject(new Error("squid-js: no worker")); + return rpc(w, this.pending, this.nextId++, msg, transfer) as Promise; + } + + encryptU32(value: number): Promise { + if (!Number.isInteger(value) || value < 0 || value > 0xff_ff_ff_ff) { + return Promise.reject(new RangeError(`encryptU32: ${value} is out of u32 range`)); + } + const s = this.session; + if (s) return Promise.resolve(s.encryptU32(value)); + return this.#rpc<{ ciphertext: ArrayBuffer }>({ kind: "encrypt", value }).then( + (r) => new Uint8Array(r.ciphertext), + ); + } + + decryptU32(ciphertext: Uint8Array): Promise { + const s = this.session; + if (s) return Promise.resolve(s.decryptU32(ciphertext)); + const ct = ciphertext.slice(); + return this.#rpc<{ value: number }>({ kind: "decrypt", ciphertext: ct.buffer }, [ct.buffer]).then( + (r) => r.value, + ); + } + + exportSeeds(): Promise { + const s = this.session; + if (s) return Promise.resolve(s.seeds()); + return this.#rpc<{ seeds: ArrayBuffer }>({ kind: "exportSeeds" }).then((r) => new Uint8Array(r.seeds)); + } +} + +export { Session }; diff --git a/packages/squid-js/src/server-browser-stub.ts b/packages/squid-js/src/server-browser-stub.ts new file mode 100644 index 0000000..057a8cd --- /dev/null +++ b/packages/squid-js/src/server-browser-stub.ts @@ -0,0 +1,4 @@ +throw new Error( + "squid-js/server is the Node-only entry point (napi native addon); import squid-js/client in the browser.", +); +export {}; diff --git a/packages/squid-js/src/server.ts b/packages/squid-js/src/server.ts new file mode 100644 index 0000000..ee6aeb0 --- /dev/null +++ b/packages/squid-js/src/server.ts @@ -0,0 +1,8 @@ +import { createRequire } from "node:module"; +import type { Evaluator as EvaluatorClass } from "../napi/index.js"; + +const require = createRequire(import.meta.url); +const addon = require("../napi/index.js") as { Evaluator: typeof EvaluatorClass }; + +export const Evaluator = addon.Evaluator; +export type Evaluator = EvaluatorClass; diff --git a/packages/squid-js/src/squid-worker.ts b/packages/squid-js/src/squid-worker.ts new file mode 100644 index 0000000..a6c1937 --- /dev/null +++ b/packages/squid-js/src/squid-worker.ts @@ -0,0 +1,54 @@ +import initWasm, { Session } from "../wasm/squid_wasm.js"; + +const send = (msg: object, transfer?: Transferable[]) => + (self as unknown as { postMessage: (m: object, t?: Transferable[]) => void }).postMessage( + msg, + transfer, + ); + +let wasmReady = false; +let session: Session | null = null; + +async function ensureWasm(url?: string) { + if (!wasmReady) { + await initWasm(url ? { module_or_path: url } : undefined); + wasmReady = true; + } +} + +self.onmessage = async (ev: MessageEvent>) => { + const d = ev.data; + const id = d.id as number; + const kind = d.kind as string; + try { + if (kind === "create") { + await ensureWasm(d.wasmUrl as string | undefined); + session?.free(); + session = d.seeds + ? Session.fromSeeds(new Uint8Array(d.seeds as ArrayBuffer), d.paramsSet as string) + : Session.newRandom(d.paramsSet as string); + const u = new Uint8Array(session.evaluationKeyBytes()); + send({ id, ok: true, evaluationKey: u.buffer }, [u.buffer]); + return; + } + if (!session) throw new Error("squid-worker: no session"); + if (kind === "encrypt") { + const u = new Uint8Array(session.encryptU32(d.value as number)); + send({ id, ok: true, ciphertext: u.buffer }, [u.buffer]); + } else if (kind === "decrypt") { + const v = session.decryptU32(new Uint8Array(d.ciphertext as ArrayBuffer)); + send({ id, ok: true, value: v }); + } else if (kind === "exportSeeds") { + const u = new Uint8Array(session.seeds()); + send({ id, ok: true, seeds: u.buffer }, [u.buffer]); + } else { + throw new Error(`squid-worker: unknown kind ${kind}`); + } + } catch (err) { + send({ + id, + ok: false, + error: err instanceof Error ? err.message : String(err), + }); + } +}; diff --git a/packages/squid-js/tsconfig.json b/packages/squid-js/tsconfig.json new file mode 100644 index 0000000..9e1fef2 --- /dev/null +++ b/packages/squid-js/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "allowJs": false, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "wasm", "napi", "node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..329c6a4 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,910 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + concurrently: + specifier: ^9.0.1 + version: 9.2.1 + rimraf: + specifier: ^6.0.1 + version: 6.1.3 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + + demo: + dependencies: + next: + specifier: ^15.3.1 + version: 15.5.15(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: + specifier: ^19.0.0 + version: 19.2.5 + react-dom: + specifier: ^19.0.0 + version: 19.2.5(react@19.2.5) + squid-js: + specifier: workspace:* + version: link:../packages/squid-js + devDependencies: + '@types/node': + specifier: ^22 + version: 22.19.17 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + + packages/squid-js: + devDependencies: + '@napi-rs/cli': + specifier: ^2.18.4 + version: 2.18.4 + '@types/node': + specifier: ^22.7.5 + version: 22.19.17 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + + tests: + devDependencies: + '@playwright/test': + specifier: ^1.48.0 + version: 1.59.1 + '@types/node': + specifier: ^22.7.5 + version: 22.19.17 + +packages: + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@napi-rs/cli@2.18.4': + resolution: {integrity: sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==} + engines: {node: '>= 10'} + hasBin: true + + '@next/env@15.5.15': + resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==} + + '@next/swc-darwin-arm64@15.5.15': + resolution: {integrity: sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.15': + resolution: {integrity: sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.15': + resolution: {integrity: sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.5.15': + resolution: {integrity: sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.5.15': + resolution: {integrity: sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.5.15': + resolution: {integrity: sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.5.15': + resolution: {integrity: sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.15': + resolution: {integrity: sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + caniuse-lite@1.0.30001788: + resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} + engines: {node: '>=18'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + engines: {node: 20 || >=22} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@15.5.15: + resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + +snapshots: + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@napi-rs/cli@2.18.4': {} + + '@next/env@15.5.15': {} + + '@next/swc-darwin-arm64@15.5.15': + optional: true + + '@next/swc-darwin-x64@15.5.15': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.15': + optional: true + + '@next/swc-linux-arm64-musl@15.5.15': + optional: true + + '@next/swc-linux-x64-gnu@15.5.15': + optional: true + + '@next/swc-linux-x64-musl@15.5.15': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.15': + optional: true + + '@next/swc-win32-x64-msvc@15.5.15': + optional: true + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + balanced-match@4.0.4: {} + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + caniuse-lite@1.0.30001788: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concurrently@9.2.1: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.8.3 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + csstype@3.2.3: {} + + detect-libc@2.1.2: + optional: true + + emoji-regex@8.0.0: {} + + escalade@3.2.0: {} + + fsevents@2.3.2: + optional: true + + get-caller-file@2.0.5: {} + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + has-flag@4.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + lru-cache@11.3.5: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minipass@7.1.3: {} + + nanoid@3.3.11: {} + + next@15.5.15(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@next/env': 15.5.15 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001788 + postcss: 8.4.31 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + styled-jsx: 5.1.6(react@19.2.5) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.15 + '@next/swc-darwin-x64': 15.5.15 + '@next/swc-linux-arm64-gnu': 15.5.15 + '@next/swc-linux-arm64-musl': 15.5.15 + '@next/swc-linux-x64-gnu': 15.5.15 + '@next/swc-linux-x64-musl': 15.5.15 + '@next/swc-win32-arm64-msvc': 15.5.15 + '@next/swc-win32-x64-msvc': 15.5.15 + '@playwright/test': 1.59.1 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + package-json-from-dist@1.0.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.5 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + + react@19.2.5: {} + + require-directory@2.1.1: {} + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + scheduler@0.27.0: {} + + semver@7.7.4: + optional: true + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shell-quote@1.8.3: {} + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + styled-jsx@5.1.6(react@19.2.5): + dependencies: + client-only: 0.0.1 + react: 19.2.5 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5c0355a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - "packages/*" + - "demo" + - "tests" diff --git a/tests/index.spec.ts b/tests/index.spec.ts new file mode 100644 index 0000000..61dd427 --- /dev/null +++ b/tests/index.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "@playwright/test"; + +// Keygen runs in a wasm worker; duration depends on the chosen `paramsSet` +// (demo default `test`). Allow extra time for cold CI / slow runners. +const KEYGEN_TIMEOUT_MS = 30_000; +const COMPUTE_TIMEOUT_MS = 15_000; + +test.setTimeout(KEYGEN_TIMEOUT_MS + COMPUTE_TIMEOUT_MS + 30_000); + +test("browser keygen → server FHE add → browser decrypt", async ({ page }) => { + page.on("pageerror", (err) => console.log(`[e2e] pageerror: ${err.message}`)); + page.on("requestfailed", (req) => + console.log( + `[e2e] requestfailed: ${req.method()} ${req.url()} — ${req.failure()?.errorText}`, + ), + ); + + await page.goto("/"); + + // Step 1 — derive keys from demo seed (WASM keygen in worker, may take a while) + await page.getByRole("button", { name: /Derive keys/i }).click(); + await expect( + page.getByRole("button", { name: /Encrypt a & b/i }), + ).toBeEnabled({ timeout: KEYGEN_TIMEOUT_MS }); + + // Step 2 — encrypt (defaults: a=7, b=5) + await page.getByRole("button", { name: /Encrypt a & b/i }).click(); + await expect( + page.getByRole("button", { name: /Send & evaluate/i }), + ).toBeEnabled({ timeout: 10_000 }); + + // Step 3 — send to server and evaluate homomorphically + await page.getByRole("button", { name: /Send & evaluate/i }).click(); + await expect( + page.getByRole("button", { name: /Decrypt result/i }), + ).toBeEnabled({ timeout: COMPUTE_TIMEOUT_MS }); + + // Step 4 — decrypt result locally; 7 + 5 = 12 + await page.getByRole("button", { name: /Decrypt result/i }).click(); + await expect(page.getByTestId("decrypt-result")).toHaveText("12", { + timeout: 10_000, + }); +}); diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..4a900c9 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,15 @@ +{ + "name": "@squid-js/e2e", + "version": "0.1.0", + "private": true, + "description": "End-to-end tests for the squid-js client/server demo.", + "type": "module", + "scripts": { + "pretest": "playwright install chromium", + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "^22.7.5" + } +} diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts new file mode 100644 index 0000000..365b853 --- /dev/null +++ b/tests/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "@playwright/test"; + +// The root `pnpm dev` runs the Next.js demo; reuseExistingServer lets a +// developer keep it open and just run `pnpm test` on the side. +export default defineConfig({ + testDir: ".", + timeout: 120_000, + fullyParallel: false, + workers: 1, + use: { + baseURL: "http://localhost:3000", + }, + webServer: { + command: "pnpm -w dev", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..4f0a331 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["playwright.config.ts", "index.spec.ts"] +}