syncova-backup/packages/platform/crypto/crypto_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

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)
}
}
}