Sealed Mail: Bodies at Rest
How Owlat seals every stored message body with an instance data key, and the deliberate search-index exceptions that stay plaintext.
Owlat seals every message body it stores — both the inline body columns and
the storage blobs (the raw .eml and large-body blobs) — with a single
instance-held data key, so a database or storage dump contains ciphertext
rather than readable mail. This is the "at rest" layer of Sealed Mail (E8b) —
distinct from the instance-to-instance PGP/MIME encryption that protects mail
on the wire.
Coverage. Every body surface is sealed both going forward (every
production write path seals as it stores) and retroactively (the resumable
back-fill migration seals existing rows and blobs). In-process readers decrypt
through the accessor plane; the naked signed-URL blob consumers (web reader,
IMAP bridge, outbound MTA, raw download) fetch through the /sealed-blobdecrypt-serving proxy. Sealing requires INSTANCE_SECRET to be
configured; it always is on a real deployment, so a database or storage dump
holds ciphertext, not readable mail.
What is sealed
Every body-bearing surface across the message shapes is sealed:
| Surface | Sealed |
|---|---|
inboundMessages | textBody, htmlBody (AI-inbox inline bodies) |
mailMessages (inline) | textBodyInline, htmlBodyInline (personal-mailbox snippet) |
unifiedMessages | content (the JSON body blob) |
mailDrafts | bodyHtml, bodyText, bodyBlocks (compose drafts) |
conversationThreads | lastPreview (team-inbox row preview) |
mailMessages (blobs) | rawStorageId (raw .eml), textBodyStorageId, htmlBodyStorageId |
The cipher
The sealing key is derived from INSTANCE_SECRET via HKDF-SHA256 with a
version-pinned salt and info label (owlat:at-rest:bodies:v1), which
domain-separates it from every other use of the instance secret (external-mail
credentials, MTA transport secrets, the E2EE key vault). Bodies are encrypted
with AES-256-GCM and stored as a self-describing envelope string:
atrest:1:<base64(iv)>:<base64(ciphertext‖gcmTag)>
The atrest: prefix and version let a reader tell a sealed value from a
legacy-plaintext one without the key, which is what makes the back-fill
migration resumable — a half-migrated table is a mix of sealed and plaintext
rows and every reader tolerates both.
Sealed detection is structurally strict, not a bare prefix test: message
bodies are attacker-controlled and can literally start with atrest:, so a
value only counts as sealed when it is a well-formed envelope (exactly four
colon parts, a known numeric version, canonical base64, a 12-byte IV, and a
ciphertext of at least the 16-byte GCM tag). A plaintext body that merely
starts with atrest: is read verbatim, never decrypted. In the other
direction, the seal path's idempotency check is keyed — a value is treated
as already-sealed only when it actually decrypts under this instance's key — so
an envelope-shaped plaintext is encrypted like any other body rather than
mistaken for ciphertext.
The whole cipher lives in apps/api/convex/lib/atRestBodies.ts, and the single
decrypt choke point is apps/api/convex/lib/messageBody.ts: every body reader
funnels through those accessors, so "unseal on read" has one home instead of
being scattered across the codebase.
Storage blobs can carry non-UTF-8 bytes (8-bit MIME, binary attachments in
the raw .eml), so they use a byte-level sibling of the same construction —
AES-256-GCM under a domain-separated key (owlat:at-rest:blobs:v1) with a
compact binary envelope (ARBLB1 magic + version + IV + ciphertext) — in the
same lib/atRestBodies.ts. Blob helpers and the serving path live in
apps/api/convex/lib/sealedBlob.ts.
Deliberate exceptions (stay plaintext)
Three surfaces are intentionally left plaintext-derived. Each is annotated at its schema index definition so the exception is discoverable in code:
- Full-text search fields. Convex indexes the plaintext of a
searchField, so sealing it would break server-side search. These fields hold a short snippet or extracted keywords, never the full body:mailMessages.snippet(search_messages)knowledgeEntries.searchableText(search_knowledge)semanticFiles.searchableText(search_files)contactssearch fieldscampaignssearch fields
- Vector embeddings. Derived from plaintext at ingest and stored as floats
(
vector_knowledge,vector_files). They are not reversible to the source text and are required for semantic retrieval. - Per-contact data export.
contacts/dataExport.tsdecrypts bodies when building a GDPR data-subject access bundle — the owner's own data package must be readable. This is the one documented place plaintext leaves the store.
Back-fill migration
apps/api/convex/migrations/0035_seal_bodies_at_rest.ts walks every body-bearing
surface one page at a time — cursor-carrying internal mutations for the inline
columns and an internal action for the storage blobs (blob contents are only
readable from an action) — driven to completion by a resumable run
orchestrator. It is idempotent (re-running skips already-sealed rows and blobs)
and never leaves a row unreadable mid-run. Because Convex storage is immutable
per id, sealing a blob reads it, stores the sealed copy under a new id, repoints
the row, and deletes the old plaintext blob only after the row points at the
sealed copy. Sealing is sharing-aware: IMAP COPY shares one storage blob
across rows (the copy row reuses the original's rawStorageId/*BodyStorageId),
so the reseal repoints every row referencing an old blob — found via the
by_raw_storage / by_text_body_storage / by_html_body_storage indexes —
before deleting it, in a single mutation. A sibling copy is therefore never left
pointing at a deleted blob.
Storage blobs
The raw .eml at rawStorageId and the large-body *BodyStorageId blobs are
sealed at rest with the byte cipher. They are still served to the naked-URL
consumers — the Postbox web reader (fetch(url).text()), the out-of-process IMAP
bridge (FETCH RFC822), the outbound MTA / external-SMTP worker (which fetches
the .eml to transmit), and raw download — but instead of a bare signed storage
URL those callers now receive a decrypt-serving proxy URL.
GET /sealed-blob (apps/api/convex/mail/sealedBlobHttp.ts) reads the sealed
blob named by a capability token, unseals it, and streams the plaintext
bytes. The token is an HMAC over storageId . contentType . expiry keyed by
INSTANCE_SECRET, minted only after the caller has been authorized at the query
site (mailbox ownership is checked before any URL is returned) — matching the
unguessable, time-limited nature of the Convex signed URL it replaces. A
bad/expired/forged token is a flat 403. Because every consumer keeps doing a
plain GET that yields the original bytes, the proxy is a transparent drop-in
and no out-of-process code changes.
The in-process reader path (readMailMessageText) unseals blobs directly through
readSealedBlobText, so server-side body reads never touch ciphertext.
A mutation cannot read or re-store a blob's bytes (blob contents are action-only), so the two paths that accept a worker-uploaded plaintext blob seal it out-of-band rather than leaving a standing plaintext residual:
- IMAP APPEND (
mail.imap.appendMessage) uploads the raw.emlstraight to storage, then schedules a per-message reseal action (resealMessageBlobs,runAfter(0)) after inserting the row. - External IMAP sync (
mail.externalDelivery.ingestExternalMessage) already seals at write: its sole calleringestExternalRawis an action that seals the raw.emland the body blobs (storeSealedBlob/splitBodyForStorage) before the mutation runs.
resealMessageBlobs is idempotent (an already-sealed blob reseals to a no-op) and
repoints + deletes the old plaintext blob in one mutation. In the brief window
between the plaintext write and the scheduled reseal, the blob reads and serves
correctly through the mixed-tolerance accessors and proxy (openBytesAtRest
passes legacy plaintext through). The back-fill remains the catch-up path for
rows written before E8b shipped.
Acceptance
A database and storage dump of a seeded, migrated instance contains zero
message-body plaintext — across the five inline body shapes and the raw .eml
- body blobs — outside the documented search-index exception above. This is
asserted by the canary test in
apps/api/convex/__tests__/sealBodiesAtRest.integration.test.ts, which dumps both the DB columns and the stored blob bytes.