10.2 QSFS
File storage that has only ever seen ciphertext.
Sharded, encrypted file storage: every blob arrives sealed, splits across shards, and is erased on schedule.
- Blob AEAD ChaCha20-Poly1305
- Blob keys HKDF-SHA512
- Session floor ML-KEM-1024 + X25519
01 / THE CARRIER — LETTERED
Send a file through it. The keys never follow.
The encryption boundary
Your file is padded to a size class and sealed on your device — one ChaCha20-Poly1305 box over the padded plaintext, under a Q-Ratchet-derived key — before a single byte leaves it. QSFS stores opaque blobs addressed by identifiers, sharded across storage paths. The keys stay with you and your recipients; the store has only ever seen ciphertext.
- Seal point
- on-device, before upload — server never holds a key
- Blob key
- Q-Ratchet-derived per file, labeled HKDF
- Traffic shape
- pad-ladder size classes — length buckets, not exact lengths
Binding beats swapping
Sealing is not enough; a captured blob must not be re-servable somewhere else. Each media blob's AAD binds its conversation, epoch, chain position, sender, file identifier, and declared media type — substitution, replay onto a sibling group, or re-serving an image as a video manifest fails authentication.
Deleted on its own clock
A file is not deleted with its message. The relay keeps each upload
until its own expiry, 45 days after upload by
default and published at /api/v1/transparency/retention;
then the media reaper deletes the blob and its upload record. The
sender’s delete for everyone, or a chat timer’s purge from
the sender’s phone, removes it sooner; destroying the
conversation does not. FSOR wraps no file, and any server-side
encryption a file’s store adds is under a key the operator
holds, so against the operator a file rests on its end-to-end seal
alone.
Uploads that survive bad networks
The sealed ciphertext uploads in 256 KB chunks, each idempotency-keyed by its digest, so a transient failure on chunk N never wipes the bytes for chunks 0…N−1. Resume is a bookkeeping operation on ciphertext — the crypto never re-runs.
Every blob arrives sealed. QSFS has never held a key.
02 / THE WRITE PATH — TRACED
Trace one file from your device to the shard
03 / THE BINDING — IN SOURCE
Read the binding in the source
/// Build the AAD for a media-blob AEAD payload. Wire layout:
/// `GROUP_MEDIA_AAD_LABEL || 0x00 || conversation_id || 0x00 || epoch(u32) ||
/// chain_index(u32) || sender_profile_id || 0x00 || file_id || 0x00 ||
/// mime_type`
///
/// `mime_type` binding stops a blob host from re-serving an image
/// ciphertext as a video manifest (the iOS decoder picks behavior off the
/// declared mime). `file_id` binding stops a captured blob from being
/// served back under a different file_id slot. Conversation/epoch/chain
/// binding stops cross-channel replay onto a sibling group.
pub fn build_media_aad(
conversation_id: &[u8],
epoch: u32,
chain_index: u32,
sender_profile_id: &[u8],
file_id: &[u8],
mime_type: &[u8],
) -> Vec<u8> {
let mut aad = Vec::with_capacity(/* exact-size preallocation elided */);
aad.extend_from_slice(GROUP_MEDIA_AAD_LABEL);
aad.push(0x00);
aad.extend_from_slice(conversation_id);
aad.push(0x00);
aad.extend_from_slice(&epoch.to_be_bytes());
aad.extend_from_slice(&chain_index.to_be_bytes());
aad.extend_from_slice(sender_profile_id);
aad.push(0x00);
aad.extend_from_slice(file_id);
aad.push(0x00);
aad.extend_from_slice(mime_type);
aad
}
04 / THE PARAMETERS — READ FROM SOURCE
Check every parameter against the file it came from
| Parameter | Value | Source |
|---|---|---|
| Blob seal | ChaCha20-Poly1305, one box over padded plaintext | FileUploadService.swift |
| Blob key | Q-Ratchet-derived per file (labeled HKDF) | crypto-core/src/group_sender.rs |
| AAD binding | conversation, epoch, chain index, sender, file_id, mime type | group_sender.rs::build_media_aad |
| Traffic shape | pad-ladder size classes | crypto-core/src/q_pad_ladder.rs |
| Upload chunk | 256 KB, digest-idempotent, resumable | FileUploadService.swift |
| Concurrency | 4 chunks in flight | FileUploadService.swift |
| Max file size | 2 GB | FileUploadService.swift |
| Deletion | media reaper at expiry, 45 days after upload by default; the uploader's DELETE sooner | backend/src/main.rs · handlers.rs |
05 / THE YIELD — BY STORAGE CLASS
Measure where your files actually stand.
| QERYX QSFS | Classical cloud storage | Email attachments | |
|---|---|---|---|
| Real workloads | |||
| Provider read | Ciphertext only | Provider-readable or provider-keyed | Plaintext at provider |
| Storage seizure | Sealed chunks + identifiers | Plaintext or provider-decryptable | Plaintext |
| Deletion | At its own expiry (45 days by default), or sooner by its sender | Soft-delete; versions persist | Forever, in mailboxes |
| Integrity | AAD-bound authentication | Checksums at best | Not included |
| Quantum recording today | Hybrid-derived keys | Classical TLS + at-rest | Plaintext |
06 / THE HARD QUESTIONS
Ask what a seizure of the store reaches.
Can QERYX open my files?
No. Files are sealed on your device before upload; QSFS holds ciphertext and identifiers, not keys.
What does a storage seizure yield?
Sealed blobs and their identifiers. Assembling plaintext requires the conversation keys — breaking both X25519 and ML-KEM-1024.
What happens to files when I delete a conversation?
They stay until their own expiry, up to 45 days after upload: destroying a conversation deletes the message rows the relay can tie to it, not the files they carried. Delete a message you sent for everyone, within two hours of sending it, to remove its file sooner.
Can a corrupted or swapped blob go unnoticed?
No — each blob's AAD binds its conversation, epoch, chain position, sender, file identifier, and declared media type. Substitution fails authentication.
Does file size leak what I sent?
Sizes land on the shared pad-ladder — length classes, not exact lengths.