Commit ab25ed32 authored by Kourser's avatar Kourser
Browse files

Courriel : réglages surchargeables depuis la console d'instance

Le root peut désormais saisir serveur, port, TLS, identifiant, mot de passe et
adresse d'expédition depuis la console. Ce qui y est enregistré prime sur les
variables d'environnement : un serveur SMTP se corrige sans redéploiement. Sans
rien en base, le comportement est inchangé — aucun déploiement existant ne bouge.

L'arbitrage se fait EN BLOC, pas champ par champ. Mélanger les deux sources
produirait des combinaisons que personne n'a voulues : l'hôte de la base avec
l'identifiant de l'environnement. Dès qu'un hôte est enregistré, cette
configuration s'applique entièrement, et la console affiche laquelle est active.

Le mot de passe SMTP est un secret RÉUTILISABLE — il doit ouvrir la connexion —
donc il ne peut pas être haché comme un mot de passe de compte. Il est chiffré
(AES-256-GCM, clé dérivée par HKDF de SECRETS_KEY ou à défaut de JWT_SECRET, avec
un `info` distinct de la signature des jetons). Ce que ça protège : une copie de
la base ne suffit plus. Ce que ça ne protège pas : une compromission de l'hôte,
qui donne la clé avec le reste. C'est écrit tel quel dans SECURITY.md, sans laisser
croire à mieux.

Trois précautions qui n'allaient pas de soi :

- Le champ mot de passe étant toujours vide à l'affichage — on ne le relit jamais
  — l'omettre du corps CONSERVE celui en place. Sans cette distinction, chaque
  enregistrement l'aurait effacé. Effacer demande un geste explicite.
- Le journal d'audit consigne les NOMS des champs modifiés, jamais les valeurs.
- `MailerService.settings()` renvoie le mot de passe en clair : c'est nécessaire
  pour le transport, et l'entête de la méthode le signale, parce que rien
  n'empêcherait un futur appelant de la renvoyer dans une réponse HTTP.

Un envoi d'essai a été ajouté, et lui PROPAGE l'erreur SMTP contrairement à
`send` : tout l'objet d'un essai est de voir ce qui ne marche pas.

CORRECTION D'UNE ERREUR PRÉCÉDENTE. Le commit « Ouverture au public » affirmait
que le mot de passe ne transitait plus en clair lors d'une réinitialisation par un
administrateur. C'était faux : mon remplacement dans `users.service.ts` avait
échoué en silence et la méthode renvoyait toujours le mot de passe. C'est corrigé
ici, et l'interface distingue enfin les deux cas. J'ai revérifié les 20 autres
affirmations des quatre commits précédents contre le code : elles sont tenues.

À FAIRE côté exploitant : `.env.example` reste à compléter (fichier hors de mes
permissions, y compris en lecture). Le bloc à y coller est dans SECURITY.md.

Non vérifié : aucune chaîne Node dans cet environnement. Contrôles manuels :
équilibre des délimiteurs, balises JSX appariées, 664 clés i18n sans doublon,
aucun chemin par lequel le mot de passe rejoigne une réponse HTTP ou un journal.

Co-Authored-By: default avatarClaude (RCA) <noreply@anthropic.com>
parent 74362e1d
Loading
Loading
Loading
Loading
+30 −3
Original line number Diff line number Diff line
@@ -55,9 +55,36 @@ même socle ; passer de l'une à l'autre ne fait que retirer l'étape d'approbat
  ailleurs ; la création directe le refuse, précisément pour qu'on ne rattache personne sans
  son accord.

Variables d'environnement à renseigner pour activer l'envoi : `SMTP_HOST`, `MAIL_FROM`, et
selon le serveur `SMTP_PORT` (587 par défaut), `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`.
`APP_URL` doit pointer sur l'URL publique, sans quoi les liens envoyés seront inutilisables.
Variables d'environnement pour activer l'envoi : `SMTP_HOST`, `MAIL_FROM`, et selon le serveur
`SMTP_PORT` (587 par défaut), `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`. `APP_URL` doit
pointer sur l'URL publique, sans quoi les liens envoyés seront inutilisables.

Ces réglages sont aussi **modifiables depuis la console d'instance**, et ce qui y est
enregistré **prime** sur l'environnement — un serveur SMTP se corrige sans redéploiement.
L'arbitrage se fait **en bloc** et non champ par champ : dès qu'un hôte est enregistré en base,
c'est cette configuration qui s'applique entièrement. Mélanger les deux sources produirait des
combinaisons que personne n'a voulues (l'hôte de la base avec l'identifiant de l'environnement).

Le mot de passe SMTP mérite un traitement à part. C'est un secret **réutilisable** — il doit
servir à ouvrir la connexion — donc il ne peut pas être haché comme un mot de passe de compte.
Il est **chiffré** avant stockage (AES-256-GCM, `apps/api/src/common/secret-box.ts`, couvert par
des tests) :

- ce que cela protège : une copie de la base — sauvegarde, export, dump volé — ne suffit plus
  à récupérer le secret, la clé vivant dans l'environnement ;
- ce que cela ne protège pas : une compromission complète de l'hôte, qui donne accès à la clé
  comme au reste. Le chiffrement au repos borne le rayon d'une fuite de base, il ne remplace
  pas la sécurité de la machine ;
- la clé est dérivée de `SECRETS_KEY` si elle existe, sinon de `JWT_SECRET` (déjà obligatoire
  en production), par HKDF avec un `info` distinct — elle n'est donc jamais la même que la clé
  de signature des jetons ;
