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>
336 lines
12 KiB
Go
336 lines
12 KiB
Go
package crypto
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Passwörter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestHashPasswordProducesVerifiableHash(testInstance *testing.T) {
|
|
const plainPassword = "ein-hinreichend-langes-passwort" // secretscan:erlaubt: erfundener Testwert
|
|
|
|
encodedHash, hashError := HashPassword(plainPassword)
|
|
if hashError != nil {
|
|
testInstance.Fatalf("HashPassword schlug fehl: %v", hashError)
|
|
}
|
|
|
|
if verifyError := VerifyPassword(plainPassword, encodedHash); verifyError != nil {
|
|
testInstance.Fatalf("das eigene Passwort wurde nicht akzeptiert: %v", verifyError)
|
|
}
|
|
}
|
|
|
|
func TestHashNeverContainsPlaintext(testInstance *testing.T) {
|
|
const plainPassword = "streng-geheimes-passwort" // secretscan:erlaubt: erfundener Testwert
|
|
|
|
encodedHash, hashError := HashPassword(plainPassword)
|
|
if hashError != nil {
|
|
testInstance.Fatalf("HashPassword schlug fehl: %v", hashError)
|
|
}
|
|
|
|
// Ein Hash, der das Passwort enthält, wäre kein Hash.
|
|
if strings.Contains(encodedHash, plainPassword) {
|
|
testInstance.Fatalf("der Hash enthält das Passwort im Klartext: %s", encodedHash)
|
|
}
|
|
}
|
|
|
|
func TestSamePasswordProducesDifferentHashes(testInstance *testing.T) {
|
|
const plainPassword = "identisches-passwort" // secretscan:erlaubt: erfundener Testwert
|
|
|
|
firstHash, _ := HashPassword(plainPassword)
|
|
secondHash, _ := HashPassword(plainPassword)
|
|
|
|
// Ohne zufälliges Salz liesse sich aus gleichen Hashes ablesen,
|
|
// welche Benutzer dasselbe Passwort verwenden.
|
|
if firstHash == secondHash {
|
|
testInstance.Fatal("zwei Hashes desselben Passworts sind identisch - das Salz wirkt nicht")
|
|
}
|
|
|
|
// Beide müssen dennoch prüfbar sein.
|
|
if VerifyPassword(plainPassword, firstHash) != nil || VerifyPassword(plainPassword, secondHash) != nil {
|
|
testInstance.Fatal("nicht beide Hashes lassen sich verifizieren")
|
|
}
|
|
}
|
|
|
|
func TestVerifyPasswordRejectsWrongPassword(testInstance *testing.T) {
|
|
encodedHash, _ := HashPassword("das-richtige-passwort")
|
|
|
|
verifyError := VerifyPassword("das-falsche-passwort", encodedHash)
|
|
if !errors.Is(verifyError, ErrPasswordMismatch) {
|
|
testInstance.Fatalf("ein falsches Passwort muss ErrPasswordMismatch liefern, war: %v", verifyError)
|
|
}
|
|
}
|
|
|
|
func TestVerifyPasswordRejectsMalformedHash(testInstance *testing.T) {
|
|
malformedHashes := map[string]string{
|
|
"leer": "",
|
|
"kein PHC-Format": "einfach-nur-text",
|
|
"unbekanntes Verfahren": "$bcrypt$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA",
|
|
"zu wenige Teile": "$argon2id$v=19$m=65536,t=3,p=4$c2FsdA",
|
|
"defektes Salz": "$argon2id$v=19$m=65536,t=3,p=4$!!!nicht-base64!!!$aGFzaA",
|
|
}
|
|
|
|
for hashDescription, malformedHash := range malformedHashes {
|
|
verifyError := VerifyPassword("beliebig", malformedHash)
|
|
// Ein unlesbarer Hash darf niemals als Übereinstimmung durchgehen.
|
|
if verifyError == nil {
|
|
testInstance.Errorf("%s: ein ungültiger Hash wurde akzeptiert", hashDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHashPasswordRejectsEmptyPassword(testInstance *testing.T) {
|
|
if _, hashError := HashPassword(""); hashError == nil {
|
|
testInstance.Fatal("ein leeres Passwort darf nicht gehasht werden")
|
|
}
|
|
}
|
|
|
|
func TestNeedsRehashDetectsWeakerParameters(testInstance *testing.T) {
|
|
// Ein Hash mit deutlich schwächeren Parametern soll erneuert werden.
|
|
const weakHash = "$argon2id$v=19$m=4096,t=1,p=1$c2FsdHNhbHRzYWx0c2E$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFz"
|
|
|
|
if !NeedsRehash(weakHash) {
|
|
testInstance.Error("ein Hash mit schwächeren Parametern soll erneuert werden")
|
|
}
|
|
|
|
currentHash, _ := HashPassword("aktuelles-passwort")
|
|
if NeedsRehash(currentHash) {
|
|
testInstance.Error("ein Hash mit aktuellen Parametern soll nicht erneuert werden")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Secrets
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// newTestSecretStore baut einen Secret Store mit zwei Schlüsselversionen.
|
|
func newTestSecretStore(testInstance *testing.T) *LocalSecretStore {
|
|
testInstance.Helper()
|
|
|
|
firstKey := bytes.Repeat([]byte{0x01}, masterKeyLength)
|
|
secondKey := bytes.Repeat([]byte{0x02}, masterKeyLength)
|
|
|
|
secretStore, storeError := NewLocalSecretStore(map[string][]byte{"v1": firstKey, "v2": secondKey}, "v2")
|
|
if storeError != nil {
|
|
testInstance.Fatalf("der Secret Store konnte nicht erstellt werden: %v", storeError)
|
|
}
|
|
|
|
return secretStore
|
|
}
|
|
|
|
func TestEncryptDecryptRoundTrip(testInstance *testing.T) {
|
|
secretStore := newTestSecretStore(testInstance)
|
|
originalSecret := []byte("JBSWY3DPEHPK3PXP")
|
|
|
|
encryptedSecret, keyVersion, encryptError := secretStore.Encrypt(originalSecret)
|
|
if encryptError != nil {
|
|
testInstance.Fatalf("Encrypt schlug fehl: %v", encryptError)
|
|
}
|
|
|
|
// Neu verschlüsselt wird immer mit der aktuellen Version.
|
|
if keyVersion != "v2" {
|
|
testInstance.Errorf("es soll mit v2 verschlüsselt werden, war %q", keyVersion)
|
|
}
|
|
|
|
// Der Geheimtext darf den Klartext nicht enthalten.
|
|
if bytes.Contains(encryptedSecret, originalSecret) {
|
|
testInstance.Fatal("der Geheimtext enthält den Klartext")
|
|
}
|
|
|
|
decryptedSecret, decryptError := secretStore.Decrypt(encryptedSecret, keyVersion)
|
|
if decryptError != nil {
|
|
testInstance.Fatalf("Decrypt schlug fehl: %v", decryptError)
|
|
}
|
|
|
|
if !bytes.Equal(decryptedSecret, originalSecret) {
|
|
testInstance.Fatalf("der entschlüsselte Wert weicht ab: %q", decryptedSecret)
|
|
}
|
|
}
|
|
|
|
func TestEncryptProducesDifferentCiphertextEachTime(testInstance *testing.T) {
|
|
secretStore := newTestSecretStore(testInstance)
|
|
originalSecret := []byte("dasselbe-secret")
|
|
|
|
firstCiphertext, _, _ := secretStore.Encrypt(originalSecret)
|
|
secondCiphertext, _, _ := secretStore.Encrypt(originalSecret)
|
|
|
|
// Gleiche Geheimtexte würden verraten, dass zwei Benutzer dasselbe Secret haben.
|
|
if bytes.Equal(firstCiphertext, secondCiphertext) {
|
|
testInstance.Fatal("zweimaliges Verschlüsseln liefert denselben Geheimtext - die Nonce wirkt nicht")
|
|
}
|
|
}
|
|
|
|
func TestDecryptDetectsTamperedCiphertext(testInstance *testing.T) {
|
|
secretStore := newTestSecretStore(testInstance)
|
|
|
|
encryptedSecret, keyVersion, _ := secretStore.Encrypt([]byte("unveränderliches-secret"))
|
|
|
|
// Ein einzelnes verändertes Bit muss auffallen.
|
|
tamperedSecret := bytes.Clone(encryptedSecret)
|
|
tamperedSecret[len(tamperedSecret)-1] ^= 0x01
|
|
|
|
_, decryptError := secretStore.Decrypt(tamperedSecret, keyVersion)
|
|
if !errors.Is(decryptError, ErrSecretTampered) {
|
|
testInstance.Fatalf("ein verändertes Bit muss erkannt werden, war: %v", decryptError)
|
|
}
|
|
}
|
|
|
|
func TestDecryptWithOldKeyVersionStillWorks(testInstance *testing.T) {
|
|
firstKey := bytes.Repeat([]byte{0x01}, masterKeyLength)
|
|
|
|
// Zuerst wird mit v1 verschlüsselt.
|
|
oldStore, _ := NewLocalSecretStore(map[string][]byte{"v1": firstKey}, "v1")
|
|
encryptedSecret, _, _ := oldStore.Encrypt([]byte("altes-secret"))
|
|
|
|
// Nach der Rotation ist v2 aktuell, v1 aber weiterhin bekannt.
|
|
rotatedStore := newTestSecretStore(testInstance)
|
|
|
|
decryptedSecret, decryptError := rotatedStore.Decrypt(encryptedSecret, "v1")
|
|
if decryptError != nil {
|
|
testInstance.Fatalf("nach einer Rotation muss ein alter Wert lesbar bleiben: %v", decryptError)
|
|
}
|
|
|
|
if string(decryptedSecret) != "altes-secret" {
|
|
testInstance.Fatalf("der entschlüsselte Wert weicht ab: %q", decryptedSecret)
|
|
}
|
|
}
|
|
|
|
func TestDecryptWithUnknownKeyVersionFails(testInstance *testing.T) {
|
|
secretStore := newTestSecretStore(testInstance)
|
|
encryptedSecret, _, _ := secretStore.Encrypt([]byte("secret"))
|
|
|
|
_, decryptError := secretStore.Decrypt(encryptedSecret, "v99")
|
|
if !errors.Is(decryptError, ErrUnknownKeyVersion) {
|
|
testInstance.Fatalf("ein unbekannter Schlüssel muss deutlich gemeldet werden, war: %v", decryptError)
|
|
}
|
|
}
|
|
|
|
func TestNewLocalSecretStoreRejectsShortKey(testInstance *testing.T) {
|
|
shortKey := bytes.Repeat([]byte{0x01}, 16)
|
|
|
|
// Ein zu kurzer Schlüssel darf nicht stillschweigend aufgefüllt werden.
|
|
if _, storeError := NewLocalSecretStore(map[string][]byte{"v1": shortKey}, "v1"); storeError == nil {
|
|
testInstance.Fatal("ein zu kurzer Schlüssel muss abgelehnt werden")
|
|
}
|
|
}
|
|
|
|
func TestParseKeySetReadsSeveralVersions(testInstance *testing.T) {
|
|
firstKey, _ := GenerateMasterKey()
|
|
secondKey, _ := GenerateMasterKey()
|
|
|
|
parsedKeys, parseError := ParseKeySet("v1:" + firstKey + ", v2:" + secondKey)
|
|
if parseError != nil {
|
|
testInstance.Fatalf("ParseKeySet schlug fehl: %v", parseError)
|
|
}
|
|
|
|
if len(parsedKeys) != 2 {
|
|
testInstance.Fatalf("es sollen 2 Schlüssel gelesen werden, waren %d", len(parsedKeys))
|
|
}
|
|
}
|
|
|
|
func TestParseKeySetRejectsInvalidInput(testInstance *testing.T) {
|
|
invalidKeySets := map[string]string{
|
|
"leer": "",
|
|
"ohne Version": "nur-ein-wert",
|
|
"kein base64": "v1:!!!kein-base64!!!",
|
|
"zu kurzer Wert": "v1:c2hvcnQ=",
|
|
}
|
|
|
|
for setDescription, invalidKeySet := range invalidKeySets {
|
|
if _, parseError := ParseKeySet(invalidKeySet); parseError == nil {
|
|
testInstance.Errorf("%s: ungültige Schlüsselliste wurde akzeptiert", setDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseKeySetErrorNeverContainsKeyMaterial(testInstance *testing.T) {
|
|
const secretLookingKey = "ZGllc2VyLXdlcnQtaXN0LWdlaGVpbS1hYmVyLXp1LWt1cno="
|
|
|
|
_, parseError := ParseKeySet("v1:" + secretLookingKey)
|
|
if parseError == nil {
|
|
testInstance.Fatal("der zu kurze Schlüssel hätte abgelehnt werden müssen")
|
|
}
|
|
|
|
// Auch eine Fehlermeldung darf kein Schlüsselmaterial preisgeben.
|
|
if strings.Contains(parseError.Error(), secretLookingKey) {
|
|
testInstance.Fatalf("die Fehlermeldung enthält Schlüsselmaterial: %v", parseError)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tokens
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGenerateTokenIsUnique(testInstance *testing.T) {
|
|
seenTokens := make(map[string]struct{})
|
|
|
|
// Eine Kollision wäre eine Sitzungsübernahme.
|
|
for tokenIndex := 0; tokenIndex < 1000; tokenIndex++ {
|
|
generatedToken, tokenError := GenerateToken()
|
|
if tokenError != nil {
|
|
testInstance.Fatalf("GenerateToken schlug fehl: %v", tokenError)
|
|
}
|
|
|
|
if _, isDuplicate := seenTokens[generatedToken]; isDuplicate {
|
|
testInstance.Fatal("zwei erzeugte Tokens sind identisch")
|
|
}
|
|
|
|
seenTokens[generatedToken] = struct{}{}
|
|
}
|
|
}
|
|
|
|
func TestHashTokenIsStableAndHidesToken(testInstance *testing.T) {
|
|
plainToken, _ := GenerateToken()
|
|
|
|
firstHash := HashToken(plainToken)
|
|
secondHash := HashToken(plainToken)
|
|
|
|
// Ohne Stabilität liesse sich eine Sitzung nicht wiederfinden.
|
|
if firstHash != secondHash {
|
|
testInstance.Fatal("derselbe Token liefert unterschiedliche Hashes")
|
|
}
|
|
|
|
// Der gespeicherte Wert darf den Token nicht enthalten.
|
|
if strings.Contains(firstHash, plainToken) {
|
|
testInstance.Fatal("der Hash enthält den Token im Klartext")
|
|
}
|
|
}
|
|
|
|
func TestTokensEqualComparesCorrectly(testInstance *testing.T) {
|
|
firstToken, _ := GenerateToken()
|
|
secondToken, _ := GenerateToken()
|
|
|
|
if !TokensEqual(HashToken(firstToken), HashToken(firstToken)) {
|
|
testInstance.Error("gleiche Tokens sollen als gleich gelten")
|
|
}
|
|
|
|
if TokensEqual(HashToken(firstToken), HashToken(secondToken)) {
|
|
testInstance.Error("verschiedene Tokens dürfen nicht als gleich gelten")
|
|
}
|
|
}
|
|
|
|
func TestGenerateRecoveryCodeAvoidsAmbiguousCharacters(testInstance *testing.T) {
|
|
// Diese Zeichen werden beim Abtippen regelmäßig verwechselt.
|
|
const ambiguousCharacters = "01OI"
|
|
|
|
for codeIndex := 0; codeIndex < 200; codeIndex++ {
|
|
recoveryCode, codeError := GenerateRecoveryCode()
|
|
if codeError != nil {
|
|
testInstance.Fatalf("GenerateRecoveryCode schlug fehl: %v", codeError)
|
|
}
|
|
|
|
if strings.ContainsAny(recoveryCode, ambiguousCharacters) {
|
|
testInstance.Fatalf("der Code enthält ein verwechselbares Zeichen: %s", recoveryCode)
|
|
}
|
|
|
|
// Format: 5 Zeichen, Bindestrich, 5 Zeichen.
|
|
if len(recoveryCode) != 11 || recoveryCode[5] != '-' {
|
|
testInstance.Fatalf("unerwartetes Codeformat: %s", recoveryCode)
|
|
}
|
|
}
|
|
}
|