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>
86 lines
2.9 KiB
Go
86 lines
2.9 KiB
Go
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
|
|
}
|