/var/lib/oximail. Ce qui protège un disque volé ou décommissionné, c’est le volume : dm-crypt/LUKS sous le répertoire de données, posé par l’exploitant, mesuré par le produit. C’est le modèle qu’ADR-020 nommait dès le départ, « LUKS, pas du bout en bout », enfin réalisé là où LUKS vit, au lieu d’être reconstruit au-dessus.
Pourquoi ce changement
D’avril à septembre 2026, le serveur chiffrait son contenu deux fois : la base par SQLCipher, chaque blob par une clé enveloppée pour son compte ou son organisation. Le coût a été mesuré avant la décision, pas affirmé : un facteur 6 à 20 sur chaque requête tant que la mémoire des clés était verrouillée (strace -c, #1388), un déchiffrement par page que le cache du système ne peut pas absorber puisqu’il porte le chiffré, aucun outil standard sur une base de production (sqlite3, EXPLAIN, VACUUM INTO, mmap), une cérémonie de clé avec escrow et clé papier, et « clé perdue = base perdue », un risque de perte de données créé par le mécanisme lui-même.
Et pour quelle garantie ? Sur la moitié de la flotte, la clé racine était en clair à côté de la base : le voleur du disque partait avec les deux. L’index de recherche était en clair partout. Aucune instance n’avait de chiffrement de volume, cinq l’attestaient. Le prix était payé plein, sans l’outillage et sans la garantie.
La règle qui tranche le modèle : ton serveur, ton root. Quelqu’un qui est déjà root sur la box est hors du modèle de menace. La sécurité se joue aux frontières : qui entre (authentification, app passwords à périmètre), qui voit quoi (droits par nœud revérifiés à chaque requête, partages, la garde des droits), où partent les données (garde d’egress, hôtes régionaux, provenance). Un mécanisme qui protège « le serveur contre son propre root » est suspect par construction, et doit se justifier par une menace réelle et un coût mesuré. Un durcissement se pèse comme une optimisation.
Ce que le chiffrement au repos protège vraiment, application ou volume : le disque qui quitte la machine (vol, déclassement, retour SAV) et la copie qui quitte la box (sauvegarde, instantané, un scp de débogage). Contre le premier, LUKS et SQLCipher sont équivalents. Contre le second, une seule question compte : où dort la clé de cette copie ? Une clé qui voyage sur le même chemin que la donnée n’est pas une clé, c’est une copie de plus.
Ce que le serveur fait, et ce qu’il mesure
Il écrit en clair. Un fichier sous la racine des blobs est son contenu, du premier au dernier octet, nommé par le SHA-256 de ce contenu ; une ligne de registre dit à quel compte ou à quelle organisation il appartient, et rien d’autre. Un gros fichier se sert par lectures positionnées sur le fichier lui-même, par fenêtres d’un mégaoctet, avec le même contrat de plages pour un message de 10 Ko et une archive de 27 Go. La base est un SQLite ordinaire :sqlite3, EXPLAIN et VACUUM INTO fonctionnent sur la production, et un blob se restaure avec cp.
Il mesure le volume, et dit ce qu’il a trouvé. Au démarrage, et à chaque oximail doctor, une sonde remonte la pile device-mapper réelle sous chaque chemin qui porte du contenu (la base, la racine des blobs, et l’index de recherche, qui a sa propre clé de configuration et se place par défaut à côté du répertoire des blobs). Trois issues, aucune silencieuse :
La sonde voit le chiffrement à travers deux couches de volumes fins ; elle ne voit pas le chiffrement natif de ZFS, c’est pourquoi la configuration de référence est LUKS sous LVM. Les journaux et le swap ne sont pas couverts par la mesure : ils relèvent de l’exploitant, et la configuration de référence les traite.
Le démarrage ne refuse jamais (ADR-104) : un volume nu est un avertissement dans le journal, et cet avertissement est la trace du consentement. L’assistant d’installation, lui, refuse : avant d’écrire quoi que ce soit,
oximail setup mesure le chemin des données exactement comme il vérifie l’horloge, continue s’il est chiffré, continue sans rien affirmer s’il ne peut pas mesurer, et s’arrête en expliquant s’il est nu. oximail setup --accept-bare-volume lève le refus en connaissance de cause ; le consentement est écrit dans la configuration et dans l’état de l’assistant, et doctor continue de mesurer :
La configuration de référence du volume
Établie et mesurée sur la flotte (septembre 2026), sur un hôte Proxmox VE avec des conteneurs LXC ; les principes valent ailleurs. L’assistant imprime ces six points quand il mesure un volume nu :- Un volume LUKS2 scopé aux instances OxiMail, pas à la machine entière : un volume logique dédié,
aes-xts-plain64avec une clé de 512 bits,argon2id, secteurs de 4096 octets, sur un groupe de volumes dédié qui ne porte que les conteneurs OxiMail. Les autres invités de l’hôte ne sont pas touchés, et le périmètre chiffré est exactement celui des données à protéger. Le disque de l’hôte lui-même n’a pas besoin d’être chiffré : le déverrouillage se fait dans le système démarré (/etc/crypttab), pas dans l’initramfs. - Déverrouillage sans opérateur : la clé scellée dans le TPM 2.0 (
systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7) plus une clé de secours sur papier, hors de la box ;crypttabavectpm2-device=auto,discard,nofail,headless=true. Un hôte sans écran ne doit jamais attendre une phrase de passe ; avecnofail+headless, un TPM qui ne descelle pas laisse le volume fermé et l’hôte debout, et la clé papier l’ouvre à la main. Sans clé papier, un changement de carte mère ou un firmware qui déplace PCR 7 rend le volume définitivement inaccessible. - Files de travail dm-crypt désactivées, de façon persistante (
cryptsetup --perf-no_read_workqueue --perf-no_write_workqueue --allow-discards --persistent refresh) : sur NVMe, les files ajoutent de la latence sans débit. Mesuré avecfioà QD1 sur le profil aléatoire 4k 70/30 d’une base SQLite : -11 % au lieu de -25 % avec les files. - Swap de l’hôte en dm-crypt à clé aléatoire (
/dev/urandomdanscrypttab), ou zram seul. Sinon une page de la base ou d’une clé qui déborde de la mémoire finit en clair à côté du volume chiffré. - Droits :
/var/lib/oximailen700,data.db*en600, propriétaire = l’utilisateur du service. Le volume protège contre le disque froid ; les droits protègent contre les autres comptes du même système, et depuis que la base est en clair, n’importe quel UID du conteneur la lirait sinon. - Vérification :
oximail doctor --jsonrendat_rest=okmesuré, puis un redémarrage à froid (coupure d’alimentation complète, pas unreboot) qui remonte le volume sans personne à la console. Un redémarrage tiède ne prouve rien sur le scellement TPM à travers un cycle d’alimentation.
Ce que la clé racine protège encore
server.key et tout ce qui l’entoure restent : le service de clés, [security] key_source (systemd-creds sur un hôte qui sait l’envelopper, plaintext avec le drapeau de consentement sinon), les clés maîtresses de tenant, l’escrow et la clé papier, la cérémonie de rotation. Ce n’est pas de l’outillage du chiffrement de contenu. La clé de tenant enveloppe des secrets qui valent quelque chose ailleurs que sur la box : le hash NT des app passwords SMB (un équivalent de mot de passe), les clés d’API des fournisseurs d’IA, les certificats de facturation, l’identité de nœud de fédération, les liens de gestion signés des modules, les jetons porteurs. Une base copiée en clair contient des identifiants valables ailleurs, et c’est exactement pourquoi la copie qui part doit rester chiffrée, par le volume ou par la sauvegarde.
Ce qui change, c’est ce que coûte la perte de la clé : des secrets re-provisionnables, jamais des données. ADR-102 est donc retournée plutôt que remplacée : une sauvegarde peut porter la clé, et l’escrow comme l’exercice de récupération restent tels quels, parce qu’ils gardent toujours la racine d’identité du nœud.
Une commande qui a besoin de la clé, sur une box dont la clé est enveloppée
Aveckey_source = "systemd-creds", la clé est lisible par root et par une unité systemd qui déclare le crédit, et par personne d’autre. Une commande lancée sous le compte de service hors de son unité ne peut pas la déballer. Une commande qui a besoin de la clé s’arrête alors, dit pourquoi, et imprime sa propre ligne de commande placée dans une unité transitoire, à lancer en root :
oximail doctor n’a pas besoin de la clé et tourne comme avant.
scp qui emporte l’escrow hors de la boîte est imprimée. oximail key escrow --guided la rejoue sur une boîte déjà installée, et doctor échoue sur un serveur sans escrow. L’escrow porte aussi le secret d’instance, si bien qu’une boîte restaurée depuis lui sur un nouveau matériel garde son identité et ses sessions ; un escrow plus ancien se réécrit par oximail key upgrade-escrow --yes, et oximail key adopt-identity --yes accepte un secret changé volontairement (sauvegardes).
- La rotation ré-enveloppe les petits objets de clé (le secret du conteneur, les clés de tenant, l’escrow) sous un nouveau
server.key; les valeurs ne changent pas et aucune donnée n’est réécrite.rotateetescrowexigent un exercice de récupération réussi : un escrow jamais testé n’est pas un escrow. purge-pendingdresse une seule liste, la même à blanc et pour de vrai : la clé racine d’avant en clair ou enveloppée, l’escrow périmé, le conteneur pré-rotation et, sur une boxsystemd-creds, l’ancienne clé enveloppée. Une rotation garde la valeur du secret d’instance, donc l’ancien escrow, son ancienne clé papier et l’ancien conteneur suffisent à retrouver la graine des jetons porteurs. Un exercice réussi sur l’escrow courant autorise toute la liste ; un exercice raté ne retire rien ;doctorcompte ce qui reste (rotation_leftovers) et nomme le verbe.- La clé de conteneur (
container.key.wrapped, conservée parfreeze-db-keyetrotate) est le secret d’instance qui sème la clé HMAC des jetons porteurs (ADR-071 Am. 1). Son nom dit encore « db key » : c’est la clé qu’elle était jusqu’en septembre, et le renommage est différé.
L’intégrité sans sceau
Le sceau garantissait qu’un blob servi était le blob stocké. La garantie est passée dans le nom : le nom d’un fichier est le SHA-256 de ses octets, et la vérification est gratuite.oximail blob verifycompte un fichier comme CORRUPT quand son empreinte ne donne plus son nom ;--quarantinele marque pour que les surfaces de lecture servent un substitut au lieu de réessayer et d’inonder le journal, et met la copie corrompue de côté sous le nom<hash>.bad. L’échantillon (1000 par défaut,--fullpour tout) est tiré au hasard ;--walkcompte à part les copies.badet les chemins disparus, au lieu d’échouer.- Une copie corrompue guérit à la prochaine écriture de son contenu. Une copie stockée n’est réutilisée que si elle n’est pas en quarantaine et a la longueur du contenu. Sinon, elle est mise de côté sous le nom
<hash>.bad, le contenu est réécrit et la quarantaine levée. La même pièce jointe qui arrive à nouveau la répare donc, là où le serveur gardait la copie corrompue (et un envoi en flux jetait le contenu sain qu’il venait de recevoir). Une ligne de registre dont le fichier a disparu est réparée de la même façon. oximail blob verify --walkpart du disque et non du registre : pour chaque fichier sous l’arbre d’un tenant, une ligne de registre existe-t-elle, et les octets donnent-ils bien le nom. Lecture seule ; sortie 1 au moindre fichier hors registre ou à la moindre divergence ;--jsonpour la supervision.doctor(blob_registry) fait le même parcours sans hacher et signale les fichiers que personne ne sait lire. Ni l’un ni l’autre ne supprime : c’est le rôle du ramasse-miettes.- Une restauration de sauvegarde refuse un blob scellé. Ce binaire n’a plus de lecteur pour l’en-tête
OXIMBLOBdes versions précédentes ;oximail restores’arrête avant d’écrire quoi que ce soit et nomme le binaire de l’époque de la sauvegarde. Une sauvegarde qui emporte le binaire de son temps se restaure avec ce lecteur. - Deux gardes de source ont remplacé l’ancienne garantie du système de types : rien n’écrit sous la racine des blobs hors du magasin de blobs, et chaque lecteur direct est recensé avec sa raison.
L’effacement, et ce qu’une sauvegarde emporte
L’effacement supprime. Il n’y a plus d’effacement cryptographique : l’effacement de compte retire les lignes, les fichiers de blobs et les documents d’index, et le service de purge déclenche désormais, à la fin d’une campagne, une réécriture des segments de l’index de recherche qui portaient des marques de suppression, pour que le texte d’un message détruit quitte les fichiers d’index et pas seulement l’index. La vérification qu’aucun autre objet ne référence encore une pièce jointe se fait par lots de cinq cents, si bien qu’une purge de cent mille fichiers libère son disque en secondes et non en jours. La sauvegarde de référence est celle de l’hyperviseur, chiffrée côté client. Elle restaure le conteneur entier, avec le binaire de son époque : il n’y a donc ni « ancien lecteur » à maintenir, ni horizon de format au-delà de la rétention des instantanés. Ce qui appartient à l’exploitant, c’est la garde de la clé de sauvegarde (une copie unique nommée, hors du chemin des données, connue d’une seconde personne) et la rétention (un espace de noms dédié à purge courte, pour qu’un compte effacé ne survive pas des années en froid). La file du relais sortant est exclue de cette sauvegarde par point de montage (ADR-149) : un instantané pris entre l’accusé et le ramasse-miettes emporterait sinon du contenu relayé pour toute la rétention.oximail backup reste pour la restauration par organisation, sur une autre box, et pour les installations sans hyperviseur. Elle écrit sur la box par défaut, donc sous le volume chiffré ; --encrypt l’enveloppe sous une phrase de passe dérivée par Argon2id pour une copie qui part. Sa data.db est un SQLite ordinaire et n’a besoin d’aucune clé pour s’ouvrir ; la page d’exploitation décrit le mode par tenant. « Aucune base en clair ne quitte la box » est une règle de procédure, pas de code, parce qu’un montage réseau ne se distingue pas d’un disque local.
Ce qui est écrit, pas tu
- Un VPS chez un tiers est hors du modèle et le dit au démarrage.
- Un hôte dont la clé de déverrouillage est scellée dans son TPM protège le disque qui quitte la carte mère, pas le serveur entier emporté.
- Nommer les blobs par l’empreinte de leur contenu en clair est un oracle d’existence d’un document connu pour qui lit le disque. C’était déjà vrai ; c’est désormais documenté.
- Un rançongiciel root n’est pas arrêté par le chiffrement des sauvegardes ; il l’est par une sauvegarde que la box ne peut pas supprimer (la box ne détient aucun identifiant de sauvegarde ; le second niveau est un tirage qui ne propage pas les suppressions).
- Les processus colocataires d’un conteneur (un proxy, un agent de supervision) lisent un blob en clair sans passer par OxiMail : UID distinct et
0700sur la racine des blobs.
Retiré, et que faire d’un TOML qui le dit encore
[storage] encrypted = true est une pierre tombale : la clé est encore lue pour qu’un ancien fichier se charge, et true est refusé au démarrage avec l’instruction de migration, jamais ignoré en silence, parce qu’un fichier SQLCipher ne s’ouvrirait pas comme un SQLite. Une box qui a sauté la migration exécute d’abord oximail migrate-db --to-plaintext avec la version précédente, puis pose encrypted = false. Les verbes migrate-db, encrypt-blobs, blob repack et account regenerate-key ont disparu, ainsi que les routes de clés JMAP ; le binaire livré embarque un SQLite standard et ne dépend d’aucune bibliothèque système de cryptographie, de TLS ou de SQLite, ce que le chemin de déploiement vérifie toujours.
Ce que ce n’est pas
- Pas du chiffrement de bout en bout. Le serveur lit le contenu pour les requêtes authentifiées. Le bout en bout pour certaines surfaces est un sujet distinct, aux compromis distincts (la recherche, les passerelles DAV et le filtrage côté serveur cessent tous de fonctionner sous E2E).
- Pas quelque chose que le serveur peut fournir. Il mesure le volume et refuse de s’installer sur un volume nu sans consentement ; il ne peut pas chiffrer le volume à votre place.