LC-77-SMTP-SEAL: migrate SMTP password to AES-256-GCM-sealed storage #200

Closed
opened 2026-05-25 21:22:04 +02:00 by longjacksonle · 0 comments

Background

LC-77 email ingress (shipped across PRs #194-#199) stores the IMAP-poll password AES-256-GCM-sealed at rest via crate::db::imap_config, modeled on the VAPID precedent (crate::db::vapid). The sealed password is decrypted only at startup, into memory owned by the poll-loop task, and never logged.

The pre-existing SMTP password (used for outbound digest / password reset / email verification) is stored plaintext in the settings key-value table under smtp_pass, written by routes::admin::post_settings. A settings.db leak yields a usable SMTP credential without needing LETS_CHAT_SECRET_KEY.

This inconsistency was flagged during the LC-77 plan and explicitly scoped out of v1 (see PR #195 commit body, docs/email-ingress.md "Security notes" section).

Scope

Migrate SMTP password to the same VAPID-sealed pattern LC-77's IMAP password uses.

Two-commit shape, mirroring the LC-77 commit-2 + commit-3 ordering:

  1. Schema migration under server/migrations/settings/: add smtp_password_encrypted BLOB + smtp_password_nonce BLOB columns (either on a new dedicated smtp_config singleton table mirroring imap_inbox_config, or alongside the existing settings rows). One-time backfill at startup: if the legacy smtp_pass row is populated and the new columns are NULL, seal the plaintext into the new columns, then NULL the legacy row (or drop it in a follow-up migration).

  2. Code change: extend routes::admin::post_settings to call crypto::seal (via a new db::smtp_config::write helper) before writing the password. Update mail::Mailer::from_env (or whichever loader reads SMTP creds) to call crypto::open at startup. Empty password input preserves the existing sealed value (matching both the LC-77 IMAP form UX and the existing SMTP write-only UX).

Test requirements

  • Round-trip test asserting the plaintext password posted to /admin/settings never appears in the on-disk sealed BLOB and never echoes from the GET render. Mirror tests/routes_admin_imap_settings.rs::first_save_seals_password_and_round_trips_via_read.
  • Migration test: existing settings.smtp_pass rows get backfilled into the sealed columns at startup.
  • Both default and saas test suites green.

Anti-scope

  • Does not change the SMTP env-var loader (SMTP_PASSWORD env var stays the operator's way to bootstrap creds without going through the admin UI).
  • Does not change outbound mail behavior; only the at-rest storage shape.

Why now

  • Operator security: settings.db is the most likely backup-target leak surface (small, often shipped in restore archives). Sealing brings SMTP up to the bar VAPID and IMAP-poll already meet.
  • Consistency: docs/email-ingress.md "Security notes" calls out this inconsistency. Closing the gap removes a documented known weakness.

References

  • crate::db::vapid for the seal/open pattern.
  • crate::db::imap_config for the LC-77 v1 mirror.
  • docs/email-ingress.md "Security notes" section.
## Background LC-77 email ingress (shipped across PRs #194-#199) stores the IMAP-poll password AES-256-GCM-sealed at rest via `crate::db::imap_config`, modeled on the VAPID precedent (`crate::db::vapid`). The sealed password is decrypted only at startup, into memory owned by the poll-loop task, and never logged. The pre-existing SMTP password (used for outbound digest / password reset / email verification) is stored **plaintext** in the `settings` key-value table under `smtp_pass`, written by `routes::admin::post_settings`. A `settings.db` leak yields a usable SMTP credential without needing `LETS_CHAT_SECRET_KEY`. This inconsistency was flagged during the LC-77 plan and explicitly scoped out of v1 (see PR #195 commit body, `docs/email-ingress.md` "Security notes" section). ## Scope Migrate SMTP password to the same VAPID-sealed pattern LC-77's IMAP password uses. Two-commit shape, mirroring the LC-77 commit-2 + commit-3 ordering: 1. **Schema migration** under `server/migrations/settings/`: add `smtp_password_encrypted BLOB` + `smtp_password_nonce BLOB` columns (either on a new dedicated `smtp_config` singleton table mirroring `imap_inbox_config`, or alongside the existing `settings` rows). One-time backfill at startup: if the legacy `smtp_pass` row is populated and the new columns are NULL, seal the plaintext into the new columns, then NULL the legacy row (or drop it in a follow-up migration). 2. **Code change**: extend `routes::admin::post_settings` to call `crypto::seal` (via a new `db::smtp_config::write` helper) before writing the password. Update `mail::Mailer::from_env` (or whichever loader reads SMTP creds) to call `crypto::open` at startup. Empty password input preserves the existing sealed value (matching both the LC-77 IMAP form UX and the existing SMTP write-only UX). ## Test requirements - Round-trip test asserting the plaintext password posted to `/admin/settings` never appears in the on-disk sealed BLOB and never echoes from the GET render. Mirror `tests/routes_admin_imap_settings.rs::first_save_seals_password_and_round_trips_via_read`. - Migration test: existing `settings.smtp_pass` rows get backfilled into the sealed columns at startup. - Both default and saas test suites green. ## Anti-scope - Does not change the SMTP env-var loader (`SMTP_PASSWORD` env var stays the operator's way to bootstrap creds without going through the admin UI). - Does not change outbound mail behavior; only the at-rest storage shape. ## Why now - Operator security: `settings.db` is the most likely backup-target leak surface (small, often shipped in restore archives). Sealing brings SMTP up to the bar VAPID and IMAP-poll already meet. - Consistency: `docs/email-ingress.md` "Security notes" calls out this inconsistency. Closing the gap removes a documented known weakness. ## References - `crate::db::vapid` for the seal/open pattern. - `crate::db::imap_config` for the LC-77 v1 mirror. - `docs/email-ingress.md` "Security notes" section.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat#200
No description provided.