413d0b16227c861c1b1c24fa6d119013e0a0e3eb58ab573397995076b3b2aa68
# Security notes — `polygenic_score_aggregate`Scoped to this bundle. The platform-wide threat model lives in`docs/manifesto.md`, `docs/requirements.md`, and `docs/simulation_mode.md` §5.Kerckhoffs applied to a product: **no guarantee rests on the secrecy — or thehonesty — of the server.** Don't trust, verify.## Trust classes (what may cross the wire)| class | example artifact | may leave the owner's machine? ||-------|------------------|-------------------------------|| RAW | `raw.json` genotypes | **no** || ENCODED | `encoded.json` dosage vector | **no** || PRIVATE | `secret_context.tenseal` (secret key), `plain.json` | **no, ever** || ENCRYPTED | `cipher.bin`, `result.bin` | yes || PUBLIC | `public_context.tenseal`, the effect weights (`manifest.yml`) | yes |Only ENCRYPTED and PUBLIC are ever uploaded. `00_keygen.py` writes the secret keyto `secret_context.tenseal`, which is used **only** by `40_decrypt.py` on theresearcher's machine. There is no `/api/v1` endpoint that accepts a secret key.The effect weights are **already public** (published in the manifest, folded intothe digest) — they are not a secret and are applied in the clear.## Server holds no secret key`30_compute_encrypted.py` — the only server-side stage, a kit shim that runs`server.py`'s `compute` — loads the **public** context plus ciphertexts,homomorphically adds them, and applies the publiceffect weights as a **plaintext-scalar multiply**. It defensively refuses acontext that carries a secret key (`context.is_private()` → error). The servertherefore never sees a single plaintext genotype; it operates on ciphertext (andpublic plaintext weights) and returns ciphertext. Decryption happens only wherethe secret key lives: locally.## Public weights → additive tier, no ciphertext × ciphertextThe statistic is a weighted sum `Σ_j w_j·g_j`, but the weights are **public**, sothe multiply is **ciphertext × plaintext**, not ciphertext × ciphertext. Thatdistinction is load-bearing:- A plaintext multiply does not raise ciphertext degree, so **no relinearization keys** are generated or shipped in the public context.- Every op is element-wise; there is no cross-slot rotation, so **no Galois keys**. The cohort reduction `Σ_j` is computed **post-decrypt in the CLI**, never under encryption.If the weights were themselves private (a different, out-of-scope protocol), themultiply would be ciphertext × ciphertext, would need relin keys, and would moveto the multiplication-supporting tier (protocols 5–6). They are not.## The append-1 sentinel is NOT a MACThe trailing sentinel slot decrypts to the exact contributor count N, anddropping one upload yields N−1 (test: `test_sentinel_tracks_dropped_upload`). Theone subtlety this protocol adds: the server weights that slot by **1** (unscaled),so it survives the plaintext-weight multiply intact and still reads N. It catches**mechanical corruption / miscounting** — it gives **no** guarantee thatcontributions are distinct, genuine, or non-Sybil. Call it what it is: anintegrity check, not authenticity.## What FHE here does and does not hide- **Hides:** individual genotype vectors from the server (inputs are ciphertext).- **Does not hide:** the released public-weighted aggregate itself, the **effect weights** (public by construction), and metadata (researcher identity, participant count/timing, ciphertext sizes, protocol choice).- **Differencing (K vs K+1):** the released *statistic* leaks an individual if you can compute `A_{K+1} − A_K` — and because the weights are public and invertible (each `w_scaled[j] ≥ 1`), the per-coordinate weighted difference divides back to the target's exact dosage. `aggregate_only` + `min_contributors ≥ 20` + `allowed_runs_per_project: 1` (cohort freeze + min-N + run cap) **mitigate** this; they are not a complete defense. Overlapping/Sybil differencing across separately frozen cohorts needs DP + cross-job query budgets (v2). Documented, not hand-waved — see `docs/simulation_mode.md` §5.- **Verify-by-re-execution is determinism, not zero-knowledge.** Re-running `30_compute_encrypted.py` on the same ciphertexts reproduces a bit-identical result digest; it proves the computation, it is not a ZK proof.## Exactness / parameter safetyBFV is exact in `Z_t`. The plaintext modulus must satisfy `t > max slot value`,which **after the public-weight multiply** is `max_j(w_scaled[j]) · 2·N` (dosage≤ 2). The flagship's 20-bit `t = 1032193` is **under-sized** for weighted sums;this bundle uses the 30-bit batching prime `t = 1073692673` (≡ 1 mod 16384),exact for the published envelope `S = 1000`, `w_scaled ≤ 2000`, N up to ~250k. Arun outside that `(S, weight range, N)` envelope must raise `t` (or thesimulation feasibility sweep reports `infeasible-at-these-params` on overflow).The sentinel sum is N, always ≪ t. The released real values are exact to thefixed-point resolution `1/S` of the published scale.
Packaged support file for application digest 413d0b16227c…b3b2aa68. It ships in the archive for review, but is outside the signed payload digest.