The Blind Machine

Genotype-Phenotype Covariance

1.0.0 Ed25519 signed

Application digest — content address of the signed/ payload

df37c9b8139fda1c3a1e95b5e64f65b6f072dbc22b2545b33516d79908e296de

114 lines · 6.78 KB · sha256:49f7e2b5485e…59cfcd23

Security notes — genotype_phenotype_covariance

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 the
honesty — 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 (genotype + phenotype) no
ENCODED encoded.json (g, broadcast y) no
PRIVATE secret_context.tenseal (secret key), plain.json no, ever
ENCRYPTED cipher.bin (packed (g,y) pair), result.bin yes
PUBLIC public_context.tenseal (relin keys, no secret key) yes

Only ENCRYPTED and PUBLIC are ever uploaded. 00_keygen.py writes the secret key
to secret_context.tenseal, used only by 40_decrypt.py on the researcher’s
machine. There is no /api/v1 endpoint that accepts a secret key.

Server holds no secret key — and it computes an encrypted PRODUCT

30_compute_encrypted.py — the only server-side stage, a kit shim that runs
server.py’s compute — loads the public context (which carries
relinearization keys but no secret key) plus the
paired genotype/phenotype ciphertexts, and homomorphically forms
Σ_i enc(g_i)·enc(y_i) and Σ_i enc(y_i)² (depth-1 ciphertext × ciphertext,
relinearized) alongside the additive Σ enc(g_i) and Σ enc(y_i). It defensively
refuses a context that carries a secret key (context.is_private() → error). The
relin keys let the server multiply and relinearize ciphertexts; they do not
let it decrypt. The server therefore never sees a single plaintext genotype or
phenotype — it operates on ciphertext and returns ciphertext. Decryption happens
only where the secret key lives: locally.

No Galois keys. The phenotype is broadcast across all slots at encode time, so
every product is element-wise and no cross-slot rotation is ever performed. The
public context ships no rotation (Galois) keys — the server cannot permute
slots even if it wanted to.

The append-1 sentinel is NOT a MAC

Every contribution appends a trailing 1 to both the genotype and phenotype
vectors, so all four decrypted moments’ last slot recovers the exact contributor
count N (sum_g/sum_y: Σ1=N; sum_gy/sum_y2: Σ 1·1 = N), and 50_decode.py
cross-checks that all four sentinels agree — a stronger corruption check than
the single-sentinel additive flagship. Dropping one upload yields N−1 (test:
test_sentinel_tracks_dropped_upload). It catches mechanical corruption /
miscounting / a dropped contribution
— it gives no guarantee that
contributions are distinct, genuine, or non-Sybil. Call it what it is: an integrity
check, not authenticity.

Pairing integrity

A contributor’s genotype and phenotype are co-packed into ONE ciphertext blob at
encrypt time
— a BMCT1 container holding that owner’s (cipher_g, cipher_y)
pair (20_encrypt.py), which 30_compute_encrypted.py unpacks back to the pair
before the fold. Pairing is therefore enforced structurally, not by input
ordering: because there is exactly one blob per contributor, the hosted worker’s
Stager (which digest-sorts every staged ciphertext,
worker/lib/blind_worker/stager.rb) can only permute whole contributors, never
separate a (g, y) pair. The moment folds are order-independent across
contributors, so any staged order yields the identical result (pinned by
test_result_is_order_independent_under_digest_sort).

This closes a real bug in the earlier “two separate ciphertexts, interleaved
(g_0, y_0, g_1, y_1, …)” design: the Stager’s digest-sort reordered those
independent g/y blobs into an arbitrary permutation, so the server paired
genotype-with-genotype and phenotype-with-phenotype by digest parity — and the
append-1 sentinel did not catch it (every blob carries a trailing 1, so all
four moments still reconciled to N on corrupt output). Co-packing removes the whole
failure mode.

The only residual mis-pairing is a dishonest owner who packs g_a with y_b
in their own blob — the pre-existing honest-encoding assumption. As with all
inputs, correctness of the released statistic assumes contributors encode
honestly against the published definition; the platform’s cohort-freeze + min-N +
run-cap governance bounds differencing, not per-contributor honesty.

What FHE here does and does not hide

  • Hides: individual genotype vectors AND phenotype values from the server
    (both inputs are ciphertext; the product is derived under encryption).
  • Does not hide: the released aggregate moments/covariance themselves, and
    metadata (researcher identity, participant count/timing, ciphertext sizes,
    protocol choice, the phenotype coding scheme — which is public).
  • Differencing (K vs K+1): the statistic leaks an individual if you can
    compute A_{K+1} − A_K. aggregate_only + min_contributors ≥ 30 +
    allowed_runs_per_project: 1 (cohort freeze + min-N + run cap) mitigate
    this; they are not a complete defense. With a covariance run an attacker who can
    difference two cohorts recovers g_target · y_target per variant AND
    y_target (from sum_y), so the min-N floor is raised to 30 for this protocol.
    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 (BFV add and relinearized multiply are deterministic; the container
    order is fixed); it proves the computation, it is not a ZK proof.

Exactness / parameter safety

BFV is exact in Z_t. The plaintext modulus must exceed the largest moment value.
For a binary (case/control) phenotype the max is sum_gy ≤ 2·N (g≤2, y≤1),
so the default t = 786433 (a 20-bit batching prime, valid at poly=16384 because
786433 = 24·32768 + 1 ≡ 1 (mod 2·16384)) stays exact for N up to ~196k; the four
sentinels are all N, always ≪ t. A quantized trait y ∈ {0..Q} raises the
envelope to sum_y2 ≤ N·Q² and sum_gy ≤ 2·N·Q, which can cross t quickly (e.g.
Q=100 overflows at N≈78) — such a deployment must pick a larger ~30-bit batching
prime ≡ 1 (mod 32768) sized to the (N, Q) envelope, or the simulation
feasibility sweep will report infeasible-at-these-params on overflow. The
multiplicative depth is fixed at 1 (one ct×ct level), well within the
coeff_mod_bit_sizes = [60,40,40,60] chain’s two-level budget, so noise-budget
exhaustion is not a concern at these params.

Packaged support file for application digest df37c9b8139f…08e296de. It ships in the archive for review, but is outside the signed payload digest.

Keyboard shortcuts

  • Cmd/Ctrl+K
    Focus global search
  • ?
    Open keyboard shortcuts

Send feedback

We'll only use this to respond to your feedback.