Sealed Mail: Nachrichtentexte im Ruhezustand

Wie Owlat jeden gespeicherten Nachrichtentext mit einem Instanz-Datenschlüssel versiegelt und welche Ausnahmen im Suchindex bewusst im Klartext bleiben.

Owlat versiegelt jeden gespeicherten Nachrichtentext — sowohl die Inline-Body-Spalten als auch die Storage-Blobs (die rohen .eml- und die Blobs großer Nachrichtentexte) — mit einem einzigen instanzeigenen Datenschlüssel, sodass ein Datenbank- oder Storage-Dump Chiffrat statt lesbarer Mail enthält. Das ist die „At-Rest“-Schicht von Sealed Mail (E8b) — zu unterscheiden von der PGP/MIME-Verschlüsselung zwischen Instanzen, die Mail auf der Leitung schützt.

Abdeckung. Jede Body-Oberfläche wird sowohl ab jetzt versiegelt (jeder produktive Schreibpfad versiegelt beim Speichern) als auch rückwirkend (die fortsetzbare Back-Fill-Migration versiegelt bestehende Zeilen und Blobs). In-Process-Leser entschlüsseln über die Accessor-Ebene; die nackten Signed-URL-Blob-Konsumenten (Web-Reader, IMAP-Bridge, ausgehender MTA, Rohdownload) holen sich die Daten über den Entschlüsselungs-Proxy /sealed-blob. Das Versiegeln setzt voraus, dass INSTANCE_SECRET konfiguriert ist; auf einem echten Deployment ist es das stets, sodass ein Datenbank- oder Storage-Dump Chiffrat und keine lesbare Mail enthält.

Was versiegelt wird

Jede Body-tragende Oberfläche über alle Nachrichtenformen hinweg wird versiegelt:

OberflächeVersiegelt
inboundMessagestextBody, htmlBody (Inline-Bodys der AI-Inbox)
mailMessages (inline)textBodyInline, htmlBodyInline (Snippet des persönlichen Postfachs)
unifiedMessagescontent (der JSON-Body-Blob)
mailDraftsbodyHtml, bodyText, bodyBlocks (Entwürfe im Editor)
conversationThreadslastPreview (Zeilenvorschau der Team-Inbox)
mailMessages (Blobs)rawStorageId (rohes .eml), textBodyStorageId, htmlBodyStorageId

Die Chiffre

Der Versiegelungsschlüssel wird aus INSTANCE_SECRET per HKDF-SHA256 mit einem versionsgepinnten Salt und Info-Label (owlat:at-rest:bodies:v1) abgeleitet, was ihn gegenüber jeder anderen Verwendung des Instanz-Secrets domänensepariert (Zugangsdaten externer Mail, MTA-Transport-Secrets, der E2EE-Schlüsseltresor). Bodys werden mit AES-256-GCM verschlüsselt und als selbstbeschreibender Envelope-String gespeichert:

atrest:1:<base64(iv)>:<base64(ciphertext‖gcmTag)>

Das Präfix atrest: und die Version erlauben es einem Leser, einen versiegelten Wert ohne den Schlüssel von einem Alt-Klartextwert zu unterscheiden — genau das macht die Back-Fill-Migration fortsetzbar: Eine halb migrierte Tabelle ist eine Mischung aus versiegelten und Klartextzeilen, und jeder Leser toleriert beides.

Die Erkennung des Versiegelungszustands ist strukturell streng und kein bloßer Präfixtest: Nachrichtentexte sind angreiferkontrolliert und können buchstäblich mit atrest: beginnen, deshalb gilt ein Wert nur dann als versiegelt, wenn er ein wohlgeformter Envelope ist (exakt vier Doppelpunkt-Teile, eine bekannte numerische Version, kanonisches Base64, ein 12-Byte-IV und ein Chiffrat von mindestens der Länge des 16-Byte-GCM-Tags). Ein Klartext-Body, der lediglich mit atrest: beginnt, wird wörtlich gelesen und nie entschlüsselt. In der Gegenrichtung ist die Idempotenzprüfung des Versiegelungspfads schlüsselgebunden — ein Wert gilt nur dann als bereits versiegelt, wenn er sich tatsächlich unter dem Schlüssel dieser Instanz entschlüsseln lässt —, sodass ein envelope-förmiger Klartext wie jeder andere Body verschlüsselt und nicht für Chiffrat gehalten wird.

