Files
patrickandClaude Sonnet 5 052d63aadb
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
QA-15 F-06: TOTP-Secrets verschluesselt speichern, Replay-Schutz, Sitzungswiderruf bei Passwortaenderung
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 08:55:54 +02:00

86 lines
2.9 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
"gitea.perlbach24.de/scripte/nexarch/internal/totp"
)
// QA-15 F-06 (c): totp_credentials.secret lag unverschluesselt in der
// Datenbank. Eigener Master-Key statt Wiederverwendung von
// NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY — beide Geheimnisse haben
// unterschiedliche Betriebszyklen (Modul-Signaturschluessel rotieren
// automatisch, dieser hier nie), eine gemeinsame Variable wuerde das
// vermischen. kekCipher (modulekeys.go) erfuellt totp.Cipher strukturell,
// kein zweites Krypto-Schema.
const envTOTPEncryptionKey = "NEXARCH_ACCOUNT_TOTP_ENCRYPTION_KEY"
// newTOTPCipher laedt den Master-Key. Ist er nicht gesetzt, liefert es
// (nil, nil) — Store.NewStore (ohne Verschluesselung) bleibt dann nutzbar
// fuer bestehende Klartext-Zeilen, aber jede NEUE Einrichtung schlaegt fehl
// (Store.encryptSecret: fail closed, kein stiller Klartext-Fallback).
func newTOTPCipher() (totp.Cipher, error) {
key, err := kek.LoadMasterKeyFromEnv(envTOTPEncryptionKey)
if err != nil {
if errors.Is(err, kek.ErrMasterKeyNotSet) {
return nil, nil
}
return nil, fmt.Errorf("%s: %w", envTOTPEncryptionKey, err)
}
return kekCipher{key: key}, nil
}
// encryptExistingTOTPSecrets verschluesselt alle noch im Klartext liegenden
// Secrets EINE ZEILE NACH DER ANDEREN (`-encrypt-totp-secrets`, QA-15 F-06).
// Das Geheimnis selbst bleibt unveraendert, nur die Ablageform aendert sich
// — bestehende Authenticator-Apps bleiben gueltig. Zeilenweise statt in
// einer Transaktion, damit ein einzelner Fehler (z.B. Verbindungsabbruch)
// nicht schon erledigte Zeilen zurueckrollt.
func encryptExistingTOTPSecrets(ctx context.Context, pool *pgxpool.Pool, cipher totp.Cipher) (int, error) {
rows, err := pool.Query(ctx, `SELECT user_id, secret FROM totp_credentials WHERE secret IS NOT NULL AND secret_ciphertext IS NULL`)
if err != nil {
return 0, fmt.Errorf("unverschluesselte totp-secrets lesen: %w", err)
}
type row struct {
userID string
secret string
}
var pending []row
for rows.Next() {
var r row
if err := rows.Scan(&r.userID, &r.secret); err != nil {
rows.Close()
return 0, fmt.Errorf("totp-secret-zeile lesen: %w", err)
}
pending = append(pending, r)
}
if err := rows.Err(); err != nil {
return 0, err
}
rows.Close()
n := 0
for _, r := range pending {
ciphertext, err := cipher.Encrypt([]byte(r.secret))
if err != nil {
return n, fmt.Errorf("secret fuer nutzer %s verschluesseln: %w", r.userID, err)
}
tag, err := pool.Exec(ctx, `
UPDATE totp_credentials SET secret = NULL, secret_ciphertext = $2
WHERE user_id = $1 AND secret IS NOT NULL AND secret_ciphertext IS NULL
`, r.userID, ciphertext)
if err != nil {
return n, fmt.Errorf("secret fuer nutzer %s speichern: %w", r.userID, err)
}
if tag.RowsAffected() == 1 {
n++
}
}
return n, nil
}