← sti.care labs

Data & storage

The plain answer to "should I trust this with my status?" What lives on your device, what reaches our server, and what we provably can't see. Not legal advice.


In one line

Everything sensitive (diagnoses, dates, who you're linked to, your groups, your handles) lives on your device, inside an encrypted blob whose key never reaches us. Our server stores ciphertext and opaque routing tokens, nothing else. It can't read your status, and the stored contact graph is unreadable to us. Even partner-notification routing, the hardest case, is now blind: the server cannot tell who was notified. The one thing we do not offer yet is self-serve deletion and export, called out in full below, not buried.

What lives where

On your device (inside a passkey- or passphrase-derived encrypted blob; the key never leaves the device):

On our server (stored in the clear, but meaningless without your key):

Store Shape What it's for
Alias payloads opaque_alias_id → ciphertext Serving a passport when someone opens a link. The id is random and meaningless, the only id we ever see. No handle, avatar, name, grouping, or even a public/private flag.
Account blob opaque_account_id → ciphertext Syncing your encrypted store across your own devices. Addressed by an opaque id derived from your key; holds nothing readable.
Recovery envelope locator → ciphertext Getting back in from a new device that has only your recovery name and password. The locator is a non-secret, owner-chosen public handle; the ciphertext is your account root wrapped by a memory-hard KDF of your password. There is one table, keyed only by the locator: no factor or account-id column. The server holds only the wrapped bytes, never the password or anything derived from it, and answers a uniform fixed-size decoy on a miss, so whether a given name has an envelope stays hidden (an existence-uniform read). The passkey unlock wraps the same root too, but that wrap stays on your device and never becomes a server envelope.
Notify routing hash(notify_token) → opaque_handle Routing an anonymous "go get tested" nudge. The token is pairwise and was exchanged phone-to-phone, never through us.
Push endpoints opaque_handle → push subscription Waking a device with a contentless ping.
Send queue batched outbound jobs Holding nudges briefly so cross-user batching hides timing. We trigger the wake; your device decides what (if anything) is shown.

Post-MVP and optional: SSO as a recovery anchor (hash(sub) → ciphertext) and an opt-in, off-by-default, content-free email channel. Both still need your key to decrypt anything.

For a miss (an id that doesn't exist, or one you can't decrypt), the server returns decoy, ciphertext-shaped bytes, uniform in both size and timing, so "can't read this" and "doesn't exist" are indistinguishable.

What we can, and can't, see

We can see: that an alias or a push endpoint exists, that some tokens got pinged, the size of a ciphertext, and aggregate operational telemetry about the service itself (request rates and latencies per endpoint, error and shed counts, queue depth, total row counts). The telemetry is system-level only: it carries no id, IP, request body, or token, no per-request trail, and nothing that links a request to a person or to another request. That's the list.

We can't see: your social graph, your group membership, any diagnosis, any test or treatment date, or how many contacts you have. None of it is readable without a key we never hold.

Partner notification stays blind

Partner notification is the hardest thing to keep private, so here is exactly how it holds. When someone reports a positive, the people they were linked with get a contentless "go get tested" nudge, and the server has to route it without learning who received it.

Two things make that hold. First, a population-wide, contentless cover wake fires on a fixed schedule whether or not anyone reported, so a quiet window emits the identical broadcast as a busy one, and a real nudge rides the next scheduled wake as one anonymous member of the population. Second, every device checks for a waiting nudge on the same uniform cadence and reads it without writing anything back, so a recipient's check is byte- and timing-identical to a non-recipient's. Together they close the leak a naive targeted push would have opened, where the server could see which handles received a nudge.

What the server still sees is only that nudges are enqueued at all, never who they reach.

Getting back in, and getting out