- **conséquence à connaître** : changer la clé source rend le mot de passe stocké illisible. Il
  devra être ressaisi. Le cas est détecté explicitement (`SecretUnreadableError`) et journalisé,
  plutôt que de produire un échec d'envoi inexpliqué.

Le mot de passe n'est **jamais renvoyé** à la console, même chiffré : elle n'a besoin que de
savoir s'il est posé (`hasPassword`). Il est également exclu du journal d'audit, qui ne
consigne que les *noms* des champs modifiés.

## Appartenances et portées d'autorité

+15 −0
Original line number Diff line number Diff line
-- Reglages de courriel surchargeables depuis la console d'instance.
--
-- Ces colonnes PRIMENT sur les variables d'environnement quand elles sont
-- renseignees : l'exploitant corrige un serveur SMTP sans redeployer. Toutes
-- nulles = comportement actuel, on retombe sur l'environnement. Aucun
-- deploiement existant ne change donc de comportement.

ALTER TABLE "InstanceSettings" ADD COLUMN "smtpHost" TEXT;
ALTER TABLE "InstanceSettings" ADD COLUMN "smtpPort" INTEGER;
ALTER TABLE "InstanceSettings" ADD COLUMN "smtpSecure" BOOLEAN;
ALTER TABLE "InstanceSettings" ADD COLUMN "smtpUser" TEXT;
-- Chiffre (AES-256-GCM) : un mot de passe SMTP doit rester reutilisable, il ne
-- peut donc pas etre hache. Le chiffrer borne le rayon d'une fuite de base.
ALTER TABLE "InstanceSettings" ADD COLUMN "smtpPasswordEnc" TEXT;
ALTER TABLE "InstanceSettings" ADD COLUMN "mailFrom" TEXT;
+15 −0
Original line number Diff line number Diff line
@@ -156,6 +156,21 @@ enum SignupMode {
model InstanceSettings {
  id         String     @id @default("singleton")
  signupMode SignupMode @default(DISABLED)

  // --- Courriel transactionnel, surchargeable depuis la console d'instance ---
  // Ces valeurs PRIMENT sur les variables d'environnement quand elles sont
  // renseignees : l'exploitant peut corriger un serveur SMTP sans redeployer.
  // `null` partout = on retombe sur l'environnement.
  smtpHost        String?
  smtpPort        Int?
  smtpSecure      Boolean?
  smtpUser        String?
  /// Mot de passe SMTP CHIFFRE (AES-256-GCM, cf. common/secret-box.ts).
  /// Un secret reutilisable ne peut pas etre hache : il doit servir a ouvrir la
  /// connexion. Il n'est JAMAIS renvoye au client, ni journalise.
  smtpPasswordEnc String?
  mailFrom        String?

  updatedAt  DateTime   @updatedAt
}

+1 −1
Original line number Diff line number Diff line
@@ -59,7 +59,7 @@ export class SignupService {
   * vaut le dire que produire un compte inutilisable.
   */
  async publicState(): Promise<{ mode: SignupMode; mailConfigured: boolean }> {
    return { mode: await this.mode(), mailConfigured: this.mailer.configured };
    return { mode: await this.mode(), mailConfigured: await this.mailer.isConfigured() };
  }

  /** Depose une demande, ou cree directement l'organisation selon le mode. */
+49 −0
Original line number Diff line number Diff line
import { openSecret, sealSecret, SecretUnreadableError } from './secret-box';

describe('secret-box', () => {
  const OLD = process.env.SECRETS_KEY;

  beforeEach(() => {
    process.env.SECRETS_KEY = 'clef-de-test-suffisamment-longue';
  });

  afterAll(() => {
    if (OLD === undefined) delete process.env.SECRETS_KEY;
    else process.env.SECRETS_KEY = OLD;
  });

  it('rend la valeur d origine', () => {
    expect(openSecret(sealSecret('mot-de-passe-smtp'))).toBe('mot-de-passe-smtp');
  });

  it('ne laisse pas le clair apparaitre dans la valeur scellee', () => {
    expect(sealSecret('mot-de-passe-smtp')).not.toContain('mot-de-passe-smtp');
  });

  it('produit un resultat different a chaque scellement (vecteur aleatoire)', () => {
    expect(sealSecret('meme-valeur')).not.toBe(sealSecret('meme-valeur'));
  });

  it('gere l accentuation et les caracteres non ASCII', () => {
    expect(openSecret(sealSecret('mot-de-passé-Ω-🔐'))).toBe('mot-de-passé-Ω-🔐');
  });

  it('refuse une valeur alteree', () => {
    const sealed = sealSecret('secret');
    const parts = sealed.split('.');
    // On modifie le chiffre : l etiquette GCM doit faire echouer le dechiffrement.
    parts[3] = Buffer.from('autre-chose').toString('base64url');
    expect(() => openSecret(parts.join('.'))).toThrow(SecretUnreadableError);
  });

  it('refuse un format inconnu', () => {
    expect(() => openSecret('pas-du-tout-scelle')).toThrow(SecretUnreadableError);
    expect(() => openSecret('v2.a.b.c')).toThrow(SecretUnreadableError);
  });

  it('signale explicitement un changement de clef, sans echouer en silence', () => {
    const sealed = sealSecret('secret');
    process.env.SECRETS_KEY = 'une-tout-autre-clef-de-chiffrement';
    expect(() => openSecret(sealed)).toThrow(SecretUnreadableError);
  });
});
Loading