Files
archivmail/internal/smtpoutconfig/store.go
T
sysopsandClaude Sonnet 5 99e4c1bbe4 fix(security): Fail-closed Tenant-Filter in v1-Suche + SMTP-Out-Key-Kette gehärtet
Security-Audit-Nachtrag (siehe PROJ-64):

- internal/api/v1_handlers.go: handleV1SearchMails fehlte der fail-closed
  Tenant-Post-Filter-Fallback für den Fall idxMgr==nil (gleiches Muster wie
  bereits in search_handlers.go). Aktuell nicht ausnutzbar, da idxMgr in
  main.go immer gesetzt wird, aber strukturelle Absicherung gegen künftige
  Regressionen (analog PROJ-55 BUG-1).
- internal/smtpoutconfig/store.go: Verschlüsselungsschlüssel wird jetzt aus
  dem HKDF-abgeleiteten aesKey gebildet statt aus dem rohen cfg.API.Secret,
  konsistent mit internal/ldapconfig und internal/imap/store.go (SEC-08).
  Verifiziert: smtp_out_config auf Produktiv (131) war leer, kein
  Breaking Change für bestehend gespeicherte Zugangsdaten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:48:55 +02:00

210 lines
6.2 KiB
Go

// Package smtpoutconfig persists the outbound SMTP relay configuration in
// PostgreSQL. Exactly one record may exist (id=1). The SMTP password is
// encrypted with AES-256-GCM using the same scheme as internal/ldapconfig.
package smtpoutconfig
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// SMTPOutConfig is the persisted outbound SMTP relay configuration.
type SMTPOutConfig struct {
ID int64 `json:"id"`
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"` // masked as "••••••" in GET responses
TLS bool `json:"tls"`
From string `json:"from"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by"`
}
const createTableSQL = `
CREATE TABLE IF NOT EXISTS smtp_out_config (
id BIGSERIAL PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
host TEXT NOT NULL DEFAULT '',
port INT NOT NULL DEFAULT 587,
usr TEXT NOT NULL DEFAULT '',
password BYTEA,
tls BOOLEAN NOT NULL DEFAULT false,
from_addr TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT ''
);
`
// Store manages SMTP-Out configuration persistence.
type Store struct {
pool *pgxpool.Pool
encKey [32]byte
}
// New connects to PostgreSQL, creates the table if needed, and returns a Store.
// secret is the HKDF-derived aesKey (see main.go, SEC-08) — not the raw
// cfg.API.Secret — so this store shares the same key-derivation lineage as
// internal/ldapconfig and internal/imap/store.go. The "-smtpout" suffix keeps
// this store's encryption key domain-separated from those.
func New(dsn, secret string) (*Store, error) {
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("smtpoutconfig: connect: %w", err)
}
key := sha256.Sum256([]byte(secret + "-smtpout"))
s := &Store{pool: pool, encKey: key}
if _, err := pool.Exec(ctx, createTableSQL); err != nil {
pool.Close()
return nil, fmt.Errorf("smtpoutconfig: init schema: %w", err)
}
return s, nil
}
// Close releases the connection pool.
func (s *Store) Close() { s.pool.Close() }
// Get returns the SMTP-Out configuration with the password masked.
// Returns nil, nil when no configuration has been saved yet.
func (s *Store) Get(ctx context.Context) (*SMTPOutConfig, error) {
cfg, err := s.query(ctx)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, nil
}
if cfg.Password != "" {
cfg.Password = "••••••"
}
return cfg, nil
}
// GetWithPassword returns the configuration including the decrypted password.
func (s *Store) GetWithPassword(ctx context.Context) (*SMTPOutConfig, error) {
return s.query(ctx)
}
// Save upserts the SMTP-Out configuration (always uses id=1).
// When password is empty the existing stored password is preserved.
func (s *Store) Save(ctx context.Context, cfg SMTPOutConfig, updatedBy string) error {
var encPw []byte
var err error
if cfg.Password != "" {
encPw, err = s.encrypt(cfg.Password)
if err != nil {
return fmt.Errorf("smtpoutconfig: encrypt password: %w", err)
}
} else {
// Preserve existing password
existing, qErr := s.query(ctx)
if qErr == nil && existing != nil && existing.Password != "" {
encPw, err = s.encrypt(existing.Password)
if err != nil {
return fmt.Errorf("smtpoutconfig: re-encrypt: %w", err)
}
}
}
_, err = s.pool.Exec(ctx, `
INSERT INTO smtp_out_config (id, enabled, host, port, usr, password, tls, from_addr, updated_at, updated_by)
VALUES (1, $1, $2, $3, $4, $5, $6, $7, NOW(), $8)
ON CONFLICT (id) DO UPDATE SET
enabled = EXCLUDED.enabled,
host = EXCLUDED.host,
port = EXCLUDED.port,
usr = EXCLUDED.usr,
password = CASE WHEN EXCLUDED.password IS NULL THEN smtp_out_config.password ELSE EXCLUDED.password END,
tls = EXCLUDED.tls,
from_addr = EXCLUDED.from_addr,
updated_at = NOW(),
updated_by = EXCLUDED.updated_by
`, cfg.Enabled, cfg.Host, cfg.Port, cfg.User, encPw, cfg.TLS, cfg.From, updatedBy)
if err != nil {
return fmt.Errorf("smtpoutconfig: upsert: %w", err)
}
return nil
}
// Delete removes the SMTP-Out configuration.
func (s *Store) Delete(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `DELETE FROM smtp_out_config WHERE id = 1`)
return err
}
func (s *Store) query(ctx context.Context) (*SMTPOutConfig, error) {
row := s.pool.QueryRow(ctx,
`SELECT id, enabled, host, port, usr, password, tls, from_addr, updated_at, updated_by
FROM smtp_out_config WHERE id = 1`,
)
var cfg SMTPOutConfig
var encPw []byte
err := row.Scan(&cfg.ID, &cfg.Enabled, &cfg.Host, &cfg.Port, &cfg.User, &encPw, &cfg.TLS, &cfg.From, &cfg.UpdatedAt, &cfg.UpdatedBy)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("smtpoutconfig: query: %w", err)
}
if len(encPw) > 0 {
pw, err := s.decrypt(encPw)
if err != nil {
return nil, fmt.Errorf("smtpoutconfig: decrypt: %w", err)
}
cfg.Password = pw
}
return &cfg, nil
}
// ── AES-256-GCM helpers ───────────────────────────────────────────────────────
func (s *Store) encrypt(plain string) ([]byte, error) {
block, err := aes.NewCipher(s.encKey[:])
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, []byte(plain), nil), nil
}
func (s *Store) decrypt(data []byte) (string, error) {
block, err := aes.NewCipher(s.encKey[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
ns := gcm.NonceSize()
if len(data) < ns {
return "", fmt.Errorf("smtpoutconfig: ciphertext too short")
}
plain, err := gcm.Open(nil, data[:ns], data[ns:], nil)
if err != nil {
return "", fmt.Errorf("smtpoutconfig: decrypt: %w", err)
}
return string(plain), nil
}