Why this matters to me
Databases get copied: through a breach, a backup left somewhere, or a request from someone with the power to demand it. I cannot make a running server blind to who is connected, but I can decide what the stored data says on its own. The goal is that a copy of the database, without the server's secrets, cannot name who is in an album. Today a copy can still tell when two memberships belong to the same unnamed person. What a copy can still link explains that, and how I plan to close it.
Everything below is about that copy. What the running server can still see is at the end.
Email addresses are not stored
Miuchio signs you in with a one-time code sent by email. The account row stores only a keyed hash of the address:
email_hmac = HMAC-SHA256(email_key, lowercase(trim(email)))email_key is kept in server configuration, not in the database. When you sign in again the server computes the same value and finds your account. The address itself is used to send the code and is not written to the database.
Nobody can find out whether an email has a Miuchio account without receiving the code sent to it. Asking for a code works the same for every address, and the app only says an account exists once the code has been entered. A copy of the database cannot be searched for a known address either, because that needs the key.
The sign in code is kept for five minutes in a short lived store, filed under the same HMAC rather than the address. The code itself is stored as a keyed hash bound to that address, so reading the store does not reveal a usable code. Five wrong guesses erase it, and each address can ask for three codes every five minutes.
A Miuchio ID instead of contacts
People find each other through a Miuchio ID: 8 characters drawn from 40 random bits, not derived from the email or anything else. Miuchio never asks for a phone number or reads contacts. Anyone who has your ID can add you to an album. ID lookups are rate limited twice: 5 a minute for any one ID, and 20 an hour in total for each account. That slows anyone trying to find accounts by guessing IDs; it does not make it impossible.
A different identity in every album
Inside an album, a person is not their account ID. They are a member token: 32 random bytes, generated separately for each album. Bob in Picnic and Bob in Trip have unrelated tokens. Everything album scoped refers to the token: wraps, signatures, uploads, names and avatars.
The server still has to answer two questions, so each membership row carries two derived values:
| Question | Stored value | How |
|---|---|---|
| Which albums is this account in? | user_handle | HMAC-SHA256 of the account ID. Deterministic, so it can be indexed. One way. The same in every album |
| Which account owns this token? | user_id_enc | The account ID encrypted with AES-256-GCM. Needed to deliver realtime events and resolve an admin's key |
handle_key, seal_key = HKDF-SHA256(user_link_key, info = "user-handle-v1" / "user-id-enc-v1")
user_handle = HMAC-SHA256(handle_key, account_id)
user_id_enc = AES-256-GCM(seal_key, account_id,
nonce = SHA256("user-id-enc-nonce-v1" || member_token)[0..12])In a database copy, Bob's account row and his two membership rows share nothing readable. Reversing user_handle means trying every possible account ID under a key the copy does not contain, and user_id_enc does not decrypt without that key either.
His two membership rows do share one value with each other: the same user_handle. That is what lets the server list his albums with one indexed lookup, and it is the gap described in What a copy can still link.
Names, titles and profile photos are encrypted
- Album titles and member names are sealed under the album key. The account row has no name field at all. Each member publishes their name separately into each album they are in.
- Profile photos are encrypted separately for each album with a fresh key, and every copy is padded to the same 128 KiB. Otherwise the server could link Bob's albums by spotting the same object, or the same object size, in each of them.
The formats are on Photos, names and avatars.
Rounded times
Default timestamps in the database are rounded down to the hour: when an account, album, membership, epoch, key delivery or photo was created. Exact times would let someone see that two accounts joined the same album 90 seconds apart. Hours keep a rough order without that precision.
Some times are still exact because the server needs them: when a member was revoked, when a member last confirmed receiving a key, session expiry, and retry schedules for cleanup jobs.
What a copy can still link
Two things in a database copy connect records without any server secret.
- The same handle in every album. Bob's memberships in Picnic and Trip carry the same
user_handle. A copy cannot say the handle is Bob, but it can say one person is in both albums, and so draw the shape of who shares albums with whom. Anyone who learns that one of those memberships is Bob's learns all of them. - Signatures. Account rows hold public identity keys, and album records hold signatures made with them: the admin's signature over each epoch change, and the sender signature on each key delivery. Checking a signature against every stored public key finds the account that made it. That points to the account of each album's admin and of whoever invited each member. The account holds no name or email, only random identifiers and the person's Miuchio ID, so it is a pseudonym. Someone who already knows that person's Miuchio ID could recognise it.
Planned: a separate handle per album
I plan to close the first gap by making the handle specific to each album:
user_handle = HMAC-SHA256(handle_key, account_id || album_id)- "Is this account in album X?" stays one indexed lookup, because the server knows both values when it asks.
- "Which albums is this account in?" can no longer use the handle. Each account row would instead carry its list of album IDs, encrypted under a server key and rewritten in the same transaction as every join, leave and removal.
- Existing rows would be rewritten once by a migration, which the server can do because it holds the key.
That removes the correlation through the shared handle. Signatures would still link an admin's albums to their unnamed account. This is not built yet.
The signature link is harder. Members need to check who signed, and hiding the signer from a copy while keeping that check would need a different kind of signature. I have no plan for that yet, so it stays listed as a limit.
What the running server still sees
The server holds email_key and user_link_key while it runs, so it can do every join above. It also sees:
- which account is connected, from which network address, and when,
- which albums an account is in, and its role,
- when photos are uploaded, by which member token, and their exact encrypted sizes,
- when members join, leave and receive keys.