mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 17:59:43 +08:00
- 新增 v2 连接恢复包 appKey 与文件密码双模式加密链路 - 扩展前后端导入导出流程并兼容 v1 与 legacy 格式 - 修复无文件密码恢复包导入误弹密码框导致的流程阻塞
478 lines
14 KiB
Go
478 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"GoNavi-Wails/internal/connection"
|
|
)
|
|
|
|
func TestConnectionPackageCryptoRoundTrip(t *testing.T) {
|
|
payload := connectionPackagePayload{
|
|
ExportedAt: "2026-04-10T12:00:00+08:00",
|
|
Connections: []connectionPackageItem{
|
|
{
|
|
ID: "conn-1",
|
|
Name: "local-mysql",
|
|
IncludeDatabases: []string{"app"},
|
|
IconType: "database",
|
|
IconColor: "#2f855a",
|
|
Config: connection.ConnectionConfig{
|
|
Type: "mysql",
|
|
Host: "127.0.0.1",
|
|
Port: 3306,
|
|
User: "root",
|
|
Database: "app",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
file, err := encryptConnectionPackage(payload, "strong-password")
|
|
if err != nil {
|
|
t.Fatalf("encryptConnectionPackage returned error: %v", err)
|
|
}
|
|
|
|
raw, err := json.Marshal(file)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal envelope returned error: %v", err)
|
|
}
|
|
if !isConnectionPackageEnvelope(string(raw)) {
|
|
t.Fatalf("isConnectionPackageEnvelope should return true for valid envelope")
|
|
}
|
|
|
|
var decoded connectionPackageFile
|
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
t.Fatalf("json.Unmarshal envelope returned error: %v", err)
|
|
}
|
|
|
|
got, err := decryptConnectionPackage(decoded, "strong-password")
|
|
if err != nil {
|
|
t.Fatalf("decryptConnectionPackage returned error: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, payload) {
|
|
t.Fatalf("round-trip mismatch: got=%+v want=%+v", got, payload)
|
|
}
|
|
}
|
|
|
|
func TestConnectionPackageV2AppManagedRoundTrip(t *testing.T) {
|
|
payload := connectionPackagePayload{
|
|
ExportedAt: "2026-04-11T12:00:00Z",
|
|
Connections: []connectionPackageItem{
|
|
{
|
|
ID: "conn-v2-1",
|
|
Name: "app-managed",
|
|
Config: connection.ConnectionConfig{
|
|
ID: "conn-v2-1",
|
|
Type: "postgres",
|
|
Host: "db.local",
|
|
Port: 5432,
|
|
User: "postgres",
|
|
Database: "app",
|
|
},
|
|
Secrets: connectionSecretBundle{
|
|
Password: "primary-secret",
|
|
SSHPassword: "ssh-secret",
|
|
OpaqueURI: "postgres://postgres:primary-secret@db.local/app",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
file, err := encryptConnectionPackageV2AppManaged(payload)
|
|
if err != nil {
|
|
t.Fatalf("encryptConnectionPackageV2AppManaged returned error: %v", err)
|
|
}
|
|
if file.V != connectionPackageSchemaVersionV2 {
|
|
t.Fatalf("expected v2 schema, got %d", file.V)
|
|
}
|
|
if file.P != connectionPackageProtectionAppManaged {
|
|
t.Fatalf("expected p=1, got %d", file.P)
|
|
}
|
|
if len(file.Connections) != 1 {
|
|
t.Fatalf("expected 1 connection, got %d", len(file.Connections))
|
|
}
|
|
if file.Connections[0].Secrets.Password == payload.Connections[0].Secrets.Password {
|
|
t.Fatal("expected p=1 secrets to stay encrypted in file")
|
|
}
|
|
|
|
raw, err := json.Marshal(file)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal returned error: %v", err)
|
|
}
|
|
if !isConnectionPackageV2AppManaged(string(raw)) {
|
|
t.Fatal("expected raw v2 p=1 payload to be detected")
|
|
}
|
|
if isConnectionPackageEnvelope(string(raw)) {
|
|
t.Fatal("v2 p=1 payload must not be misclassified as v1 envelope")
|
|
}
|
|
rawString := string(raw)
|
|
for _, forbidden := range []string{
|
|
"schemaVersion",
|
|
"cipher",
|
|
"protectionLevel",
|
|
"ENC:",
|
|
"primary-secret",
|
|
"ssh-secret",
|
|
"postgres://postgres:primary-secret@db.local/app",
|
|
} {
|
|
if strings.Contains(rawString, forbidden) {
|
|
t.Fatalf("v2 p=1 payload must not contain %q: %s", forbidden, rawString)
|
|
}
|
|
}
|
|
|
|
got, err := decryptConnectionPackageV2AppManaged(file)
|
|
if err != nil {
|
|
t.Fatalf("decryptConnectionPackageV2AppManaged returned error: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, payload) {
|
|
t.Fatalf("round-trip mismatch: got=%+v want=%+v", got, payload)
|
|
}
|
|
}
|
|
|
|
func TestConnectionPackageV2ProtectedRoundTrip(t *testing.T) {
|
|
payload := connectionPackagePayload{
|
|
ExportedAt: "2026-04-11T12:00:00Z",
|
|
Connections: []connectionPackageItem{
|
|
{
|
|
ID: "conn-v2-2",
|
|
Name: "password-protected",
|
|
Config: connection.ConnectionConfig{
|
|
ID: "conn-v2-2",
|
|
Type: "mysql",
|
|
Host: "db.local",
|
|
Port: 3306,
|
|
User: "root",
|
|
Database: "app",
|
|
},
|
|
Secrets: connectionSecretBundle{
|
|
Password: "primary-secret",
|
|
SSHPassword: "ssh-secret",
|
|
ProxyPassword: "proxy-secret",
|
|
HTTPTunnelPassword: "http-secret",
|
|
MySQLReplicaPassword: "mysql-secret",
|
|
MongoReplicaPassword: "mongo-secret",
|
|
OpaqueURI: "mysql://root:primary-secret@tcp(db.local:3306)/app",
|
|
OpaqueDSN: "root:primary-secret@tcp(db.local:3306)/app",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
file, err := encryptConnectionPackageV2Protected(payload, "package-password")
|
|
if err != nil {
|
|
t.Fatalf("encryptConnectionPackageV2Protected returned error: %v", err)
|
|
}
|
|
if file.V != connectionPackageSchemaVersionV2 {
|
|
t.Fatalf("expected v2 schema, got %d", file.V)
|
|
}
|
|
if file.P != connectionPackageProtectionPasswordProtected {
|
|
t.Fatalf("expected p=2, got %d", file.P)
|
|
}
|
|
if file.D == "" || file.NC == "" {
|
|
t.Fatal("expected p=2 file to carry outer encrypted payload")
|
|
}
|
|
if strings.HasPrefix(file.D, "ENC:") {
|
|
t.Fatalf("outer payload must not carry ENC prefix, got %q", file.D)
|
|
}
|
|
|
|
raw, err := json.Marshal(file)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal returned error: %v", err)
|
|
}
|
|
if !isConnectionPackageV2Protected(string(raw)) {
|
|
t.Fatal("expected raw v2 p=2 payload to be detected")
|
|
}
|
|
if isConnectionPackageEnvelope(string(raw)) {
|
|
t.Fatal("v2 p=2 payload must not be misclassified as v1 envelope")
|
|
}
|
|
rawString := string(raw)
|
|
for _, forbidden := range []string{
|
|
"schemaVersion",
|
|
"cipher",
|
|
"protectionLevel",
|
|
"ENC:",
|
|
"primary-secret",
|
|
"ssh-secret",
|
|
} {
|
|
if strings.Contains(rawString, forbidden) {
|
|
t.Fatalf("v2 p=2 payload must not contain %q: %s", forbidden, rawString)
|
|
}
|
|
}
|
|
|
|
got, err := decryptConnectionPackageV2Protected(file, "package-password")
|
|
if err != nil {
|
|
t.Fatalf("decryptConnectionPackageV2Protected returned error: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, payload) {
|
|
t.Fatalf("round-trip mismatch: got=%+v want=%+v", got, payload)
|
|
}
|
|
}
|
|
|
|
func TestConnectionPackageV2ProtectedWrongPasswordReturnsUnifiedError(t *testing.T) {
|
|
file, err := encryptConnectionPackageV2Protected(connectionPackagePayload{
|
|
Connections: []connectionPackageItem{
|
|
{
|
|
ID: "conn-v2-3",
|
|
Name: "wrong-password",
|
|
Config: connection.ConnectionConfig{
|
|
ID: "conn-v2-3",
|
|
Type: "postgres",
|
|
},
|
|
Secrets: connectionSecretBundle{
|
|
Password: "primary-secret",
|
|
},
|
|
},
|
|
},
|
|
}, "correct-password")
|
|
if err != nil {
|
|
t.Fatalf("encryptConnectionPackageV2Protected returned error: %v", err)
|
|
}
|
|
|
|
_, err = decryptConnectionPackageV2Protected(file, "wrong-password")
|
|
if !errors.Is(err, errConnectionPackageDecryptFailed) {
|
|
t.Fatalf("wrong p=2 password should return unified error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConnectionPackageDecryptWrongPasswordReturnsUnifiedError(t *testing.T) {
|
|
payload := connectionPackagePayload{
|
|
Connections: []connectionPackageItem{
|
|
{
|
|
ID: "conn-1",
|
|
Name: "test",
|
|
Config: connection.ConnectionConfig{
|
|
Type: "mysql",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
file, err := encryptConnectionPackage(payload, "correct-password")
|
|
if err != nil {
|
|
t.Fatalf("encryptConnectionPackage returned error: %v", err)
|
|
}
|
|
|
|
_, err = decryptConnectionPackage(file, "wrong-password")
|
|
if !errors.Is(err, errConnectionPackageDecryptFailed) {
|
|
t.Fatalf("wrong password should return unified error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConnectionPackageDecryptTamperedHeaderFailsAADValidation(t *testing.T) {
|
|
payload := connectionPackagePayload{
|
|
Connections: []connectionPackageItem{
|
|
{
|
|
ID: "conn-1",
|
|
Name: "test",
|
|
Config: connection.ConnectionConfig{
|
|
Type: "mysql",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
file, err := encryptConnectionPackage(payload, "correct-password")
|
|
if err != nil {
|
|
t.Fatalf("encryptConnectionPackage returned error: %v", err)
|
|
}
|
|
|
|
t.Run("cipher", func(t *testing.T) {
|
|
tampered := file
|
|
tampered.Nonce = "AAAAAAAAAAAAAAAA"
|
|
_, err := decryptConnectionPackage(tampered, "correct-password")
|
|
if !errors.Is(err, errConnectionPackageDecryptFailed) {
|
|
t.Fatalf("tampered nonce should fail with unified error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("kdf-salt", func(t *testing.T) {
|
|
tampered := file
|
|
tampered.KDF.Salt = "AAAAAAAAAAAAAAAAAAAAAA=="
|
|
_, err := decryptConnectionPackage(tampered, "correct-password")
|
|
if !errors.Is(err, errConnectionPackageDecryptFailed) {
|
|
t.Fatalf("tampered kdf salt should fail with unified error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestConnectionPackagePasswordRequired(t *testing.T) {
|
|
payload := connectionPackagePayload{
|
|
Connections: []connectionPackageItem{
|
|
{
|
|
ID: "conn-1",
|
|
Name: "test",
|
|
Config: connection.ConnectionConfig{
|
|
Type: "mysql",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err := encryptConnectionPackage(payload, " ")
|
|
if !errors.Is(err, errConnectionPackagePasswordRequired) {
|
|
t.Fatalf("encryptConnectionPackage should return password required error, got: %v", err)
|
|
}
|
|
|
|
_, err = decryptConnectionPackage(connectionPackageFile{}, " ")
|
|
if !errors.Is(err, errConnectionPackagePasswordRequired) {
|
|
t.Fatalf("decryptConnectionPackage should return password required error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConnectionPackageDecryptUnsupportedHeaderReturnsUnsupportedError(t *testing.T) {
|
|
payload := connectionPackagePayload{
|
|
Connections: []connectionPackageItem{
|
|
{
|
|
ID: "conn-1",
|
|
Name: "test",
|
|
Config: connection.ConnectionConfig{
|
|
Type: "mysql",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
file, err := encryptConnectionPackage(payload, "correct-password")
|
|
if err != nil {
|
|
t.Fatalf("encryptConnectionPackage returned error: %v", err)
|
|
}
|
|
|
|
t.Run("schemaVersion", func(t *testing.T) {
|
|
tampered := file
|
|
tampered.SchemaVersion = tampered.SchemaVersion + 1
|
|
_, err := decryptConnectionPackage(tampered, "correct-password")
|
|
if !errors.Is(err, errConnectionPackageUnsupported) {
|
|
t.Fatalf("unsupported schemaVersion should return unsupported error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("kind", func(t *testing.T) {
|
|
tampered := file
|
|
tampered.Kind = "other_connection_package"
|
|
_, err := decryptConnectionPackage(tampered, "correct-password")
|
|
if !errors.Is(err, errConnectionPackageUnsupported) {
|
|
t.Fatalf("unsupported kind should return unsupported error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("cipher", func(t *testing.T) {
|
|
tampered := file
|
|
tampered.Cipher = "AES-128-GCM"
|
|
_, err := decryptConnectionPackage(tampered, "correct-password")
|
|
if !errors.Is(err, errConnectionPackageUnsupported) {
|
|
t.Fatalf("unsupported cipher should return unsupported error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("kdf-name", func(t *testing.T) {
|
|
tampered := file
|
|
tampered.KDF.Name = "PBKDF2"
|
|
_, err := decryptConnectionPackage(tampered, "correct-password")
|
|
if !errors.Is(err, errConnectionPackageUnsupported) {
|
|
t.Fatalf("unsupported kdf name should return unsupported error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestValidateConnectionPackageKDFSpecRejectsOversizedParams(t *testing.T) {
|
|
t.Run("memory", func(t *testing.T) {
|
|
spec := defaultConnectionPackageKDFSpec()
|
|
spec.MemoryKiB = connectionPackageKDFMaxMemoryKiB + 1
|
|
if err := validateConnectionPackageKDFSpec(spec); !errors.Is(err, errConnectionPackageUnsupported) {
|
|
t.Fatalf("oversized memory should return unsupported error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("timeCost", func(t *testing.T) {
|
|
spec := defaultConnectionPackageKDFSpec()
|
|
spec.TimeCost = connectionPackageKDFMaxTimeCost + 1
|
|
if err := validateConnectionPackageKDFSpec(spec); !errors.Is(err, errConnectionPackageUnsupported) {
|
|
t.Fatalf("oversized timeCost should return unsupported error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("parallelism", func(t *testing.T) {
|
|
spec := defaultConnectionPackageKDFSpec()
|
|
spec.Parallelism = connectionPackageKDFMaxParallelism + 1
|
|
if err := validateConnectionPackageKDFSpec(spec); !errors.Is(err, errConnectionPackageUnsupported) {
|
|
t.Fatalf("oversized parallelism should return unsupported error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDecryptConnectionPackagePlaintextRejectsOversizedPayload(t *testing.T) {
|
|
nonce := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageNonceBytes))
|
|
salt := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageSaltBytes))
|
|
payload := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageMaxCiphertextBytes+1))
|
|
|
|
file := connectionPackageFile{
|
|
SchemaVersion: connectionPackageSchemaVersion,
|
|
Kind: connectionPackageKind,
|
|
Cipher: connectionPackageCipher,
|
|
KDF: connectionPackageKDFSpec{
|
|
Name: connectionPackageKDFName,
|
|
MemoryKiB: connectionPackageKDFDefaultMemoryKiB,
|
|
TimeCost: connectionPackageKDFDefaultTimeCost,
|
|
Parallelism: connectionPackageKDFDefaultParallelism,
|
|
Salt: salt,
|
|
},
|
|
Nonce: nonce,
|
|
Payload: payload,
|
|
}
|
|
|
|
_, err := decryptConnectionPackagePlaintext(file, "correct-password")
|
|
if !errors.Is(err, errConnectionPackagePayloadTooLarge) {
|
|
t.Fatalf("oversized payload should return errConnectionPackagePayloadTooLarge, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecryptConnectionPackagePlaintextRejectsOversizedBase64PayloadBeforeDecode(t *testing.T) {
|
|
nonce := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageNonceBytes))
|
|
|
|
file := connectionPackageFile{
|
|
SchemaVersion: connectionPackageSchemaVersion,
|
|
Kind: connectionPackageKind,
|
|
Cipher: connectionPackageCipher,
|
|
KDF: connectionPackageKDFSpec{
|
|
Name: connectionPackageKDFName,
|
|
MemoryKiB: connectionPackageKDFDefaultMemoryKiB,
|
|
TimeCost: connectionPackageKDFDefaultTimeCost,
|
|
Parallelism: connectionPackageKDFDefaultParallelism,
|
|
Salt: base64.StdEncoding.EncodeToString(make([]byte, connectionPackageSaltBytes)),
|
|
},
|
|
Nonce: nonce,
|
|
Payload: strings.Repeat("A", connectionPackageMaxPayloadBase64Bytes+4),
|
|
}
|
|
|
|
_, err := decryptConnectionPackagePlaintext(file, "correct-password")
|
|
if !errors.Is(err, errConnectionPackagePayloadTooLarge) {
|
|
t.Fatalf("oversized base64 payload should return errConnectionPackagePayloadTooLarge, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEncryptConnectionPackageRejectsOversizedPayload(t *testing.T) {
|
|
_, err := encryptConnectionPackage(connectionPackagePayload{
|
|
Connections: []connectionPackageItem{
|
|
{
|
|
ID: "conn-large",
|
|
Name: strings.Repeat("x", connectionPackageMaxCiphertextBytes),
|
|
Config: connection.ConnectionConfig{
|
|
ID: "conn-large",
|
|
Type: "postgres",
|
|
Host: "db.large.local",
|
|
Port: 5432,
|
|
User: "postgres",
|
|
},
|
|
},
|
|
},
|
|
}, "correct-password")
|
|
if !errors.Is(err, errConnectionPackagePayloadTooLarge) {
|
|
t.Fatalf("oversized export payload should return errConnectionPackagePayloadTooLarge, got: %v", err)
|
|
}
|
|
}
|