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:

SurfaceSealed
inboundMessagestextBody, htmlBody (AI-inbox inline bodies)
mailMessages (inline)textBodyInline, htmlBodyInline (personal-mailbox snippet)
unifiedMessagescontent (the JSON body blob)
mailDraftsbodyHtml, bodyText, bodyBlocks (compose drafts)
conversationThreadslastPreview (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)
    • contacts search fields
    • campaigns search 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.ts decrypts 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 .eml straight 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 caller ingestExternalRaw is an action that seals the raw .eml and 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.