diff --git a/internal/config/export_test.go b/internal/config/export_test.go new file mode 100644 index 0000000..debfb69 --- /dev/null +++ b/internal/config/export_test.go @@ -0,0 +1,280 @@ +package config + +import ( + "bytes" + "encoding/base64" + "testing" +) + +// Internal tests for unexported functions in export.go + +func TestEncryptDecryptWithPassphrase(t *testing.T) { + tests := []struct { + name string + plaintext []byte + passphrase string + }{ + { + name: "simple text", + plaintext: []byte("hello world"), + passphrase: "secret123", + }, + { + name: "empty string", + plaintext: []byte(""), + passphrase: "password", + }, + { + name: "large data", + plaintext: bytes.Repeat([]byte("test data "), 1000), + passphrase: "mypassphrase", + }, + { + name: "unicode content", + plaintext: []byte("こんにちは世界 🌍"), + passphrase: "pass123", + }, + { + name: "binary data", + plaintext: []byte{0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd}, + passphrase: "binary-pass", + }, + { + name: "json data", + plaintext: []byte(`{"version":"4.1","nodes":{"pve":[]}}`), + passphrase: "json-export-pass", + }, + { + name: "long passphrase", + plaintext: []byte("test data"), + passphrase: "this is a very long passphrase that exceeds the normal key length", + }, + { + name: "short passphrase", + plaintext: []byte("test data"), + passphrase: "a", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Encrypt + encrypted, err := encryptWithPassphrase(tc.plaintext, tc.passphrase) + if err != nil { + t.Fatalf("encryptWithPassphrase failed: %v", err) + } + + // Verify encrypted data is different from plaintext + if bytes.Equal(encrypted, tc.plaintext) && len(tc.plaintext) > 0 { + t.Error("encrypted data should be different from plaintext") + } + + // Decrypt + decrypted, err := decryptWithPassphrase(encrypted, tc.passphrase) + if err != nil { + t.Fatalf("decryptWithPassphrase failed: %v", err) + } + + // Verify roundtrip + if !bytes.Equal(decrypted, tc.plaintext) { + t.Errorf("decrypted = %q, want %q", decrypted, tc.plaintext) + } + }) + } +} + +func TestEncryptWithPassphrase_UniqueOutput(t *testing.T) { + plaintext := []byte("test data") + passphrase := "password" + + // Encrypt twice - should produce different ciphertexts due to random salt/nonce + encrypted1, err := encryptWithPassphrase(plaintext, passphrase) + if err != nil { + t.Fatalf("first encryption failed: %v", err) + } + + encrypted2, err := encryptWithPassphrase(plaintext, passphrase) + if err != nil { + t.Fatalf("second encryption failed: %v", err) + } + + if bytes.Equal(encrypted1, encrypted2) { + t.Error("encrypting same data twice should produce different ciphertext") + } + + // Both should decrypt to the same plaintext + decrypted1, _ := decryptWithPassphrase(encrypted1, passphrase) + decrypted2, _ := decryptWithPassphrase(encrypted2, passphrase) + + if !bytes.Equal(decrypted1, plaintext) || !bytes.Equal(decrypted2, plaintext) { + t.Error("both ciphertexts should decrypt to original plaintext") + } +} + +func TestDecryptWithPassphrase_WrongPassphrase(t *testing.T) { + plaintext := []byte("secret data") + + encrypted, err := encryptWithPassphrase(plaintext, "correct-password") + if err != nil { + t.Fatalf("encryption failed: %v", err) + } + + // Try to decrypt with wrong password - should fail + _, err = decryptWithPassphrase(encrypted, "wrong-password") + if err == nil { + t.Error("decryption with wrong passphrase should fail") + } +} + +func TestDecryptWithPassphrase_TooShort(t *testing.T) { + // Ciphertext shorter than salt (32 bytes) + shortCiphertext := []byte("too short") + _, err := decryptWithPassphrase(shortCiphertext, "password") + if err == nil { + t.Error("decryption should fail for ciphertext shorter than salt") + } + + // Ciphertext exactly 32 bytes (only salt, no actual encrypted data) + saltOnly := make([]byte, 32) + _, err = decryptWithPassphrase(saltOnly, "password") + if err == nil { + t.Error("decryption should fail when no encrypted data present") + } +} + +func TestDecryptWithPassphrase_TamperedCiphertext(t *testing.T) { + plaintext := []byte("test data") + passphrase := "password" + + encrypted, err := encryptWithPassphrase(plaintext, passphrase) + if err != nil { + t.Fatalf("encryption failed: %v", err) + } + + // Tamper with the ciphertext (modify a byte in the middle) + tampered := make([]byte, len(encrypted)) + copy(tampered, encrypted) + tampered[len(tampered)/2] ^= 0xff + + // Decryption should fail due to authentication failure + _, err = decryptWithPassphrase(tampered, passphrase) + if err == nil { + t.Error("decryption should fail for tampered ciphertext") + } +} + +func TestDecryptWithPassphrase_TamperedSalt(t *testing.T) { + plaintext := []byte("test data") + passphrase := "password" + + encrypted, err := encryptWithPassphrase(plaintext, passphrase) + if err != nil { + t.Fatalf("encryption failed: %v", err) + } + + // Tamper with the salt (first 32 bytes) + tampered := make([]byte, len(encrypted)) + copy(tampered, encrypted) + tampered[0] ^= 0xff + + // Decryption should fail because wrong key will be derived + _, err = decryptWithPassphrase(tampered, passphrase) + if err == nil { + t.Error("decryption should fail for tampered salt") + } +} + +func TestEncryptedData_MinimumSize(t *testing.T) { + plaintext := []byte("x") + passphrase := "pass" + + encrypted, err := encryptWithPassphrase(plaintext, passphrase) + if err != nil { + t.Fatalf("encryption failed: %v", err) + } + + // Encrypted data should be at least: + // - 32 bytes salt + // - 12 bytes nonce (GCM default) + // - 1 byte plaintext + // - 16 bytes auth tag + minSize := 32 + 12 + 1 + 16 + if len(encrypted) < minSize { + t.Errorf("encrypted data size = %d, want at least %d", len(encrypted), minSize) + } +} + +func TestExportData_Fields(t *testing.T) { + ed := ExportData{ + Version: "4.1", + } + + if ed.Version != "4.1" { + t.Errorf("Version = %q, want 4.1", ed.Version) + } + + if ed.GuestMetadata != nil { + t.Error("GuestMetadata should be nil by default") + } + + if ed.OIDC != nil { + t.Error("OIDC should be nil by default") + } + + if ed.APITokens != nil { + t.Error("APITokens should be nil by default") + } +} + +func TestEncryptDecrypt_Base64Roundtrip(t *testing.T) { + // Test the actual export/import flow with base64 encoding + plaintext := []byte(`{"version":"4.1","exportedAt":"2024-01-01T00:00:00Z"}`) + passphrase := "export-password" + + // Encrypt + encrypted, err := encryptWithPassphrase(plaintext, passphrase) + if err != nil { + t.Fatalf("encryption failed: %v", err) + } + + // Base64 encode (as done in ExportConfig) + encoded := base64.StdEncoding.EncodeToString(encrypted) + + // Base64 decode (as done in ImportConfig) + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("base64 decode failed: %v", err) + } + + // Decrypt + decrypted, err := decryptWithPassphrase(decoded, passphrase) + if err != nil { + t.Fatalf("decryption failed: %v", err) + } + + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("roundtrip failed: got %q, want %q", decrypted, plaintext) + } +} + +func TestEncryptWithPassphrase_EmptyPassphrase(t *testing.T) { + // While ExportConfig validates empty passphrase, the underlying function + // should still handle it (or we document it doesn't work with empty) + plaintext := []byte("test") + + // Empty passphrase is technically allowed by the encryption function + // (PBKDF2 handles it), but it's very weak + encrypted, err := encryptWithPassphrase(plaintext, "") + if err != nil { + t.Fatalf("encryption with empty passphrase failed: %v", err) + } + + decrypted, err := decryptWithPassphrase(encrypted, "") + if err != nil { + t.Fatalf("decryption with empty passphrase failed: %v", err) + } + + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("roundtrip with empty passphrase failed") + } +}