Plan — HEaaS from 4.0/10 (SOTA 3/10) to ≥ 9/10¶
Context¶
The audit (docs/project/codebase_audit.md, pushed on ccr-ea1e4a6d-o48ujy) found:
- The core promise is false. The server generates and holds the TFHE client key (backend/src/crypto/engine.rs:291). The key pool rotates over unrelated key pairs, and keys are regenerated on every boot.
- The UI is dead. The meta CSP blocks SvelteKit's inline bootstrap (verified in Chromium), vite dev returns HTTP 500, and the OAuth/verify/reset fragments are never consumed.
- Security gaps: spoofable rate-limit IP, OAuth email-verification gaps, tokens in localStorage, raw bincode + unchecked_* ops on untrusted ciphertexts.
- Hygiene: no CI, 14 RustSec and 48 npm advisories, no observability, an in-memory job runner.
Decisions taken: client-held keys (browser WASM + Rust SDK), keep SQLite, replace the sandbox with the real browser flow.
Target: every audit dimension ≥ 9 and no CRITICAL/HIGH findings left. The work is delivered as phased commits on ccr-ea1e4a6d-o48ujy, each phase green in CI before the next starts.
Phase 0: Guard rails (do first)¶
- CI in
.github/workflows/ci.yml: - backend job:
cargo fmt --check,cargo clippy --all-targets -D warnings,cargo test,cargo deny check(advisories, licenses, bans; newbackend/deny.toml) - frontend job:
pnpm install --frozen-lockfile,svelte-check --fail-on-warnings,vitest,pnpm build, Playwright e2e againstvite previewwith a mocked API - Docker build job;
hadolint - Add
.github/dependabot.ymlcovering cargo, npm, docker and actions. - Dependency refresh:
cargo updateclears the transitive RustSec items (rustls, webpki, quinn-proto, h2, crossbeam-epoch, tokio ≥ 1.53.3).- Bump
tfheto 1.8.x with featuresinteger, zk-pok. First confirm withcargo docthat the 1.8 names are stillCompressedServerKey,safe_serialization::{safe_serialize, safe_deserialize_conformant},FheUint16andProvenCompactCiphertextList. Verified present in 1.3.3; must be re-checked on 1.8. - Remove
bincode. - Standardise on rustls: sqlx
runtime-tokio-rustls, lettretokio1-rustls-tls. Trim sqlx features so the unusedsqlx-mysql/rsatree drops out, or document it in adeny.tomlexception with justification. - Frontend: drop axios for
fetch. Bump to@sveltejs/kitlatest 2.x,vite-plugin-svelte5/6, vite 6/7 (fixes the dev-server 500 and the npm advisories). Add@types/node,vitest,@playwright/test.
Phase 1: Cryptographic core (CRITICAL)¶
New module layout under backend/src/crypto/:
- params.rs: a single source of truth (TFHE high-level ConfigBuilder::default() = TUniform 2^-128). It is exported to clients via GET /api/v1/he/params, which returns parameter id, tfhe version, max serialized sizes and supported ops.
- keys.rs: per-user key sets.
- POST /api/v1/keys (multipart or octet-stream; body limit about 64 MB, configurable) accepts a client-generated CompressedServerKey.
- The key is checked with safe_deserialize_conformant against the config's conformance params and a size limit.
- It is stored at ${KEY_STORE_DIR}/{user_id}/{key_id}.bin (written atomically: tmp, fsync, rename). The DB row is in a new table server_keys(id, user_id, sha256, size, created_at, revoked_at).
- Also: GET /keys, DELETE /keys/{id}, at most N keys per user.
- Decompressed ServerKeys are held in a bounded LRU (moka), keyed by key_id and capped by memory weight.
- eval.rs: the evaluator, which replaces engine.rs.
- Deserialize inputs with safe_deserialize_conformant (limit ~1 MB per ciphertext; accept CompactCiphertextList/FheUint16).
- Run tfhe::set_server_key inside the worker thread for the job's key.
- Ops: a typed enum Op { Add, Sub, Mul, Min, Max, Eq, Lt, Sum(Vec) } using carry-propagating default high-level operators (no unchecked_*).
- Serialize the result with safe_serialize.
- Fail on a block-count or parameter mismatch with a typed error.
- Delete HeContextPool, server-side ClientKey, /compute/sandbox and sandbox_compute_optimized.
- Tests (the core of the 9/10 score):
- Property tests (proptest, small case count, #[ignore]-gated heavy variants run in a nightly CI job): client keygen → encrypt → server eval with the uploaded compressed key → client decrypt equals the plaintext op mod 2^16, for every Op.
- Negative tests: wrong-key ciphertext, truncated bytes, oversize, wrong params all return 4xx and never panic.
Phase 2: Persistent, bounded job system (HIGH)¶
- New migration:
jobsgainskey_id,attempts,started_at,finished_atand an index(status, created_at).- Inputs and results move out of the DB into the blob store
${DATA_DIR}/jobs/{id}/{in0,in1,out}; the DB keeps hashes and sizes. jobs/worker.rs:- N workers (
HE_WORKERS, default = cores) claim jobs atomically withUPDATE jobs SET status='processing', started_at=… WHERE id = (SELECT id FROM jobs WHERE status='pending' ORDER BY created_at LIMIT 1) RETURNING …. - CPU work runs on a dedicated rayon pool behind a
Semaphore. - Per-job timeout; retry with
attempts < 3. - At startup, stale
processingjobs are requeued. - Workers drain on SIGTERM via an actix
ServerHandleplus aCancellationToken. - API (v1):
POST /api/v1/jobstakes{key_id, op, inputs: [b64…]}as JSON or multipart. It returns202+Location, honours theIdempotency-Keyheader, and returns 503 +Retry-Afterwhen the queue is full.GET /api/v1/jobs?cursor=&limit=returns a summary DTO (no blobs, nouser_id).GET /api/v1/jobs/{id}returns status.GET /api/v1/jobs/{id}/resultreturns the ciphertext as octet-stream.DELETE /api/v1/jobs/{id}.- Quota: charged only on
POST /jobs, keeping the existing atomicINSERT … ON CONFLICT … RETURNINGfrommiddleware/quota.rs. A DB error returns 503, not 429.
Phase 3: Auth & web security (HIGH/MEDIUM)¶
- Rate limiting:
- Replace
middleware/rate_limit.rswith thegovernorcrate, keyed onpeer_addr(). ATRUSTED_PROXIESCIDR list enables the rightmost-untrustedX-Forwarded-Forhop. - Add a per-email login limiter, stricter limits for email-sending routes, and
Retry-After/RateLimit-*headers. - Compose: the backend is no longer published on
0.0.0.0; only nginx exposes ports. - Sessions:
- The refresh token moves to an
HttpOnly; Secure; SameSite=Strict; Path=/api/v1/authcookie, and the access token stays in memory on the frontend. - Refresh rotation runs in a transaction with
family_id; reuse of a rotated token revokes the family (migration addsfamily_id,rotated_at). - CSRF: SameSite=Strict, plus an
Origincheck middleware for cookie-authenticated POSTs. - JWT:
- Claims gain
iss,aud,typ,iat,jti; validation pins the alg,audandiss. - The OAuth state uses a separate HMAC key derived via HKDF from
JWT_SECRET. - At boot,
JWT_SECRETmust be at least 32 bytes. - OAuth:
- Random state + PKCE verifier stored in a 10-minute
HttpOnlycookie and compared on callback. - Google: OIDC
id_tokenchecked foremail_verified == true(or v3 userinfoemail_verified). - GitHub: always use
/user/emailswithprimary && verified. - Callbacks issue a one-time login code (DB row, 60 s TTL, hashed) in the redirect. The frontend POSTs it to
/auth/exchange, so tokens no longer appear in URLs. - One shared
reqwest::Clientwith timeouts, and the provider HTTP status is checked. - Verify / reset:
- The email links land on frontend pages, which POST the token. Verify no longer runs on GET, so link scanners can't consume it.
- The reset token is consumed atomically with
UPDATE … RETURNING. - Remove the legacy plaintext-token matching.
- Mail URLs are logged only in debug builds.
- Account management: account delete and password reset run in transactions. OAuth-only accounts require recent auth (
auth_time≤ 5 min) to delete. - Headers: actix
DefaultHeaders(nosniff,frame-ancestors 'none', Referrer-Policy, COOP/CORP), with CORS restricted to explicit methods and headers. nginx gets a TLS server block with HSTS and dropsX-XSS-Protection.
Phase 4: Frontend rebuild (CRITICAL/HIGH)¶
svelte.config.js:adapter-node,paths.base: '/heaas',kit.csp: { mode: 'hash', directives: {script-src:['self','wasm-unsafe-eval'], …} }. Remove the meta CSP fromapp.htmland self-host the fonts.$lib/api.ts: a typedfetchclient withcredentials: 'include', an in-memory access token, single-flight refresh, and typedApiError {code,message}.$lib/fhe/: a lazy-loadedtfheWASM worker (npmtfhe, version matched to the backend crate).- Keygen, encrypt and decrypt run in a Web Worker so the UI never blocks.
- The client key is stored in IndexedDB, encrypted with AES-GCM using a key from PBKDF2/Argon2 (WebCrypto) over a user passphrase.
- Export/import of the key file.
- The compressed server key is uploaded to
POST /keys. - Pages:
/login: consumes#code=→/auth/exchange,#error=. Adds OAuth buttons, forgot password, and resend verification./verifyand/reset: landing pages that POST the token./keys: generate/import/export/revoke keys./dashboard: encrypt locally → submit job → poll → decrypt locally. Shows only ciphertext sizes and the honest scheme label ("TFHE (CGGI), FheUint16")./jobs: paginated history./account: existing page, kept.- Migrate to Svelte 5 runes. Share one
$lib/password-policy.ts(removes the 3× duplication). - Tests: vitest for the api client and policy; Playwright e2e for register → verify → keygen → add job → decrypt correct, CSP-violation listener = 0, and a11y with
@axe-core/playwright.
Phase 5: Code structure & API quality (MEDIUM)¶
- Split
api/auth.rs(1,413 lines) intoauth/{password,session,email,oauth/{mod,google,github}}.rs. - One
Mailer(built at startup,web::Data) with templated bodies (removes the duplication atauth.rs:139-264). const USER_COLUMNSor aUserRepo.#[derive(FromRow)]structs replace tuple rows (fixes clippytype_complexityatuser.rs:54).- Error envelope
{error:{code,message,details?}}plus aJsonConfig/QueryConfigerror handler that returns JSON 400/422/413. - Typed
Configbuilt withenvyor a validated struct: every limit (rate limits, payload sizes, TTLs, quota, workers) becomes configurable, and the server fails fast on invalid values. Remove the personal Tailscale URL default. - Routes are versioned under
/api/v1. An OpenAPI spec is generated withutoipaand served at/api/v1/openapi.json; CI checks it has not drifted. - Rust SDK/CLI crate:
sdk/(workspace memberheaas-sdk+heaasCLI) with keygen, upload-key, encrypt, submit, wait, decrypt. It reusescrypto/params.rs, which moves to a sharedheaas-corecrate so the SDK and server can never disagree on parameters. The workspace rootCargo.tomllistsbackend,coreandsdk.
Phase 6: Observability & lifecycle (MEDIUM)¶
tracing+tracing-subscriber(JSON) +tracing-actix-web(request id, propagated asX-Request-Id); replaceenv_logger/log.- Prometheus
/metrics(actix-web-prom + custom counters/histograms for job latency, queue depth, HE op duration, key-cache hits), protected by a bearer token or an internal bind. /api/healthfor liveness;/api/health/readychecks a DB ping, write access to the key store, and worker liveness.- Graceful shutdown: stop accepting work, drain workers (timeout), close the pool.
- Docker:
- Multi-stage
cargo-chefbuilder;debian:bookworm-slimor distroless runtime; non-root user;HEALTHCHECK;.dockerignore; pinned base tags. - Frontend
Dockerfile(adapter-node, non-root). - Compose with
service_healthy,read_only,cap_drop: [ALL],no-new-privileges, and named volumes for the DB/keys/blobs. backup.shalso backs up the key/blob store.
Phase 7: Cleanup & honest docs (LOW but blocks 9/10)¶
- Delete
backend/src/db/schema.sqlandfoldertree.txt; merge.Jules/into.jules/. - Rewrite
README.md(architecture diagram, threat model: what the server can and cannot learn, parameter sheet generated fromparams.rs, quick start, SDK usage) andCustomer_guide.md. Remove SEAL/BFV, "n=4096, t=1024", billing and audit claims. - Add
SECURITY.md,CONTRIBUTING.md,CHANGELOG.md. - Re-run the codebase-auditor and append the new row to
docs/project/.audit/results_log.md.
Critical files¶
- Replace or remove:
backend/src/crypto/engine.rs,backend/src/api/compute.rs,backend/src/middleware/rate_limit.rs,frontend/src/app.html,frontend/src/lib/api.ts - Major edits:
backend/src/main.rs,backend/src/api/auth.rs(split),backend/src/api/user.rs,backend/src/config.rs,backend/src/errors.rs,backend/src/middleware/{jwt,quota}.rs,backend/Cargo.toml,frontend/svelte.config.js,frontend/package.json, allfrontend/src/routes/**,Dockerfile,docker-compose.yml,nginx-heaas.conf,README.md,Customer_guide.md - New:
backend/migrations/2026102500000{1..4}_*.sql(server_keys, jobs v2, refresh families, login codes),backend/src/crypto/{params,keys,eval}.rs,backend/src/jobs/worker.rs,core/,sdk/,frontend/src/lib/fhe/,.github/workflows/ci.yml,backend/deny.toml - Reuse:
hash_token(auth.rs:17)- atomic quota SQL (
middleware/quota.rs:443) - the OAuth takeover guard and its test (
auth.rs:853-873, 1263) is_valid_email/is_strong_password(api/validation.rs)- the
spawn_blockingpattern for bcrypt
Verification¶
- Every phase:
cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test && cargo deny check, pluspnpm svelte-check && pnpm test && pnpm build && pnpm exec playwright test. CI must be green before the push. - Crypto correctness: an end-to-end Rust integration test using
actix_web::testwith an in-memory SQLite and temp dirs. - Flow: SDK keygen → upload compressed key → submit job per Op → worker → fetch result → decrypt equals expected.
- Negative cases: wrong key, malformed, oversize and foreign-params inputs give 4xx and the process never panics.
- Restart test: kill the server mid-job, restart, and the job completes.
- Security regression tests:
- Spoofed
X-Forwarded-Fordoesn't bypass the limiter. - Google
email_verified=falseis rejected (provider responses mocked withwiremock). - Refresh reuse revokes the family.
- The OAuth state cookie mismatch is rejected.
- Verify via GET has no side effect.
list_jobscontains no blobs and nouser_id.- Polling doesn't consume quota.
- Browser:
- Playwright against
docker compose up: zero CSP violations, the full register → keygen → encrypt → job → decrypt flow, and OAuth with mocked providers. - Check that the network tab never carries the client key (assert no request body contains the serialized client-key prefix).
- Supply chain:
cargo audit/cargo denyandpnpm audit --prodreturn 0 high/critical. - Final audit: re-run the codebase-auditor skill. Pass criteria: every dimension ≥ 9, no CRITICAL/HIGH findings, the report and log updated, and a SOTA re-score ≥ 9.
Delivery¶
One commit (or a small series) per phase, pushed to ccr-ea1e4a6d-o48ujy. No PR unless asked. The TFHE heavy property tests run as a nightly/workflow_dispatch job, because keygen takes 10–60 s per key.