From 79c5bfb3d45beca1235e242004e0ba7aa08df392 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 7 Jun 2026 13:38:53 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(connection-import):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AF=BC=E5=85=A5=20Navicat=20NCX=20?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E4=B8=8E=E5=AF=86=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Navicat NCX XML 解析与 v1/v2 密码解密能力 - 接入后端连接导入链路并补充导入失败提示 - 前端补充 NCX 格式识别、缺失密码提示与定向测试 --- frontend/src/App.tsx | 4 +- frontend/src/utils/connectionExport.test.ts | 7 + frontend/src/utils/connectionExport.ts | 11 +- internal/app/connection_package_transfer.go | 15 + internal/app/navicat_ncx_import.go | 470 ++++++++++++++++++++ internal/app/navicat_ncx_import_test.go | 219 +++++++++ 6 files changed, 723 insertions(+), 3 deletions(-) create mode 100644 internal/app/navicat_ncx_import.go create mode 100644 internal/app/navicat_ncx_import_test.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f120f06..c5adfe7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2058,14 +2058,14 @@ function App() { const importKind = detectConnectionImportKind(raw); if (importKind === 'invalid') { - void message.error('文件格式错误:仅支持 GoNavi 恢复包、历史 JSON 连接数组或 MySQL Workbench XML'); + void message.error('文件格式错误:仅支持 GoNavi 恢复包、历史 JSON 连接数组、MySQL Workbench XML 或 Navicat NCX'); return; } try { setPendingConnectionImportPayload(null); const importedViews = await importConnectionsPayload(raw, ''); - if (importKind === 'mysql-workbench-xml' && importedViews.some(v => !v.hasPrimaryPassword)) { + if ((importKind === 'mysql-workbench-xml' || importKind === 'navicat-ncx') && importedViews.some(v => !v.hasPrimaryPassword)) { void message.warning(`成功导入 ${importedViews.length} 个连接,部分连接未包含密码,请编辑对应连接并输入密码后保存`); } else { void message.success(`成功导入 ${importedViews.length} 个连接`); diff --git a/frontend/src/utils/connectionExport.test.ts b/frontend/src/utils/connectionExport.test.ts index d18541a..110535a 100644 --- a/frontend/src/utils/connectionExport.test.ts +++ b/frontend/src/utils/connectionExport.test.ts @@ -88,6 +88,13 @@ describe('connectionExport', () => { ]))).toBe('legacy-json'); }); + it('detects Navicat NCX xml exports', () => { + expect(detectConnectionImportKind(` + + +`)).toBe('navicat-ncx'); + }); + it('returns invalid for malformed or unsupported content', () => { expect(detectConnectionImportKind('{not-json}')).toBe('invalid'); expect(detectConnectionImportKind(JSON.stringify({ diff --git a/frontend/src/utils/connectionExport.ts b/frontend/src/utils/connectionExport.ts index 2a790c3..a54ce02 100644 --- a/frontend/src/utils/connectionExport.ts +++ b/frontend/src/utils/connectionExport.ts @@ -1,6 +1,6 @@ import type { ConnectionConfig, SavedConnection } from '../types'; -export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | 'invalid'; +export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | 'navicat-ncx' | 'invalid'; export type ConnectionPackageDialogSnapshot = { open: boolean; mode: 'export' | 'import'; @@ -109,10 +109,19 @@ const isMySQLWorkbenchXML = (raw: string): boolean => ( raw.includes(' ( + raw.includes('= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') { + continue + } + return false + } + return true +} + +func decryptNavicatHexSecret(raw string) (string, error) { + ciphertext, err := hex.DecodeString(strings.TrimSpace(raw)) + if err != nil { + return "", err + } + + if plaintext, err := decryptNavicatHexSecretV1(ciphertext); err == nil { + return plaintext, nil + } + if plaintext, err := decryptNavicatHexSecretV2(ciphertext); err == nil { + return plaintext, nil + } + return "", errNavicatSecretDecryptFailed +} + +func newNavicatCryptoV1() (*navicatCryptoV1, error) { + sum := sha1.Sum([]byte("3DC5CA39")) + block, err := blowfish.NewCipher(sum[:]) + if err != nil { + return nil, err + } + initialVector := bytesRepeat(0xFF, block.BlockSize()) + block.Encrypt(initialVector, initialVector) + return &navicatCryptoV1{ + block: block, + initialVector: initialVector, + }, nil +} + +func decryptNavicatHexSecretV1(ciphertext []byte) (string, error) { + cryptoV1, err := newNavicatCryptoV1() + if err != nil { + return "", err + } + return cryptoV1.decrypt(ciphertext) +} + +func (c *navicatCryptoV1) decrypt(ciphertext []byte) (string, error) { + blockSize := c.block.BlockSize() + currentVector := append([]byte(nil), c.initialVector...) + roundBytes := len(ciphertext) / blockSize * blockSize + plaintext := make([]byte, 0, len(ciphertext)) + decryptedBlock := make([]byte, blockSize) + + for offset := 0; offset < roundBytes; offset += blockSize { + c.block.Decrypt(decryptedBlock, ciphertext[offset:offset+blockSize]) + plaintext = append(plaintext, xorBytes(decryptedBlock, currentVector)...) + currentVector = xorBytes(currentVector, ciphertext[offset:offset+blockSize]) + } + + if leftover := len(ciphertext) - roundBytes; leftover > 0 { + stream := make([]byte, blockSize) + c.block.Encrypt(stream, currentVector) + plaintext = append(plaintext, xorBytes(ciphertext[roundBytes:], stream[:leftover])...) + } + + if !isLikelyNavicatPlaintext(plaintext) { + return "", errNavicatSecretDecryptFailed + } + return string(plaintext), nil +} + +func decryptNavicatHexSecretV2(ciphertext []byte) (string, error) { + block, err := aes.NewCipher([]byte("libcckeylibcckey")) + if err != nil { + return "", err + } + if len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 { + return "", errNavicatSecretDecryptFailed + } + + plaintext := make([]byte, len(ciphertext)) + currentVector := []byte("libcciv libcciv ") + for offset := 0; offset < len(ciphertext); offset += block.BlockSize() { + block.Decrypt(plaintext[offset:offset+block.BlockSize()], ciphertext[offset:offset+block.BlockSize()]) + for i := 0; i < block.BlockSize(); i++ { + plaintext[offset+i] ^= currentVector[i] + } + currentVector = ciphertext[offset : offset+block.BlockSize()] + } + + plaintext, err = stripPKCS7Padding(plaintext, block.BlockSize()) + if err != nil || !isLikelyNavicatPlaintext(plaintext) { + return "", errNavicatSecretDecryptFailed + } + return string(plaintext), nil +} + +func stripPKCS7Padding(data []byte, blockSize int) ([]byte, error) { + if len(data) == 0 || len(data)%blockSize != 0 { + return nil, errNavicatSecretDecryptFailed + } + paddingLength := int(data[len(data)-1]) + if paddingLength <= 0 || paddingLength > blockSize || paddingLength > len(data) { + return nil, errNavicatSecretDecryptFailed + } + for _, value := range data[len(data)-paddingLength:] { + if int(value) != paddingLength { + return nil, errNavicatSecretDecryptFailed + } + } + return data[:len(data)-paddingLength], nil +} + +func isLikelyNavicatPlaintext(data []byte) bool { + if len(data) == 0 { + return true + } + if !utf8.Valid(data) { + return false + } + for _, r := range string(data) { + if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' { + return false + } + } + return true +} + +func xorBytes(left, right []byte) []byte { + limit := len(left) + if len(right) < limit { + limit = len(right) + } + result := make([]byte, limit) + for index := 0; index < limit; index++ { + result[index] = left[index] ^ right[index] + } + return result +} + +func bytesRepeat(value byte, count int) []byte { + data := make([]byte, count) + for index := range data { + data[index] = value + } + return data +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/app/navicat_ncx_import_test.go b/internal/app/navicat_ncx_import_test.go new file mode 100644 index 0000000..eddea35 --- /dev/null +++ b/internal/app/navicat_ncx_import_test.go @@ -0,0 +1,219 @@ +package app + +import ( + "crypto/aes" + "encoding/hex" + "fmt" + "strings" + "testing" +) + +func TestImportConnectionsPayloadNavicatNCXImportsConfigsAndSecrets(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + mysqlPassword := mustEncryptNavicatV1(t, "mysql-secret") + postgresPassword := mustEncryptNavicatV2(t, "pg-secret") + sshPassword := mustEncryptNavicatV1(t, "ssh-secret") + proxyPassword := mustEncryptNavicatV2(t, "proxy-secret") + + raw := fmt.Sprintf(` + + + + +`, mysqlPassword, postgresPassword, sshPassword, proxyPassword) + + imported, err := app.ImportConnectionsPayload(raw, "") + if err != nil { + t.Fatalf("ImportConnectionsPayload returned error: %v", err) + } + if len(imported) != 3 { + t.Fatalf("expected 3 imported connections, got %d", len(imported)) + } + + mysqlConn := imported[0] + if mysqlConn.Name != "Primary MySQL" { + t.Fatalf("expected mysql connection name, got %q", mysqlConn.Name) + } + if mysqlConn.Config.Type != "mysql" { + t.Fatalf("expected mysql config type, got %q", mysqlConn.Config.Type) + } + if !mysqlConn.HasPrimaryPassword { + t.Fatal("expected mysql connection password flag") + } + resolvedMySQL, err := app.resolveConnectionSecrets(mysqlConn.Config) + if err != nil { + t.Fatalf("resolveConnectionSecrets for mysql returned error: %v", err) + } + if resolvedMySQL.Password != "mysql-secret" { + t.Fatalf("expected mysql password, got %q", resolvedMySQL.Password) + } + if resolvedMySQL.Host != "127.0.0.1" || resolvedMySQL.Port != 3307 || resolvedMySQL.Database != "demo" { + t.Fatalf("expected mysql host/port/database to survive import, got %#v", resolvedMySQL) + } + + postgresConn := imported[1] + if postgresConn.Config.Type != "postgres" { + t.Fatalf("expected postgres config type, got %q", postgresConn.Config.Type) + } + if !postgresConn.HasPrimaryPassword || !postgresConn.HasSSHPassword || !postgresConn.HasProxyPassword { + t.Fatalf("expected postgres connection to keep primary/ssh/proxy secrets, got %#v", postgresConn) + } + if !postgresConn.Config.UseSSL || postgresConn.Config.SSLMode != "required" || postgresConn.Config.SSLCAPath != "/etc/ssl/ca.pem" { + t.Fatalf("expected postgres SSL settings to survive import, got %#v", postgresConn.Config) + } + if !postgresConn.Config.UseSSH || postgresConn.Config.SSH.Host != "jump.local" || postgresConn.Config.SSH.Port != 2222 || postgresConn.Config.SSH.User != "ops" { + t.Fatalf("expected postgres SSH settings to survive import, got %#v", postgresConn.Config.SSH) + } + if !postgresConn.Config.UseProxy || postgresConn.Config.Proxy.Type != "http" || postgresConn.Config.Proxy.Host != "proxy.local" || postgresConn.Config.Proxy.Port != 8088 || postgresConn.Config.Proxy.User != "proxy-user" { + t.Fatalf("expected postgres proxy settings to survive import, got %#v", postgresConn.Config.Proxy) + } + resolvedPostgres, err := app.resolveConnectionSecrets(postgresConn.Config) + if err != nil { + t.Fatalf("resolveConnectionSecrets for postgres returned error: %v", err) + } + if resolvedPostgres.Password != "pg-secret" { + t.Fatalf("expected postgres password, got %q", resolvedPostgres.Password) + } + if resolvedPostgres.SSH.Password != "ssh-secret" { + t.Fatalf("expected ssh password, got %q", resolvedPostgres.SSH.Password) + } + if resolvedPostgres.Proxy.Password != "proxy-secret" { + t.Fatalf("expected proxy password, got %q", resolvedPostgres.Proxy.Password) + } + + sqliteConn := imported[2] + if sqliteConn.Config.Type != "sqlite" { + t.Fatalf("expected sqlite config type, got %q", sqliteConn.Config.Type) + } + if sqliteConn.Config.Host != `C:\navicat\history.db` || sqliteConn.Config.Database != `C:\navicat\history.db` { + t.Fatalf("expected sqlite file path to survive import, got %#v", sqliteConn.Config) + } +} + +func TestImportConnectionsPayloadNavicatNCXRejectsUndecryptableSavedPassword(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + raw := ` + + +` + + _, err := app.ImportConnectionsPayload(raw, "") + if err == nil { + t.Fatal("expected invalid Navicat password import to fail") + } + if got := err.Error(); got == "" || !strings.Contains(got, "Navicat") && !strings.Contains(got, "密码") { + t.Fatalf("expected navicat password error, got %v", err) + } +} + +func TestImportConnectionsPayloadNavicatNCXMapsOracleSIDAndRedisDB(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + oraclePassword := mustEncryptNavicatV2(t, "oracle-secret") + redisPassword := mustEncryptNavicatV1(t, "redis-secret") + + raw := fmt.Sprintf(` + + + +`, oraclePassword, redisPassword) + + imported, err := app.ImportConnectionsPayload(raw, "") + if err != nil { + t.Fatalf("ImportConnectionsPayload returned error: %v", err) + } + if len(imported) != 2 { + t.Fatalf("expected 2 imported connections, got %d", len(imported)) + } + + oracleConn := imported[0] + if oracleConn.Config.Type != "oracle" || oracleConn.Config.ConnectionParams != "SID=ORCL" { + t.Fatalf("expected oracle SID connection params, got %#v", oracleConn.Config) + } + resolvedOracle, err := app.resolveConnectionSecrets(oracleConn.Config) + if err != nil { + t.Fatalf("resolveConnectionSecrets for oracle returned error: %v", err) + } + if resolvedOracle.Password != "oracle-secret" { + t.Fatalf("expected oracle password, got %q", resolvedOracle.Password) + } + + redisConn := imported[1] + if redisConn.Config.Type != "redis" || redisConn.Config.RedisDB != 5 { + t.Fatalf("expected redis db index to survive import, got %#v", redisConn.Config) + } + resolvedRedis, err := app.resolveConnectionSecrets(redisConn.Config) + if err != nil { + t.Fatalf("resolveConnectionSecrets for redis returned error: %v", err) + } + if resolvedRedis.Password != "redis-secret" { + t.Fatalf("expected redis password, got %q", resolvedRedis.Password) + } +} + +func mustEncryptNavicatV1(t *testing.T, value string) string { + t.Helper() + cryptoV1, err := newNavicatCryptoV1() + if err != nil { + t.Fatalf("newNavicatCryptoV1 returned error: %v", err) + } + return cryptoV1.encrypt([]byte(value)) +} + +func mustEncryptNavicatV2(t *testing.T, value string) string { + t.Helper() + block, err := aes.NewCipher([]byte("libcckeylibcckey")) + if err != nil { + t.Fatalf("aes.NewCipher returned error: %v", err) + } + plaintext := applyPKCS7Padding([]byte(value), block.BlockSize()) + currentVector := []byte("libcciv libcciv ") + ciphertext := make([]byte, len(plaintext)) + for offset := 0; offset < len(plaintext); offset += block.BlockSize() { + blockInput := xorBytes(plaintext[offset:offset+block.BlockSize()], currentVector) + block.Encrypt(ciphertext[offset:offset+block.BlockSize()], blockInput) + currentVector = ciphertext[offset : offset+block.BlockSize()] + } + return strings.ToUpper(hex.EncodeToString(ciphertext)) +} + +func (c *navicatCryptoV1) encrypt(plaintext []byte) string { + blockSize := c.block.BlockSize() + currentVector := append([]byte(nil), c.initialVector...) + roundBytes := len(plaintext) / blockSize * blockSize + ciphertext := make([]byte, 0, len(plaintext)) + encryptedBlock := make([]byte, blockSize) + + for offset := 0; offset < roundBytes; offset += blockSize { + blockInput := xorBytes(plaintext[offset:offset+blockSize], currentVector) + c.block.Encrypt(encryptedBlock, blockInput) + ciphertext = append(ciphertext, encryptedBlock...) + currentVector = xorBytes(currentVector, encryptedBlock) + } + + if leftover := len(plaintext) - roundBytes; leftover > 0 { + stream := make([]byte, blockSize) + c.block.Encrypt(stream, currentVector) + ciphertext = append(ciphertext, xorBytes(plaintext[roundBytes:], stream[:leftover])...) + } + + return strings.ToUpper(hex.EncodeToString(ciphertext)) +} + +func applyPKCS7Padding(data []byte, blockSize int) []byte { + paddingLength := blockSize - (len(data) % blockSize) + if paddingLength == 0 { + paddingLength = blockSize + } + padded := make([]byte, 0, len(data)+paddingLength) + padded = append(padded, data...) + for i := 0; i < paddingLength; i++ { + padded = append(padded, byte(paddingLength)) + } + return padded +}