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>
257 lines
9.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|