syncova-backup/packages/platform/totp/totp_test.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

257 lines
9.5 KiB
Go

package totp
import (
"encoding/base32"
"errors"
"strings"
"testing"
"time"
)
// RFC 6238 Anhang B nennt Testvektoren für das Secret "12345678901234567890"
// (bzw. dessen Wiederholung für SHA256/SHA512). Diese Vektoren sind der
// maßgebliche Nachweis, dass die Umsetzung korrekt ist.
func encodeTestSecret(asciiSecret string) string {
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(asciiSecret))
}
func TestGenerateCodeMatchesRFC6238TestVectors(testInstance *testing.T) {
// Die Testvektoren verwenden acht Ziffern.
const sha1Secret = "12345678901234567890" // secretscan:erlaubt: erfundener Testwert
const sha256Secret = "12345678901234567890123456789012" // secretscan:erlaubt: erfundener Testwert
const sha512Secret = "1234567890123456789012345678901234567890123456789012345678901234" // secretscan:erlaubt: erfundener Testwert
testVectors := []struct {
unixTime int64
algorithm Algorithm
asciiSecret string
expectedCode string
}{
{59, AlgorithmSHA1, sha1Secret, "94287082"},
{59, AlgorithmSHA256, sha256Secret, "46119246"},
{59, AlgorithmSHA512, sha512Secret, "90693936"},
{1111111109, AlgorithmSHA1, sha1Secret, "07081804"},
{1111111111, AlgorithmSHA1, sha1Secret, "14050471"},
{1234567890, AlgorithmSHA1, sha1Secret, "89005924"},
{2000000000, AlgorithmSHA1, sha1Secret, "69279037"},
{20000000000, AlgorithmSHA1, sha1Secret, "65353130"},
{1111111109, AlgorithmSHA256, sha256Secret, "68084774"},
{1234567890, AlgorithmSHA512, sha512Secret, "93441116"},
}
for _, testVector := range testVectors {
configuration := Configuration{
Algorithm: testVector.algorithm,
Digits: 8,
Period: 30 * time.Second,
Skew: 0,
}
generatedCode, generateError := GenerateCode(
encodeTestSecret(testVector.asciiSecret),
time.Unix(testVector.unixTime, 0).UTC(),
configuration,
)
if generateError != nil {
testInstance.Fatalf("GenerateCode schlug fehl: %v", generateError)
}
if generatedCode != testVector.expectedCode {
testInstance.Errorf("%s bei t=%d: erwartet %s, war %s",
testVector.algorithm, testVector.unixTime, testVector.expectedCode, generatedCode)
}
}
}
func TestValidateAcceptsCurrentCode(testInstance *testing.T) {
testSecret, _ := GenerateSecret()
configuration := DefaultConfiguration()
validationTime := time.Now()
currentCode, _ := GenerateCode(testSecret, validationTime, configuration)
validationResult, validateError := Validate(testSecret, currentCode, validationTime, 0, configuration)
if validateError != nil {
testInstance.Fatalf("der aktuelle Code wurde abgelehnt: %v", validateError)
}
expectedTimeStep := validationTime.UTC().Unix() / 30
if validationResult.TimeStep != expectedTimeStep {
testInstance.Errorf("Zeitschritt soll %d sein, war %d", expectedTimeStep, validationResult.TimeStep)
}
}
func TestValidateRejectsCodeReuse(testInstance *testing.T) {
testSecret, _ := GenerateSecret()
configuration := DefaultConfiguration()
validationTime := time.Now()
currentCode, _ := GenerateCode(testSecret, validationTime, configuration)
firstResult, firstError := Validate(testSecret, currentCode, validationTime, 0, configuration)
if firstError != nil {
testInstance.Fatalf("die erste Prüfung schlug fehl: %v", firstError)
}
// Derselbe Code darf innerhalb seines Zeitfensters kein zweites Mal gelten:
// sonst könnte ein abgefangener Code erneut verwendet werden.
_, secondError := Validate(testSecret, currentCode, validationTime, firstResult.TimeStep, configuration)
if !errors.Is(secondError, ErrCodeAlreadyUsed) {
testInstance.Fatalf("die Wiederverwendung muss erkannt werden, war: %v", secondError)
}
}
func TestValidateToleratesClockSkew(testInstance *testing.T) {
testSecret, _ := GenerateSecret()
configuration := DefaultConfiguration()
validationTime := time.Now()
// Eine um einen Schritt nachgehende Uhr soll noch akzeptiert werden.
previousCode, _ := GenerateCode(testSecret, validationTime.Add(-30*time.Second), configuration)
if _, validateError := Validate(testSecret, previousCode, validationTime, 0, configuration); validateError != nil {
testInstance.Errorf("ein Code des vorherigen Zeitschritts soll gelten: %v", validateError)
}
// Ebenso eine leicht vorgehende Uhr.
nextCode, _ := GenerateCode(testSecret, validationTime.Add(30*time.Second), configuration)
if _, validateError := Validate(testSecret, nextCode, validationTime, 0, configuration); validateError != nil {
testInstance.Errorf("ein Code des nächsten Zeitschritts soll gelten: %v", validateError)
}
}
func TestValidateRejectsCodeOutsideSkewWindow(testInstance *testing.T) {
testSecret, _ := GenerateSecret()
configuration := DefaultConfiguration()
validationTime := time.Now()
// Zwei Schritte Abweichung liegen ausserhalb der Toleranz.
staleCode, _ := GenerateCode(testSecret, validationTime.Add(-90*time.Second), configuration)
if _, validateError := Validate(testSecret, staleCode, validationTime, 0, configuration); !errors.Is(validateError, ErrInvalidCode) {
testInstance.Fatalf("ein zu alter Code muss abgelehnt werden, war: %v", validateError)
}
}
func TestValidateRejectsWrongCode(testInstance *testing.T) {
testSecret, _ := GenerateSecret()
configuration := DefaultConfiguration()
if _, validateError := Validate(testSecret, "000000", time.Now(), 0, configuration); validateError == nil {
testInstance.Fatal("ein falscher Code wurde akzeptiert")
}
}
func TestValidateRejectsCodeOfWrongLength(testInstance *testing.T) {
testSecret, _ := GenerateSecret()
configuration := DefaultConfiguration()
for _, malformedCode := range []string{"", "1234", "1234567", "abcdef"} {
if _, validateError := Validate(testSecret, malformedCode, time.Now(), 0, configuration); validateError == nil {
testInstance.Errorf("der Code %q wurde akzeptiert", malformedCode)
}
}
}
func TestValidateAcceptsCodeWithSpaces(testInstance *testing.T) {
testSecret, _ := GenerateSecret()
configuration := DefaultConfiguration()
validationTime := time.Now()
currentCode, _ := GenerateCode(testSecret, validationTime, configuration)
// Authenticator-Apps zeigen Codes gruppiert an ("123 456"); ein Benutzer,
// der das mit abtippt, soll deshalb nicht scheitern.
spacedCode := currentCode[:3] + " " + currentCode[3:]
if _, validateError := Validate(testSecret, spacedCode, validationTime, 0, configuration); validateError != nil {
testInstance.Fatalf("ein Code mit Leerzeichen soll gelten: %v", validateError)
}
}
func TestValidateRejectsInvalidSecret(testInstance *testing.T) {
configuration := DefaultConfiguration()
if _, validateError := Validate("!!!kein-base32!!!", "123456", time.Now(), 0, configuration); validateError == nil {
testInstance.Fatal("ein ungültiges Secret wurde akzeptiert")
}
}
func TestSecretErrorNeverLeaksSecret(testInstance *testing.T) {
const secretLookingValue = "GEHEIMESSECRETABER!!!UNGUELTIG"
_, validateError := Validate(secretLookingValue, "123456", time.Now(), 0, DefaultConfiguration())
if validateError == nil {
testInstance.Fatal("das ungültige Secret hätte abgelehnt werden müssen")
}
// Auch eine Fehlermeldung darf kein Secret preisgeben.
if strings.Contains(validateError.Error(), secretLookingValue) {
testInstance.Fatalf("die Fehlermeldung enthält das Secret: %v", validateError)
}
}
func TestGenerateSecretIsUnique(testInstance *testing.T) {
seenSecrets := make(map[string]struct{})
for secretIndex := 0; secretIndex < 500; secretIndex++ {
generatedSecret, secretError := GenerateSecret()
if secretError != nil {
testInstance.Fatalf("GenerateSecret schlug fehl: %v", secretError)
}
if _, isDuplicate := seenSecrets[generatedSecret]; isDuplicate {
testInstance.Fatal("zwei erzeugte Secrets sind identisch")
}
seenSecrets[generatedSecret] = struct{}{}
}
}
func TestGeneratedSecretWorksEndToEnd(testInstance *testing.T) {
generatedSecret, _ := GenerateSecret()
configuration := DefaultConfiguration()
validationTime := time.Now()
generatedCode, generateError := GenerateCode(generatedSecret, validationTime, configuration)
if generateError != nil {
testInstance.Fatalf("GenerateCode schlug fehl: %v", generateError)
}
if _, validateError := Validate(generatedSecret, generatedCode, validationTime, 0, configuration); validateError != nil {
testInstance.Fatalf("ein selbst erzeugter Code wurde abgelehnt: %v", validateError)
}
}
func TestProvisioningURIContainsExpectedParameters(testInstance *testing.T) {
const testSecret = "JBSWY3DPEHPK3PXP" // secretscan:erlaubt: erfundener Testwert
provisioningURI := ProvisioningURI(testSecret, "admin@example.local", "Syncova", DefaultConfiguration())
for _, expectedFragment := range []string{
"otpauth://totp/",
"secret=" + testSecret,
"issuer=Syncova",
"algorithm=SHA1",
"digits=6",
"period=30",
} {
if !strings.Contains(provisioningURI, expectedFragment) {
testInstance.Errorf("die URI enthält %q nicht: %s", expectedFragment, provisioningURI)
}
}
}
func TestCodePreservesLeadingZeros(testInstance *testing.T) {
testSecret, _ := GenerateSecret()
configuration := DefaultConfiguration()
// Über viele Zeitpunkte hinweg muss jeder Code exakt sechs Zeichen haben;
// eine abgeschnittene führende Null wäre ein Anmeldefehler.
for stepIndex := int64(0); stepIndex < 2000; stepIndex++ {
generatedCode, _ := GenerateCode(testSecret, time.Unix(stepIndex*30, 0), configuration)
if len(generatedCode) != DefaultDigits {
testInstance.Fatalf("der Code %q hat nicht %d Ziffern", generatedCode, DefaultDigits)
}
}
}