Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A plus button in the bottom bar of the Tables sidebar opens a menu to create a new table or view, without right-clicking. It's disabled while safe mode blocks writes.
- The sidebar can show every database on the server as an expandable tree. Switch a connection between the flat list and the tree from the View menu (Sidebar Layout); right-click a database or schema to set it active. Set the default layout for new connections in Settings, General. Applies to MySQL, MariaDB, PostgreSQL, MSSQL, ClickHouse, Redshift; SQLite, Redis, MongoDB, BigQuery keep their existing sidebar. (#139)
- A connection can read its password from a file, environment variable, or command at connect time instead of the Keychain, so scripts can provision a connection without entering the password by hand. (#1254)
- Import connections from Navicat: export a connections file from Navicat (File, Export Connections), then pick it under Import from Other App. SSH tunnel and SSL settings come across, and saved passwords are decrypted during import. (#1485)

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import AppKit
import Foundation
import os
import Security
import UniformTypeIdentifiers

// MARK: - Protocol

Expand All @@ -22,8 +23,14 @@ protocol ForeignAppImporter {
/// system show a per-item access prompt. Importers that read passwords from
/// a file (DBeaver, Beekeeper Studio) return false so no prompt is promised.
var readsPasswordsFromKeychain: Bool { get }
/// Non-nil for importers that read a user-selected export file instead of an
/// installed app's on-disk store. The values are the content types the file
/// picker filters to; the source picker presents a panel and hands the
/// chosen URL to `setSelectedFile(_:)` before importing.
var importFileTypes: [UTType]? { get }
func installedAppURL() -> URL?
func connectionCount() -> Int
mutating func setSelectedFile(_ url: URL)
func importConnections(includePasswords: Bool) throws -> ForeignAppImportResult
}

Expand All @@ -39,6 +46,10 @@ extension ForeignAppImporter {
func isAvailable() -> Bool {
installedAppURL() != nil
}

var importFileTypes: [UTType]? { nil }

mutating func setSelectedFile(_ url: URL) {}
}

// MARK: - Result
Expand Down Expand Up @@ -85,7 +96,8 @@ enum ForeignAppImporterRegistry {
SequelAceImporter(),
DBeaverImporter(),
DataGripImporter(),
BeekeeperStudioImporter()
BeekeeperStudioImporter(),
NavicatImporter()
]
}

Expand Down
156 changes: 156 additions & 0 deletions TablePro/Core/Services/Export/ForeignApp/Navicat/NavicatCipher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//
// NavicatCipher.swift
// TablePro
//
// Decrypts the password fields in a Navicat `.ncx` export. Navicat ships two
// fixed-key ciphers (the keys are baked into the app, so no user secret is
// needed): v1 used through Navicat 11 and v2 from Navicat 12 onward. A `.ncx`
// never contains the machine-bound v3 cipher, so trying v2 then v1 and keeping
// the first candidate that decodes to plausible text (valid UTF-8 with no
// control characters) recovers exported passwords. The control-character check
// stops a v1 ciphertext whose length is a multiple of the AES block size from
// being misread as a v2 one.
// Reference: github.com/HyperSine/how-does-navicat-encrypt-password
//
// - v2: AES-128-CBC, PKCS#7 padding, key `libcckeylibcckey`, IV `libcciv libcciv `
// - v1: Blowfish-ECB blocks wrapped in a custom CBC-style XOR chain, key
// `SHA1("3DC5CA39")`, IV `BlowfishECB(0xFF * 8)`
// Ciphertext is uppercase hex in both schemes.
//

import CommonCrypto
import Foundation

enum NavicatCipher {
static func decrypt(_ hex: String) -> String? {
guard !hex.isEmpty, let ciphertext = Data(navicatHex: hex) else { return nil }
if let value = decryptV2(ciphertext), isPlausibleText(value) { return value }
if let value = decryptV1(ciphertext), isPlausibleText(value) { return value }
return nil
}

private static func isPlausibleText(_ value: String) -> Bool {
guard !value.isEmpty else { return false }
return value.unicodeScalars.allSatisfy { $0.properties.generalCategory != .control }
}

// MARK: - V2 (Navicat 12+)

private static let aesKey = Data("libcckeylibcckey".utf8)
private static let aesIV = Data("libcciv libcciv ".utf8)

private static func decryptV2(_ ciphertext: Data) -> String? {
guard !ciphertext.isEmpty, ciphertext.count.isMultiple(of: kCCBlockSizeAES128) else { return nil }

let bufferSize = ciphertext.count + kCCBlockSizeAES128
var buffer = Data(count: bufferSize)
var decryptedSize = 0

let status = buffer.withUnsafeMutableBytes { bufferBytes in
ciphertext.withUnsafeBytes { cipherBytes in
aesIV.withUnsafeBytes { ivBytes in
aesKey.withUnsafeBytes { keyBytes in
CCCrypt(
CCOperation(kCCDecrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
keyBytes.baseAddress, kCCKeySizeAES128,
ivBytes.baseAddress,
cipherBytes.baseAddress, ciphertext.count,
bufferBytes.baseAddress, bufferSize,
&decryptedSize
)
}
}
}
}
guard status == kCCSuccess else { return nil }
return String(data: buffer.prefix(decryptedSize), encoding: .utf8)
}

// MARK: - V1 (Navicat 11)

private static let blowfishKey = sha1(Data("3DC5CA39".utf8))
private static let blockSize = 8

private static func decryptV1(_ ciphertext: Data) -> String? {
guard !ciphertext.isEmpty,
var vector = blowfishECB([UInt8](repeating: 0xFF, count: blockSize), operation: kCCEncrypt) else {
return nil
}

let bytes = [UInt8](ciphertext)
let fullBlocks = bytes.count / blockSize
var output = [UInt8]()
output.reserveCapacity(bytes.count)

for blockIndex in 0..<fullBlocks {
let start = blockIndex * blockSize
let block = Array(bytes[start..<start + blockSize])
guard let decrypted = blowfishECB(block, operation: kCCDecrypt) else { return nil }
for offset in 0..<blockSize {
output.append(decrypted[offset] ^ vector[offset])
}
for offset in 0..<blockSize {
vector[offset] ^= block[offset]
}
}

let remainder = bytes.count % blockSize
if remainder > 0 {
guard let keystream = blowfishECB(vector, operation: kCCEncrypt) else { return nil }
let tailStart = fullBlocks * blockSize
for offset in 0..<remainder {
output.append(bytes[tailStart + offset] ^ keystream[offset])
}
}

return String(bytes: output, encoding: .utf8)
}

// MARK: - Primitives

private static func blowfishECB(_ block: [UInt8], operation: Int) -> [UInt8]? {
var output = [UInt8](repeating: 0, count: block.count)
var movedBytes = 0
let status = blowfishKey.withUnsafeBytes { keyBytes in
CCCrypt(
CCOperation(operation),
CCAlgorithm(kCCAlgorithmBlowfish),
CCOptions(kCCOptionECBMode),
keyBytes.baseAddress, blowfishKey.count,
nil,
block, block.count,
&output, output.count,
&movedBytes
)
}
guard status == kCCSuccess, movedBytes == block.count else { return nil }
return output
}

private static func sha1(_ data: Data) -> Data {
var hash = Data(count: Int(CC_SHA1_DIGEST_LENGTH))
hash.withUnsafeMutableBytes { hashBytes in
data.withUnsafeBytes { dataBytes in
_ = CC_SHA1(dataBytes.baseAddress, CC_LONG(data.count), hashBytes.bindMemory(to: UInt8.self).baseAddress)
}
}
return hash
}
}

private extension Data {
init?(navicatHex hex: String) {
guard hex.count.isMultiple(of: 2) else { return nil }
var data = Data(capacity: hex.count / 2)
var index = hex.startIndex
while index < hex.endIndex {
let next = hex.index(index, offsetBy: 2)
guard let byte = UInt8(hex[index..<next], radix: 16) else { return nil }
data.append(byte)
index = next
}
self = data
}
}
Loading
Loading