Cleaning a photo
Before anything is encrypted, the phone builds two new images from the pixels. The original file is never uploaded.
| Full photo | Thumbnail | |
|---|---|---|
| Format | JPEG, quality 90 | JPEG, quality 82 |
| Size | On Android, scaled down to at most about 12.5 megapixels. The Dart path, used on iPhone for now, keeps the original resolution | Longest side 640 px |
| Byte limit | 64 MiB of ciphertext, enforced by the server | Under 420 KiB (quality, then size, drops to fit) |
These are the current defaults. At this quality the difference from the original is hard to see, and uploads stay small. Full resolution costs network and storage, not encryption. I plan to let you choose full quality when you upload, and to choose for each album whether location and camera details are removed. Neither is built yet.
- On Android a native decoder reads the photo once and produces both images. Camera rotation is applied to the pixels first, so nothing depends on metadata afterwards. I am working on the same native decoder for iPhone.
- A JPEG sanitizer walks the output segment by segment and keeps only what is needed to draw the pixels. EXIF, XMP, other application segments, comments and any data after the image are dropped. This is where location and camera details are removed.
- Where there is no native decoder, or it does not recognise the format, a Dart decoder does the same job in the background, clearing metadata before it re-encodes.
- If the photo cannot be decoded either way, the upload is refused. Miuchio never falls back to sending the original file with its metadata.
Encrypting
Each photo gets a random 32 byte key (DEK) and a random 16 byte media ID. The thumbnail gets its own separate key, so a leaked thumbnail key cannot open the full photo.
photo_ct = AES-256-GCM(DEK_photo, photo, aad = media_id)
thumb_ct = AES-256-GCM(DEK_thumb, thumb, aad = media_id || "thumb")Files of 1 MiB or more use format 0x03, which splits them into 1 MiB segments. It has the same goals as the STREAM construction: segments cannot be reordered, dropped, or moved to another file. Miuchio's variant puts the segment index and a last-segment flag in each segment's associated data and derives each nonce with HKDF:
header = 0x03 || file_nonce (12) || u32_be(segment_size)
segment_i = tag_i (16) || ciphertext_i
nonce_i = HKDF-SHA256(ikm = DEK, salt = file_nonce, info = "seg-nonce-v1" || u32_be(i), len = 12)
aad_i = media_id || u32_be(i) || u32_be(is_last ? 1 : 0)Today the whole file is still read into memory; streaming from disk is future work.
Wrapping the photo keys
dek_wrap = AES-256-GCM(MK_epoch, DEK, aad = album_id (16) || u32_be(epoch))Both keys are wrapped this way and stored next to the photo record with the epoch number. Changing the epoch number makes the wrap fail to open, so the server cannot point a photo at a different key.
Why two layers instead of encrypting photos with the album key: the photo's own encryption depends only on its media ID. When a removal forces a new epoch in the middle of an upload, only the two 61 byte wraps are redone. The encrypted photo stays as it is.
Uploading
- Reserve. The phone sends the media ID, epoch, ciphertext sizes and SHA-256 hashes, and both wraps. Under the album lock the server checks the epoch is current, no new epoch is owed, and the media ID is not someone else's. It saves a pending record.
- Send. The server returns upload links that expire after 30 minutes. Storage only accepts a body with exactly the declared size and SHA-256 checksum. Storage names are random and unrelated to the album or media ID. The phone uploads the ciphertext straight to storage.
- Confirm. The server checks each stored object's size and hash, then repeats the epoch and removal checks under the album lock before the photo becomes visible.
Why check twice: Bob reserves a photo under epoch 0, and while the bytes upload, Alice removes Carol. If only the reserve step checked, Bob's photo would be published under MK0, a key Carol holds. The confirm check refuses it. Bob's phone drops the reservation and, once MK1 has arrived, wraps the two photo keys again under MK1 and sends the same encrypted photo under a new reservation, with the same media ID. The photo is encrypted only once.
Before sending, the phone saves the ciphertext locally with a small metadata file encrypted under its cache key, so an upload survives the app being killed. Pending records that are never confirmed are removed by the server after 2 hours. Only the member who uploaded a photo can delete it.
Opening a photo
- Read the photo record and its epoch.
- Open the DEK with that epoch's MK.
- Download the ciphertext through a short lived link.
- Check the size and SHA-256 hash, which catches corruption early.
- Decrypt. The authentication tag is what actually proves the file was not changed.
Names, titles and avatars
Album titles and member names are small, so they are sealed under the album's newest MK directly. The epoch travels in front of the ciphertext, so a name sealed under an old epoch still opens after a rotation:
name_ct = u32_be(epoch) || AES-256-GCM(MK_epoch, name, aad)
album title aad = "miuchio.album-name-v1" || album_id || u32_be(epoch)
member name aad = "miuchio.member-name-v1" || album_id || member_token || u32_be(epoch)A profile photo is cropped to a 512 px square, cleaned like any photo, padded to exactly 128 KiB, and encrypted separately for each album the person is in:
blob = AES-256-GCM(DEK, u32_be(len) || jpeg || zeros,
aad = "miuchio.avatar-v1" || album_id || member_token || avatar_id)
key_ct = u32_be(epoch) || AES-256-GCM(MK_epoch, DEK,
aad = "miuchio.avatar-key-v1" || album_id || member_token || avatar_id || u32_be(epoch))A fresh DEK and a separate object per album, all the same size, means the server cannot link one person's albums by comparing avatar objects. See Metadata and the social graph.
The local cache
Decrypted photos are cached on the phone, encrypted again under a device cache key with the media ID and the asset (file or thumb) as associated data. Thumbnails are kept in app storage the system does not clear, so albums stay readable offline. The app still removes the least recently viewed thumbnails once they pass 512 MiB, except those shown on the shelf. Full photos go in the system cache directory with a 1 GiB budget, and the system can clear them too.
The cache key is read from the key store once each time the app starts, and it stays the same across sign outs. Before that, every cached photo needed a key store call, which was the slowest part of scrolling an album. Names, avatars and identity pins are stored the same way. The album list and photo records are not sealed; they rely on the app's private storage, which on Android is excluded from device backups.
What this does not hide
- The exact size of every encrypted photo and thumbnail. Padding photos into size buckets is planned but not built.
- When photos are uploaded, and which member token uploaded them.
- There is no signed list of an album's photos yet, so the server could hide, reorder or replay records, or show members different lists.
- Any member holding an epoch key can make a valid wrap. Who uploaded a photo is recorded by the server, not signed by the uploader.