QA-15 F-06: TOTP-Secrets verschluesselt speichern, Replay-Schutz, Sitzungswiderruf bei Passwortaenderung
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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-22 08:55:54 +02:00
1 parent d86db439b4
commit 052d63aadb
7 files changed
+300 -23

No files matched your search

+26 -3
View File
@@ -50,6 +50,7 @@ func envInt(name string, def, min, max int) int {
func main() {
rotateModuleKey := flag.Bool("rotate-module-key", false, "neuen Modul-Signaturschluessel anlegen und beenden")
encryptTOTPSecrets := flag.Bool("encrypt-totp-secrets", false, "QA-15 F-06: bestehende Klartext-TOTP-Secrets nachverschluesseln und beenden")
flag.Parse()
dsn := requireEnv("NEXARCH_ACCOUNT_TENANT_DSN")
tenantSlug := requireEnv("NEXARCH_ACCOUNT_TENANT_SLUG")
@@ -70,12 +71,33 @@ func main() {
}
defer pool.Close()
totpCipher, err := newTOTPCipher()
if err != nil {
log.Fatalf("totp-verschluesselung: %v", err)
}
if totpCipher == nil {
log.Printf("account-api: %s nicht gesetzt — bestehende Klartext-TOTP-Secrets bleiben lesbar, NEUE Einrichtungen schlagen fehl (fail closed, QA-15 F-06)", envTOTPEncryptionKey)
}
totpStore := totp.NewEncryptedStore(pool, totpCipher)
if *encryptTOTPSecrets {
if totpCipher == nil {
log.Fatalf("-encrypt-totp-secrets: %s muss gesetzt sein", envTOTPEncryptionKey)
}
n, err := encryptExistingTOTPSecrets(ctx, pool, totpCipher)
if err != nil {
log.Fatalf("totp-secrets nachverschluesseln: %v", err)
}
log.Printf("account-api: %d totp-secret(s) nachverschluesselt", n)
return
}
users := user.NewTenantUserStore(pool)
totpStore := totp.NewStore(pool)
tokenIssuer := auth.NewTokenIssuer(jwtSecret)
loginService := auth.NewLoginService(users, tokenIssuer, tenantSlug)
authHandler := auth.NewHandler(loginService, tokenIssuer, auth.NewRevocationStore(pool), nil)
revocationStore := auth.NewRevocationStore(pool)
authHandler := auth.NewHandler(loginService, tokenIssuer, revocationStore, nil)
// QA-15 F-04: Kontosperre (Postgres, geteilt) und Begrenzung je Quelladresse.
lockStore := lockout.NewStore(pool).WithPolicy(
envInt("NEXARCH_ACCOUNT_LOCKOUT_MAX_FAILED", lockout.DefaultMaxFailedAttempts, 1, 100),
@@ -96,7 +118,8 @@ func main() {
time.Duration(envInt("NEXARCH_ACCOUNT_LOGIN_WINDOW_SECONDS", 60, 1, 3600))*time.Second)
totpHandler := totp.NewHandler(users, totpStore, loginService, totpIssuer).WithLoginGuard(lockStore, ipLimiter, trustForwarded)
profileHandler := auth.NewProfileHandler(users)
changePasswordHandler := auth.NewChangePasswordHandler(users)
// QA-15 F-06: alte Sitzungen nach einer Passwortaenderung widerrufen.
changePasswordHandler := auth.NewChangePasswordHandler(users).WithSessionRotation(revocationStore, tokenIssuer)
resetStore := authtoken.NewStore(pool)
resetHandler := authtoken.NewHandler(resetStore, users, authtoken.LogNotifier{})
+85
View File
@@ -0,0 +1,85 @@
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
}
+46 -1
View File
@@ -12,18 +12,43 @@ import (
// Handler (Login/Logout), weil hier zusaetzlich der TenantUserStore fuer das
// Aktualisieren des Hashes gebraucht wird.
type ChangePasswordHandler struct {
users *user.TenantUserStore
users *user.TenantUserStore
revocation *RevocationStore
issuer *TokenIssuer
}
// NewChangePasswordHandler mit revocation/issuer = nil erhaelt das
// bisherige Verhalten (kein Sitzungswiderruf) — genutzt nur dort, wo kein
// RevocationStore verfuegbar ist. Produktivcode (cmd/account-api) MUSS
// beide setzen, siehe WithSessionRotation.
func NewChangePasswordHandler(users *user.TenantUserStore) *ChangePasswordHandler {
return &ChangePasswordHandler{users: users}
}
// WithSessionRotation aktiviert QA-15 F-06: nach einer Passwortaenderung
// werden ALLE zuvor ausgestellten Tokens dieses Nutzers widerrufen (der
// Handler-Kommentar hatte das schon immer versprochen — "alte Session
// unbrauchbar machen" — ohne es umzusetzen). Der Aufrufer selbst bleibt
// angemeldet: er bekommt ein frisches Token in der Antwort.
func (h *ChangePasswordHandler) WithSessionRotation(revocation *RevocationStore, issuer *TokenIssuer) *ChangePasswordHandler {
h.revocation = revocation
h.issuer = issuer
return h
}
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
type changePasswordResponse struct {
// Token ist nur gesetzt, wenn WithSessionRotation aktiv ist — das alte
// Token des Aufrufers ist dann durch RevokeAllForUser ebenfalls
// widerrufen und muss ersetzt werden, sonst waere der Aufrufer durch die
// eigene Aktion ausgesperrt.
Token string `json:"token,omitempty"`
}
// ChangePassword verlangt das aktuelle Passwort, bevor ein neues gesetzt wird
// — ohne diese Pruefung koennte ein gekapertes, noch gueltiges Session-Cookie
// allein zur vollstaendigen Kontoübernahme reichen (Passwort setzen, alte
@@ -61,5 +86,25 @@ func (h *ChangePasswordHandler) ChangePassword(w http.ResponseWriter, r *http.Re
return
}
resp := changePasswordResponse{}
if h.revocation != nil && h.issuer != nil {
if err := h.revocation.RevokeAllForUser(r.Context(), claims.UserID); err != nil {
// Das Passwort ist bereits geaendert; ein fehlgeschlagener
// Widerruf darf das nicht rueckgaengig machen, aber die Antwort
// muss es sichtbar machen statt still ein altes Token gueltig
// zu lassen.
http.Error(w, "Passwort geaendert, alte Sitzungen konnten aber nicht widerrufen werden. Bitte erneut anmelden.", http.StatusInternalServerError)
return
}
token, err := h.issuer.Issue(claims.UserID, claims.TenantSlug)
if err != nil {
http.Error(w, "Passwort geaendert, neue Sitzung konnte aber nicht ausgestellt werden. Bitte erneut anmelden.", http.StatusInternalServerError)
return
}
resp.Token = token
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
+106 -15
View File
@@ -17,18 +17,70 @@ var (
ErrInvalidCode = errors.New("totp: code ungueltig")
ErrNotConfirmed = errors.New("totp: einrichtung noch nicht bestaetigt")
ErrRecoveryCodeUsed = errors.New("totp: wiederherstellungscode bereits verwendet oder ungueltig")
// ErrSecretUnavailable wird geliefert, wenn ein gespeichertes Secret ohne
// gueltigen Cipher gelesen werden muss (QA-15 F-06: fail closed statt
// eines stillen Klartext-Fallbacks).
ErrSecretUnavailable = errors.New("totp: secret kann ohne verschluesselung nicht gelesen werden")
)
const RecoveryCodeCount = 10
type Store struct {
pool *pgxpool.Pool
// Cipher verschluesselt/entschluesselt das TOTP-Secret vor der Ablage
// (QA-15 F-06). Dieselbe Formgebung wie moduletrust.SecretCipher — ein
// *cmd/account-api.kekCipher erfuellt beide Schnittstellen strukturell,
// ohne zweiten Verschluesselungsweg im Prozess.
type Cipher interface {
Encrypt(plaintext []byte) ([]byte, error)
Decrypt(ciphertext []byte) ([]byte, error)
}
type Store struct {
pool *pgxpool.Pool
cipher Cipher
}
// NewStore erstellt einen Store OHNE Verschluesselung — nur fuer Tests und
// fuer den Aufruf aus Werkzeugen, die die Secret-Spalte selbst nicht lesen
// (z.B. reine Migrationspruefungen). Produktivcode MUSS NewEncryptedStore
// verwenden (main.go erzwingt das ueber den Cipher-Parameter).
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// NewEncryptedStore erstellt einen Store, der neue/geaenderte Secrets ueber
// cipher verschluesselt ablegt und beim Lesen entschluesselt. cipher darf
// nicht nil sein.
func NewEncryptedStore(pool *pgxpool.Pool, cipher Cipher) *Store {
return &Store{pool: pool, cipher: cipher}
}
func (s *Store) encryptSecret(plaintext string) (ciphertext []byte, err error) {
if s.cipher == nil {
return nil, ErrSecretUnavailable
}
return s.cipher.Encrypt([]byte(plaintext))
}
// decodeSecret liest das Secret aus row (secret, secret_ciphertext). Ist ein
// Chiffrat vorhanden, wird es entschluesselt (fail closed ohne Cipher oder
// bei falschem Schluessel). Fehlt das Chiffrat (Altzeile vor der
// Verschluesselungsumstellung), gilt die Klartextspalte weiter als
// gueltiger Wert — kein erzwungener Bruch bestehender Einrichtungen, aber
// jede SCHREIBENDE Operation dieses Stores verschluesselt ab sofort.
func (s *Store) resolveSecret(plainCol string, cipherCol []byte) (string, error) {
if len(cipherCol) == 0 {
return plainCol, nil
}
if s.cipher == nil {
return "", ErrSecretUnavailable
}
pt, err := s.cipher.Decrypt(cipherCol)
if err != nil {
return "", fmt.Errorf("totp: secret entschluesseln: %w", err)
}
return string(pt), nil
}
// BeginSetup erzeugt ein neues Secret fuer einen Benutzer und speichert es
// als NOCH NICHT bestaetigt — der Code muss vor Aktivierung einmal
// verifiziert werden (Akzeptanzkriterium 1).
@@ -38,11 +90,16 @@ func (s *Store) BeginSetup(ctx context.Context, userID, issuer, accountName stri
return "", "", err
}
ciphertext, err := s.encryptSecret(secret)
if err != nil {
return "", "", fmt.Errorf("totp-einrichtung: %w", err)
}
_, err = s.pool.Exec(ctx, `
INSERT INTO totp_credentials (user_id, secret, confirmed)
VALUES ($1, $2, false)
ON CONFLICT (user_id) DO UPDATE SET secret = $2, confirmed = false
`, userID, secret)
INSERT INTO totp_credentials (user_id, secret, secret_ciphertext, confirmed)
VALUES ($1, NULL, $2, false)
ON CONFLICT (user_id) DO UPDATE SET secret = NULL, secret_ciphertext = $2, confirmed = false
`, userID, ciphertext)
if err != nil {
return "", "", fmt.Errorf("totp-einrichtung speichern: %w", err)
}
@@ -55,15 +112,20 @@ func (s *Store) BeginSetup(ctx context.Context, userID, issuer, accountName stri
// Wiederherstellungscodes erzeugt und im Klartext zurueckgegeben — gespeichert
// wird nur deren Hash.
func (s *Store) ConfirmSetup(ctx context.Context, userID, code string) (recoveryCodes []string, err error) {
var secret string
var plainCol *string
var cipherCol []byte
if err := s.pool.QueryRow(ctx, `
SELECT secret FROM totp_credentials WHERE user_id = $1
`, userID).Scan(&secret); err != nil {
SELECT secret, secret_ciphertext FROM totp_credentials WHERE user_id = $1
`, userID).Scan(&plainCol, &cipherCol); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotConfirmed
}
return nil, fmt.Errorf("secret lesen: %w", err)
}
secret, err := s.resolveSecret(derefOr(plainCol, ""), cipherCol)
if err != nil {
return nil, err
}
ok, err := Validate(secret, code, time.Now())
if err != nil {
@@ -120,9 +182,11 @@ func (s *Store) IsEnabled(ctx context.Context, userID string) (bool, error) {
// VerifyLoginCode akzeptiert entweder einen gueltigen TOTP-Code oder einen
// noch nicht verwendeten Wiederherstellungscode (Akzeptanzkriterium 2 + 3).
func (s *Store) VerifyLoginCode(ctx context.Context, userID, code string) (bool, error) {
var secret string
var plainCol *string
var cipherCol []byte
var confirmed bool
err := s.pool.QueryRow(ctx, `SELECT secret, confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&secret, &confirmed)
var lastUsedStep *int64
err := s.pool.QueryRow(ctx, `SELECT secret, secret_ciphertext, confirmed, last_used_step FROM totp_credentials WHERE user_id = $1`, userID).Scan(&plainCol, &cipherCol, &confirmed, &lastUsedStep)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) || !confirmed {
return false, nil
@@ -132,11 +196,31 @@ func (s *Store) VerifyLoginCode(ctx context.Context, userID, code string) (bool,
if !confirmed {
return false, nil
}
if ok, err := Validate(secret, code, time.Now()); err != nil {
secret, err := s.resolveSecret(derefOr(plainCol, ""), cipherCol)
if err != nil {
return false, err
} else if ok {
return true, nil
}
ok, step, err := ValidateStep(secret, code, time.Now())
if err != nil {
return false, err
}
if ok {
// QA-15 F-06 (b): ein bereits fuer diesen oder einen frueheren
// Zeitschritt verwendeter Code wird nicht erneut akzeptiert. Der
// atomare UPDATE (WHERE last_used_step IS NULL OR < step) verhindert
// ein Wettrennen zwischen zwei parallelen Anfragen mit demselben Code.
if lastUsedStep != nil && step <= *lastUsedStep {
return false, nil
}
tag, err := s.pool.Exec(ctx, `
UPDATE totp_credentials SET last_used_step = $2
WHERE user_id = $1 AND (last_used_step IS NULL OR last_used_step < $2)
`, userID, step)
if err != nil {
return false, fmt.Errorf("totp: letzten zeitschritt speichern: %w", err)
}
return tag.RowsAffected() == 1, nil
}
return s.consumeRecoveryCode(ctx, userID, code)
@@ -157,6 +241,13 @@ func (s *Store) consumeRecoveryCode(ctx context.Context, userID, code string) (b
return tag.RowsAffected() == 1, nil
}
func derefOr(p *string, def string) string {
if p == nil {
return def
}
return *p
}
func hashRecoveryCode(code string) []byte {
sum := sha256.Sum256([]byte(code))
return sum[:]
+15 -4
View File
@@ -63,19 +63,30 @@ func GenerateCode(secret string, t time.Time) (string, error) {
// Validate prueft code gegen secret mit der dokumentierten Zeitversatz-
// Toleranz (DefaultSkewSteps) um t. Vergleich ist timing-safe.
func Validate(secret, code string, t time.Time) (bool, error) {
ok, _, err := ValidateStep(secret, code, t)
return ok, err
}
// ValidateStep wie Validate, liefert zusaetzlich den passenden HOTP-Zeitschritt
// (QA-15 F-06 (b): Store.VerifyLoginCode braucht ihn, um einen bereits
// verwendeten Code innerhalb seines 90-Sekunden-Fensters abzulehnen — sonst
// bliebe ein einmal abgefangener Code fuer die volle Fensterdauer wiederholt
// gueltig).
func ValidateStep(secret, code string, t time.Time) (ok bool, step int64, err error) {
key, err := decodeSecret(secret)
if err != nil {
return false, err
return false, 0, err
}
counter := int64(t.Unix() / StepSeconds)
for delta := -DefaultSkewSteps; delta <= DefaultSkewSteps; delta++ {
candidate := hotp(key, uint64(counter+int64(delta)))
candStep := counter + int64(delta)
candidate := hotp(key, uint64(candStep))
if timingsafe.EqualString(candidate, code) {
return true, nil
return true, candStep, nil
}
}
return false, nil
return false, 0, nil
}
func decodeSecret(secret string) ([]byte, error) {
@@ -0,0 +1,4 @@
ALTER TABLE totp_credentials DROP COLUMN last_used_step;
ALTER TABLE totp_credentials DROP CONSTRAINT totp_credentials_secret_present;
ALTER TABLE totp_credentials DROP COLUMN secret_ciphertext;
ALTER TABLE totp_credentials ALTER COLUMN secret SET NOT NULL;
@@ -0,0 +1,18 @@
-- QA-15 F-06: totp_credentials.secret lag unverschluesselt in der
-- Datenbank. Neue Spalte fuer den verschluesselten Wert; secret wird
-- nullable, weil ab jetzt ausschliesslich secret_ciphertext neu beschrieben
-- wird. Bestehende Zeilen (secret gesetzt, secret_ciphertext NULL) bleiben
-- lesbar, bis das Nachverschluesselungs-Werkzeug sie migriert hat
-- (cmd/account-api -encrypt-totp-secrets) — das Geheimnis selbst aendert
-- sich dabei NICHT (sonst waeren alle eingerichteten Authenticator-Apps
-- ungueltig).
ALTER TABLE totp_credentials ALTER COLUMN secret DROP NOT NULL;
ALTER TABLE totp_credentials ADD COLUMN secret_ciphertext BYTEA;
ALTER TABLE totp_credentials ADD CONSTRAINT totp_credentials_secret_present
CHECK (secret IS NOT NULL OR secret_ciphertext IS NOT NULL);
-- QA-15 F-06 (b): ein gueltiger Code liess sich innerhalb seines
-- 90-Sekunden-Fensters mehrfach einloesen. last_used_step haelt den
-- zuletzt akzeptierten HOTP-Zeitschritt fest; ein Code, dessen Schritt
-- <= last_used_step ist, wird abgelehnt (Wiederholungsschutz).
ALTER TABLE totp_credentials ADD COLUMN last_used_step BIGINT;