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
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:
1 parent
d86db439b4
commit
052d63aadb
7 files changed
+300
-23
No files matched your search
+26
-3
@@ -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{})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user