Every step, and every byte on the wire.
The trace below follows one question at a library of 20,000 passages. Steps marked Laptop never touch the network. The sizes are what the app's ledger shows.
| Step | Where | What happens | On the wire |
|---|---|---|---|
| Once | Download | Public setup. The manifest, the 4 MB fetch hint and the page map. Identical for every user, cached by library version. | ↓ ~4 MB |
| Once | Upload | Fresh CKKS keys. The laptop makes new keys for the session and uploads only the public and evaluation keys. | ↑ 35 MB |
| 1 | Laptop | Read the chart. A local model reads the question and the chart items the clinician kept on, and writes a search query. | 0 B |
| 2 | Laptop | Embed and encrypt. bge-small turns the query into 384 numbers; CKKS encrypts them under the secret key. | 0 B |
| 3 | Relay | POST /score. The encrypted query goes to the server through the relay. | ↑ 334 KB |
| 4 | Server | Score blind. The encrypted query is multiplied by every passage embedding, in blocks of 3,712, and the encrypted scores go back. | ↓ ciphertext |
| 5 | Laptop | Decrypt and pick. The laptop decrypts the scores and takes the top 8, at most 2 from any one source page. | 0 B |
| 6 | Relay | POST /fetch. Exactly 8 SimplePIR queries, one per passage. Each is 20,000 random-looking numbers. | ↑ 625 KB |
| 7 | Server | Answer the fetch. For each query, one multiply over the whole library. The same work whichever passage was asked for. | ↓ 8 × 4 KB |
| 8 | Laptop | Decode. Subtract the hint times the secret, round: the 8 passages, byte for byte. | 0 B |
| 9 | Laptop | Answer. The local model writes from the chart and the 8 passages, citing each. The ledger and the proof update. | 0 B |
The threat model, in one table.
Each party sees only what it needs to do its job. The laptop is the only place the question, its embedding, the scores and the answer exist in readable form.
| Party | Sees | Never sees |
|---|---|---|
| Laptopthe clinician's machine | Everything: the chart, the question, the keys, the scores, the passages and the answer. | Nothing is hidden from it. It holds the keys. |
| Relayforwards ciphertext | The laptop's IP, ciphertext sizes and timing. | The question, the embedding, the results. |
| Serverscores and serves the library | Ciphertext sizes, timing, and that a query happened. | The question, the embedding, the scores, which passages were fetched, who asked. |
| Tavily and Exabuild the library | Our crawl of public health topics. With the optional web search: generic one-concept searches mixed with decoys, plus the laptop's IP and timing. | The question, or anything from the encrypted library search. |
| EHRthe hospital's system | The SMART sign-in and which charts were pulled. It already holds them. | The questions asked about them. |
Embeddings can be turned back into text. Vec2Text recovered 92% of 32-token inputs exactly, including full names from clinical notes. So the 384 numbers never leave unencrypted either.
Scoring a question nobody can read.
CKKS is homomorphic encryption for approximate real numbers: the server can multiply and add encrypted vectors without ever decrypting them. That is exactly what a similarity search needs.
# on the laptop (holds the secret key s) q = embed(question + chart context) # 384 numbers, unit length ct = Enc(q) # CKKS, N = 8192, 4,096 slots # on the server (holds only public and evaluation keys) for each block E_b of 3,712 passage embeddings: ct_b = ct · E_bᵀ # one plaintext multiply per block # back on the laptop scores = Dec(ct_b) → top 8, at most 2 per page # never sent back
| Parameter | Value | Why |
|---|---|---|
| Library | TenSEAL (Microsoft SEAL) | CKKS vectors and vector × matrix products |
| Polynomial degree N | 8192 | 4,096 slots and one multiplication level, all a vector × plain matrix product needs |
| Coefficient modulus | [60, 40, 40, 60] bits | One rescale after the multiply, with headroom |
| Scale | 2⁴⁰ | Enough precision that the encrypted top 8 matched the plaintext top 8 exactly in testing |
| Block size | 4,096 − 384 = 3,712 | TenSEAL's product is only exact while block + dimension ≤ slots; the code enforces it |
| Embedding | BAAI/bge-small-en-v1.5, 384-d | Runs on the laptop; small enough to keep scoring under 10 s at 20,000 passages |
Decrypted results never go back
Li and Micciancio showed that returning CKKS decryptions to the party holding the ciphertext can leak the secret key. BlindSpot never sends a decrypted value to the server.
New keys every session
The laptop makes fresh keys at start and uploads the public part once. The server can tell that queries came from the same session, not what they asked.
Getting 8 passages without saying which.
Once the laptop knows which 8 passages it wants, it still has to get them. SimplePIR lets it fetch each one with a query that looks like random numbers, so the server must multiply it against the whole library and can't tell what was asked for.
The server multiplies every query against every passage. Which 8 the laptop wanted never shows.
# laptop wants passage j; keeps a fresh secret s and noise e qu = A·s + e + Δ·u_j # u_j: 1 at position j, 0 elsewhere; Δ = 2³²/256 # A·s + e is an LWE sample: random-looking without s # server: library D (1,024 bytes × n passages), public hint H = D·A ans = D·qu # one multiply over every passage # laptop ans − H·s = Δ·D[:, j] + D·e # noise |D·e| < Δ/2 round(· / Δ) → passage j, byte-exact
Why the server learns nothing: A·s + e is a Learning With Errors sample, computationally indistinguishable from uniform numbers mod 2³². Adding Δ at one position of a uniformly random vector gives another uniformly random vector, so for every choice of passage the query looks the same. That is the SimplePIR security theorem, and its reduction goes to LWE, the same family of assumptions behind NIST's post-quantum standard ML-KEM.
| Parameter | BlindSpot | SimplePIR paper |
|---|---|---|
| LWE secret dimension | 1,024 | 2¹⁰ = 1,024 |
| Modulus q | 2³² | 2³², native 32-bit arithmetic |
| Error | discrete Gaussian, σ = 6.4 | σ = 6.4, same sampler as the reference code |
| Secret | fresh per query, os.urandom | fresh per query |
| Security target | 128-bit | 128-bit, lattice estimator |
| Samples per query | n ≤ 250,000 < 2¹⁸ | up to 2²¹ at these parameters |
| Matrix A | SHAKE-128 of a public seed | a public hash in counter mode |
The count never changes
The client always sends exactly 8 queries and the server rejects any other count, so the number can't reveal how good the matches were. Repeating a passage looks no different from fetching two.
36 standard deviations of margin
At 20,000 passages the worst-case decoding noise would have to land 36σ out: about 1 in 10²⁸⁴ per question. Tests check byte-exact round trips on worst-case records.
That a fetch happened
When, that it was exactly 8, and sizes that depend only on the library: 8 × 80 KB in, 8 × 4 KB out. Its running time is the same for every query.
Which passages, or whether they repeat
Not which passages were fetched, not whether two requests asked for the same one, not the scores, not the question.
Hiding who asked. Blocking everything else.
The relay sits between the laptop and the server, so the server never sees the laptop's IP. It forwards only the protocol paths and only the body, content type, session id and library version. It keeps no access log. Everything else returns 404, so the server's internal endpoints stay private.
The egress guard is an allowlist inside the client: the relay, the local model, and an EHR on the same machine unless another host is listed. It checks the host before the HTTP client is called, so a blocked request never resolves a name or opens a socket. Every request, allowed or blocked, lands in the ledger the clinician can open.
Today's relay is a stand-in for Oblivious HTTP (RFC 9458). For the IP-hiding guarantee to hold, the relay and the server must be run by different parties.
A receipt the clinician can check.
After each answer, the app replays the server's side using only the bytes the laptop sent to the relay for that answer. Those bytes are kept in memory, never on disk. Then it tries to read them.
- No secret key left the laptop. The key upload holds public and evaluation keys only.
- The server can't decrypt the question. Decrypting the query with the uploaded keys fails.
- No words left in readable form. Every field is base64-decoded and searched for phrases and words from the question and the chart. In the demo: 46 searched, 0 found.
- The server can't tell which passages were read. The 8 fetch queries touch every passage, and a best-guess attacker does no better than chance.
The same checks run as a script, scripts/verify_server_view.py, against a recorder placed in front of the real server. These checks catch mistakes; the guarantee itself is the math.
Public sources, rebuilt every day.
The library is public-domain US government text, because it is handed to users passage by passage: FDA drug labels from openFDA and DailyMed, NLM's MedlinePlus, and .gov health pages that Tavily and Exa crawl. No clinician's question ever reaches them.
- Build. Clean, de-duplicate and pack into fixed 1,024-byte records with the URL and title inside. Embed each record, and compute the PIR hint once per library.
- Refresh. A scheduled job runs every 6 hours and rebuilds when the library is over a day old. Label changes, such as an NSAID warning at 20 weeks or a new contraceptive interaction, reach answers without retraining anything.
- Verify, then publish. Before publishing, the job checks the files, shapes and manifest, does a PIR round trip, and compares the encrypted and plaintext top result. A failed build is never published.
- Hot-swap. The server polls for new versions and swaps them in without a restart. A client on the old version gets a clear "library changed" reply, re-syncs and retries once.
Clinical features that never phone home.
Only what the clinician keeps on
Chart items switched on go with the question as local context: age, sex and the clinical items. Never the name, birth date or MRN. Only the local model reads it.
Live dictation with Whisper
Audio streams to the app on the laptop, which re-runs an open Whisper model about every second. The patient's medicines are passed as spelling hints. Nothing appears on the ledger.
SMART on FHIR and HL7 v2
Sign-in uses the authorization code flow with PKCE S256 and refresh tokens. Lab results arrive over MLLP on 127.0.0.1. Pulled charts and tokens are sealed on disk.
Encrypted, never synced
Each answer is one encrypted file: an HMAC-SHA256 keystream with encrypt-then-MAC, the key wrapped by Windows DPAPI. Retention of 30 days, 1 year or always; export as FHIR DocumentReference.
FDA safety notices, matched locally
The server publishes one public feed of FDA safety notices, the same file for everyone. The laptop matches it against each patient's medicines.
Web search, reviewed first
When the library's best match is weak, a local model splits the question into generic one-concept searches plus decoys, estimates what each provider could guess, and sends nothing until the clinician presses Send.
Measured on one laptop.
| Library | Encrypted scoring | 8 private fetches (server) | Upload per question |
|---|---|---|---|
| 3,000 passages | 1.2 s | 0.02 s | ~0.4 MB |
| 20,000 passages | 7.3 s | 0.1 s | ~1 MB |
Plus a one-time 35 MB key upload per session and a 4 MB public hint download. The encrypted top 8 matched the plaintext top 8 exactly, and every fetched record was byte-exact. Building the PIR hint takes about a second at 20,000 passages.
What it doesn't do yet.
A stand-in, not real OHTTP. Next: a third-party Oblivious HTTP relay.
Capped near 20,000 passages to keep encrypted scoring under about 10 s. Next: clustering, as MIT's Tiptoe does for web-scale private search.
PIR gives privacy, not integrity: a malicious server could return wrong text, though it still couldn't learn what was asked.
The server can link queries within one session (not their content). Keys are new each session.
Splitshot talks to Tavily and Exa directly: they see the laptop's IP and generic terms. Decoys reduce what they learn but don't remove it.
FDA labels and MedlinePlus only: no licensed journals, no dosing sections yet.
A small local model can misread a passage; in our grading about 1 sentence in 7 was unsupported or uncited. General information, not medical advice.
Tested against a mock SMART on FHIR server and synthetic patients, not a live hospital EHR.