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

255 lines
8.3 KiB
Go

package totp
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
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
// 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).
func (s *Store) BeginSetup(ctx context.Context, userID, issuer, accountName string) (secret, provisioningURI string, err error) {
secret, err = GenerateSecret()
if err != nil {
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, 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)
}
return secret, ProvisioningURI(issuer, accountName, secret), nil
}
// ConfirmSetup verifiziert den ersten eingegebenen Code und aktiviert 2FA
// erst dann (Akzeptanzkriterium 1). Bei Erfolg werden einmalig
// 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 plainCol *string
var cipherCol []byte
if err := s.pool.QueryRow(ctx, `
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 {
return nil, err
}
if !ok {
return nil, ErrInvalidCode
}
if _, err := s.pool.Exec(ctx, `UPDATE totp_credentials SET confirmed = true WHERE user_id = $1`, userID); err != nil {
return nil, fmt.Errorf("bestaetigung speichern: %w", err)
}
recoveryCodes, err = s.generateRecoveryCodes(ctx, userID)
if err != nil {
return nil, err
}
return recoveryCodes, nil
}
func (s *Store) generateRecoveryCodes(ctx context.Context, userID string) ([]string, error) {
codes := make([]string, RecoveryCodeCount)
for i := range codes {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("wiederherstellungscode erzeugen: %w", err)
}
code := hex.EncodeToString(buf)
codes[i] = code
hash := hashRecoveryCode(code)
if _, err := s.pool.Exec(ctx, `
INSERT INTO totp_recovery_codes (user_id, code_hash) VALUES ($1, $2)
`, userID, hash); err != nil {
return nil, fmt.Errorf("wiederherstellungscode speichern: %w", err)
}
}
return codes, nil
}
// IsEnabled prueft, ob 2FA fuer einen Benutzer aktiv (bestaetigt) ist.
func (s *Store) IsEnabled(ctx context.Context, userID string) (bool, error) {
var confirmed bool
err := s.pool.QueryRow(ctx, `SELECT confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&confirmed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("status lesen: %w", err)
}
return confirmed, nil
}
// 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 plainCol *string
var cipherCol []byte
var confirmed bool
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
}
return false, fmt.Errorf("secret lesen: %w", err)
}
if !confirmed {
return false, nil
}
secret, err := s.resolveSecret(derefOr(plainCol, ""), cipherCol)
if err != nil {
return false, err
}
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)
}
// consumeRecoveryCode markiert einen Wiederherstellungscode atomar als
// verwendet (WHERE used_at IS NULL) — ein bereits verbrauchter Code kann
// nicht doppelt eingeloest werden (Akzeptanzkriterium 3 / Pruefung 2).
func (s *Store) consumeRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
hash := hashRecoveryCode(code)
tag, err := s.pool.Exec(ctx, `
UPDATE totp_recovery_codes SET used_at = now()
WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL
`, userID, hash)
if err != nil {
return false, fmt.Errorf("wiederherstellungscode einloesen: %w", err)
}
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[:]
}