Die gesamte Chiffre liegt in apps/api/convex/lib/atRestBodies.ts, und der einzige Entschlüsselungs-Chokepoint ist apps/api/convex/lib/messageBody.ts: Jeder Body-Leser läuft durch diese Accessoren, sodass „beim Lesen entsiegeln“ eine einzige Heimat hat, statt über die Codebasis verstreut zu sein.

Storage-Blobs können Nicht-UTF-8-Bytes tragen (8-Bit-MIME, binäre Anhänge im rohen .eml), daher nutzen sie ein byteweises Geschwister derselben Konstruktion — AES-256-GCM unter einem domänenseparierten Schlüssel (owlat:at-rest:blobs:v1) mit einem kompakten Binär-Envelope (ARBLB1-Magic + Version + IV + Chiffrat) — in derselben lib/atRestBodies.ts. Die Blob-Helfer und der Auslieferungspfad liegen in apps/api/convex/lib/sealedBlob.ts.

Bewusste Ausnahmen (bleiben Klartext)

Drei Oberflächen bleiben absichtlich klartextabgeleitet. Jede ist an ihrer Schema-Index-Definition annotiert, damit die Ausnahme im Code auffindbar ist:

  • Volltext-Suchfelder. Convex indexiert den Klartext eines searchField, eine Versiegelung würde die serverseitige Suche also zerstören. Diese Felder halten ein kurzes Snippet oder extrahierte Schlüsselwörter, nie den vollständigen Body:
    • mailMessages.snippet (search_messages)
    • knowledgeEntries.searchableText (search_knowledge)
    • semanticFiles.searchableText (search_files)
    • Suchfelder von contacts
    • Suchfelder von campaigns
  • Vektor-Embeddings. Beim Ingest aus dem Klartext abgeleitet und als Floats gespeichert (vector_knowledge, vector_files). Sie sind nicht auf den Quelltext umkehrbar und werden für die semantische Suche benötigt.
  • Datenexport pro Kontakt. contacts/dataExport.ts entschlüsselt Bodys beim Erstellen eines DSGVO-Auskunftspakets — das eigene Datenpaket des Betroffenen muss lesbar sein. Das ist die eine dokumentierte Stelle, an der Klartext den Speicher verlässt.

Back-Fill-Migration

