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äche | Versiegelt |
|---|---|
inboundMessages | textBody, htmlBody (Inline-Bodys der AI-Inbox) |
mailMessages (inline) | textBodyInline, htmlBodyInline (Snippet des persönlichen Postfachs) |
unifiedMessages | content (der JSON-Body-Blob) |
mailDrafts | bodyHtml, bodyText, bodyBlocks (Entwürfe im Editor) |
conversationThreads | lastPreview (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.tsentschlü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.emldirekt 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 AufruferingestExternalRawist eine Action, die das rohe.emlund 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.