diff --git a/cmd/account-api/main.go b/cmd/account-api/main.go index b66b55c2..b7aa3be2 100644 --- a/cmd/account-api/main.go +++ b/cmd/account-api/main.go @@ -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{}) diff --git a/cmd/account-api/totpcipher.go b/cmd/account-api/totpcipher.go new file mode 100644 index 00000000..8a2e7d3b --- /dev/null +++ b/cmd/account-api/totpcipher.go @@ -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 +} diff --git a/internal/auth/changepassword.go b/internal/auth/changepassword.go index 81c611f3..2a9cb6cc 100644 --- a/internal/auth/changepassword.go +++ b/internal/auth/changepassword.go @@ -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) } diff --git a/internal/totp/store.go b/internal/totp/store.go index 04cea719..012e3bc2 100644 --- a/internal/totp/store.go +++ b/internal/totp/store.go @@ -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[:] diff --git a/internal/totp/totp.go b/internal/totp/totp.go index cadb7aaf..0fab273d 100644 --- a/internal/totp/totp.go +++ b/internal/totp/totp.go @@ -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) { diff --git a/migrations/tenant/0009_totp_secret_encryption.down.sql b/migrations/tenant/0009_totp_secret_encryption.down.sql new file mode 100644 index 00000000..c9f8ca94 --- /dev/null +++ b/migrations/tenant/0009_totp_secret_encryption.down.sql @@ -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; diff --git a/migrations/tenant/0009_totp_secret_encryption.up.sql b/migrations/tenant/0009_totp_secret_encryption.up.sql new file mode 100644 index 00000000..eb65c22d --- /dev/null +++ b/migrations/tenant/0009_totp_secret_encryption.up.sql @@ -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;