apps/api/convex/migrations/0035_seal_bodies_at_rest.ts durchläuft jede Body-tragende Oberfläche seitenweise — cursortragende interne Mutationen für die Inline-Spalten und eine interne Action für die Storage-Blobs (Blob-Inhalte sind nur aus einer Action lesbar) —, angetrieben von einem fortsetzbaren run-Orchestrator bis zum Abschluss. Sie ist idempotent (ein erneuter Lauf überspringt bereits versiegelte Zeilen und Blobs) und lässt mitten im Lauf nie eine Zeile unlesbar zurück. Weil Convex-Storage pro ID unveränderlich ist, liest das Versiegeln eines Blobs ihn ein, speichert die versiegelte Kopie unter einer neuen ID, biegt die Zeile um und löscht den alten Klartext-Blob erst, nachdem die Zeile auf die versiegelte Kopie zeigt. Das Versiegeln ist sharing-bewusst: IMAP COPY teilt einen Storage-Blob über Zeilen hinweg (die Kopie-Zeile verwendet rawStorageId/*BodyStorageId des Originals wieder), deshalb biegt das erneute Versiegeln jede Zeile um, die auf einen alten Blob verweist — gefunden über die Indizes by_raw_storage / by_text_body_storage / by_html_body_storage —, bevor er in einer einzigen Mutation gelöscht wird. Eine Geschwisterkopie bleibt somit nie auf einen gelöschten Blob zeigen.

Storage-Blobs

Das rohe .eml unter rawStorageId und die *BodyStorageId-Blobs großer Nachrichtentexte sind im Ruhezustand mit der Byte-Chiffre versiegelt. Sie werden weiterhin an die Nackt-URL-Konsumenten ausgeliefert — den Postbox-Web-Reader (fetch(url).text()), die prozessexterne IMAP-Bridge (FETCH RFC822), den ausgehenden MTA / den externen SMTP-Worker (der das .eml zum Übertragen holt) und den Rohdownload —, doch statt einer blanken signierten Storage-URL erhalten diese Aufrufer nun eine Entschlüsselungs-Proxy-URL.

GET /sealed-blob (apps/api/convex/mail/sealedBlobHttp.ts) liest den versiegelten Blob, den ein Capability-Token benennt, entsiegelt ihn und streamt die Klartext-Bytes. Das Token ist ein HMAC über storageId . contentType . expiry, geschlüsselt mit INSTANCE_SECRET, und wird erst ausgestellt, nachdem der Aufrufer an der Query-Stelle autorisiert wurde (die Postfach-Eigentümerschaft wird geprüft, bevor irgendeine URL zurückgegeben wird) — passend zur unerratbaren, zeitlich begrenzten Natur der signierten Convex-URL, die es ersetzt. Ein ungültiges/abgelaufenes/gefälschtes Token ergibt schlicht 403. Weil jeder Konsument weiterhin ein einfaches GET macht, das die Originalbytes liefert, ist der Proxy ein transparenter Drop-in und kein prozessexterner Code muss geändert werden.

Der In-Process-Lesepfad (readMailMessageText) entsiegelt Blobs direkt über readSealedBlobText, sodass serverseitige Body-Lesevorgänge nie Chiffrat berühren.

Eine Mutation kann die Bytes eines Blobs weder lesen noch neu speichern (Blob-Inhalte sind nur aus Actions zugänglich), deshalb versiegeln die zwei Pfade, die einen vom Worker hochgeladenen Klartext-Blob annehmen, ihn außerhalb der Reihe, statt ein stehendes Klartext-Residuum zu hinterlassen:

  • IMAP APPEND (mail.imap.appendMessage) lädt das rohe .eml direkt in den Storage und plant nach dem Einfügen der Zeile eine Reseal-Action pro Nachricht ein (resealMessageBlobs, runAfter(0)).
  • Externer IMAP-Sync (mail.externalDelivery.ingestExternalMessage) versiegelt bereits beim Schreiben: Sein einziger Aufrufer ingestExternalRaw ist eine Action, die das rohe .eml und die Body-Blobs versiegelt (storeSealedBlob / splitBodyForStorage), bevor die Mutation läuft.

resealMessageBlobs ist idempotent (ein bereits versiegelter Blob wird beim erneuten Versiegeln zum No-Op) und biegt in einer Mutation um und löscht den alten Klartext-Blob. Im kurzen Fenster zwischen dem Klartext-Schreibvorgang und dem eingeplanten erneuten Versiegeln liest und liefert der Blob über die mischtoleranten Accessoren und den Proxy korrekt aus (openBytesAtRest reicht Alt-Klartext durch). Der Back-Fill bleibt der Nachholpfad für Zeilen, die vor dem Ausliefern von E8b geschrieben wurden.

Abnahme

Ein Datenbank- und Storage-Dump einer eingespielten, migrierten Instanz enthält null Klartext von Nachrichtentexten — über die fünf Inline-Body-Formen und das rohe .eml

  • Body-Blobs hinweg — abgesehen von der oben dokumentierten Suchindex-Ausnahme. Belegt wird das durch den Canary-Test in apps/api/convex/__tests__/sealBodiesAtRest.integration.test.ts, der sowohl die DB-Spalten als auch die gespeicherten Blob-Bytes dumpt.