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>
255 lines
8.3 KiB
Go
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[:]
|
|
}
|