Skip to content

Commit 72c5c09

Browse files
authored
feat: add encrypted connection export with credentials (Pro) (#474)
* feat: add encrypted connection export with credentials (Pro) * fix: remove onDrag that broke list selection and reordering, fix export sheet binding * fix: address code review — credential injection, Pro gate, decrypt race, passphrase handling
1 parent a57296d commit 72c5c09

9 files changed

Lines changed: 561 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Encrypted connection export with credentials: Pro users can include passwords in exports, protected by AES-256-GCM encryption with a passphrase
1213
- Connection sharing: export/import connections as `.tablepro` files (#466)
1314
- Import preview with duplicate detection, warning badges, and per-item resolution
1415
- "Copy as Import Link" context menu action for sharing via `tablepro://` URLs
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
//
2+
// ConnectionExportCrypto.swift
3+
// TablePro
4+
//
5+
// AES-256-GCM encryption for connection export files with PBKDF2 key derivation.
6+
//
7+
8+
import CommonCrypto
9+
import CryptoKit
10+
import Foundation
11+
12+
enum ConnectionExportCryptoError: LocalizedError {
13+
case invalidPassphrase
14+
case corruptData
15+
case unsupportedVersion(UInt8)
16+
17+
var errorDescription: String? {
18+
switch self {
19+
case .invalidPassphrase:
20+
return String(localized: "Incorrect passphrase")
21+
case .corruptData:
22+
return String(localized: "The encrypted file is corrupt or incomplete")
23+
case .unsupportedVersion(let v):
24+
return String(localized: "Unsupported encryption version \(v)")
25+
}
26+
}
27+
}
28+
29+
enum ConnectionExportCrypto {
30+
private static let magic = Data("TPRO".utf8) // 4 bytes
31+
private static let currentVersion: UInt8 = 1
32+
private static let saltLength = 32
33+
private static let nonceLength = 12
34+
private static let pbkdf2Iterations: UInt32 = 600_000
35+
private static let keyLength = 32 // AES-256
36+
37+
// Header: magic (4) + version (1) + salt (32) + nonce (12) = 49 bytes
38+
private static let headerLength = 4 + 1 + saltLength + nonceLength
39+
40+
static func isEncrypted(_ data: Data) -> Bool {
41+
data.count > headerLength && data.prefix(4) == magic
42+
}
43+
44+
static func encrypt(data: Data, passphrase: String) throws -> Data {
45+
var salt = Data(count: saltLength)
46+
let saltStatus = salt.withUnsafeMutableBytes { buffer -> OSStatus in
47+
guard let baseAddress = buffer.baseAddress else { return errSecParam }
48+
return SecRandomCopyBytes(kSecRandomDefault, saltLength, baseAddress)
49+
}
50+
guard saltStatus == errSecSuccess else {
51+
throw ConnectionExportCryptoError.corruptData
52+
}
53+
54+
let key = try deriveKey(passphrase: passphrase, salt: salt)
55+
let nonce = AES.GCM.Nonce()
56+
let sealed = try AES.GCM.seal(data, using: key, nonce: nonce)
57+
58+
var result = Data()
59+
result.append(magic)
60+
result.append(currentVersion)
61+
result.append(salt)
62+
result.append(contentsOf: nonce)
63+
result.append(sealed.ciphertext)
64+
result.append(sealed.tag)
65+
return result
66+
}
67+
68+
static func decrypt(data: Data, passphrase: String) throws -> Data {
69+
guard data.count > headerLength else {
70+
throw ConnectionExportCryptoError.corruptData
71+
}
72+
guard data.prefix(4) == magic else {
73+
throw ConnectionExportCryptoError.corruptData
74+
}
75+
76+
let version = data[4]
77+
guard version <= currentVersion else {
78+
throw ConnectionExportCryptoError.unsupportedVersion(version)
79+
}
80+
81+
let salt = data[5 ..< 37]
82+
let nonceData = data[37 ..< 49]
83+
let ciphertextAndTag = data[49...]
84+
85+
guard ciphertextAndTag.count > 16 else {
86+
throw ConnectionExportCryptoError.corruptData
87+
}
88+
89+
let ciphertext = ciphertextAndTag.dropLast(16)
90+
let tag = ciphertextAndTag.suffix(16)
91+
92+
let key = try deriveKey(passphrase: passphrase, salt: Data(salt))
93+
let nonce = try AES.GCM.Nonce(data: nonceData)
94+
let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag)
95+
96+
do {
97+
return try AES.GCM.open(sealedBox, using: key)
98+
} catch {
99+
throw ConnectionExportCryptoError.invalidPassphrase
100+
}
101+
}
102+
103+
private static func deriveKey(passphrase: String, salt: Data) throws -> SymmetricKey {
104+
let passphraseData = Data(passphrase.utf8)
105+
var derivedKey = Data(count: keyLength)
106+
107+
let status = derivedKey.withUnsafeMutableBytes { derivedKeyBytes in
108+
passphraseData.withUnsafeBytes { passphraseBytes in
109+
salt.withUnsafeBytes { saltBytes in
110+
CCKeyDerivationPBKDF(
111+
CCPBKDFAlgorithm(kCCPBKDF2),
112+
passphraseBytes.baseAddress?.assumingMemoryBound(to: Int8.self),
113+
passphraseData.count,
114+
saltBytes.baseAddress?.assumingMemoryBound(to: UInt8.self),
115+
salt.count,
116+
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
117+
pbkdf2Iterations,
118+
derivedKeyBytes.baseAddress?.assumingMemoryBound(to: UInt8.self),
119+
keyLength
120+
)
121+
}
122+
}
123+
}
124+
125+
guard status == kCCSuccess else {
126+
throw ConnectionExportCryptoError.corruptData
127+
}
128+
129+
return SymmetricKey(data: derivedKey)
130+
}
131+
}

TablePro/Core/Services/Export/ConnectionExportService.swift

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ enum ConnectionExportError: LocalizedError {
1616
case invalidFormat
1717
case unsupportedVersion(Int)
1818
case decodingFailed(String)
19+
case requiresPassphrase
20+
case decryptionFailed(String)
1921

2022
var errorDescription: String? {
2123
switch self {
@@ -31,6 +33,10 @@ enum ConnectionExportError: LocalizedError {
3133
return String(localized: "This file requires a newer version of TablePro (format version \(version))")
3234
case .decodingFailed(let detail):
3335
return String(localized: "Failed to parse connection file: \(detail)")
36+
case .requiresPassphrase:
37+
return String(localized: "This file is encrypted and requires a passphrase")
38+
case .decryptionFailed(let detail):
39+
return String(localized: "Decryption failed: \(detail)")
3440
}
3541
}
3642
}
@@ -217,7 +223,8 @@ enum ConnectionExportService {
217223
appVersion: appVersion,
218224
connections: exportableConnections,
219225
groups: exportableGroups,
220-
tags: exportableTags
226+
tags: exportableTags,
227+
credentials: nil
221228
)
222229
}
223230

@@ -246,6 +253,82 @@ enum ConnectionExportService {
246253
}
247254
}
248255

256+
// MARK: - Encrypted Export
257+
258+
static func buildEnvelopeWithCredentials(for connections: [DatabaseConnection]) -> ConnectionExportEnvelope {
259+
let baseEnvelope = buildEnvelope(for: connections)
260+
261+
var credentialsMap: [String: ExportableCredentials] = [:]
262+
for (index, connection) in connections.enumerated() {
263+
let password = ConnectionStorage.shared.loadPassword(for: connection.id)
264+
let sshPassword = ConnectionStorage.shared.loadSSHPassword(for: connection.id)
265+
let keyPassphrase = ConnectionStorage.shared.loadKeyPassphrase(for: connection.id)
266+
let totpSecret = ConnectionStorage.shared.loadTOTPSecret(for: connection.id)
267+
268+
// Collect plugin-specific secure fields
269+
var pluginSecureFields: [String: String]?
270+
if let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: connection.type.pluginTypeId) {
271+
let secureFieldIds = snapshot.connection.additionalConnectionFields
272+
.filter(\.isSecure)
273+
.map(\.id)
274+
if !secureFieldIds.isEmpty {
275+
var fields: [String: String] = [:]
276+
for fieldId in secureFieldIds {
277+
if let value = ConnectionStorage.shared.loadPluginSecureField(
278+
fieldId: fieldId,
279+
for: connection.id
280+
) {
281+
fields[fieldId] = value
282+
}
283+
}
284+
if !fields.isEmpty {
285+
pluginSecureFields = fields
286+
}
287+
}
288+
}
289+
290+
let hasAnyCredential = password != nil || sshPassword != nil
291+
|| keyPassphrase != nil || totpSecret != nil || pluginSecureFields != nil
292+
293+
if hasAnyCredential {
294+
credentialsMap[String(index)] = ExportableCredentials(
295+
password: password,
296+
sshPassword: sshPassword,
297+
keyPassphrase: keyPassphrase,
298+
totpSecret: totpSecret,
299+
pluginSecureFields: pluginSecureFields
300+
)
301+
}
302+
}
303+
304+
return ConnectionExportEnvelope(
305+
formatVersion: baseEnvelope.formatVersion,
306+
exportedAt: baseEnvelope.exportedAt,
307+
appVersion: baseEnvelope.appVersion,
308+
connections: baseEnvelope.connections,
309+
groups: baseEnvelope.groups,
310+
tags: baseEnvelope.tags,
311+
credentials: credentialsMap.isEmpty ? nil : credentialsMap
312+
)
313+
}
314+
315+
static func exportConnectionsEncrypted(
316+
_ connections: [DatabaseConnection],
317+
to url: URL,
318+
passphrase: String
319+
) throws {
320+
let envelope = buildEnvelopeWithCredentials(for: connections)
321+
let jsonData = try encode(envelope)
322+
let encryptedData = try ConnectionExportCrypto.encrypt(data: jsonData, passphrase: passphrase)
323+
324+
do {
325+
try encryptedData.write(to: url, options: .atomic)
326+
logger.info("Exported \(connections.count) encrypted connections to \(url.path)")
327+
} catch {
328+
throw ConnectionExportError.fileWriteFailed(url.path)
329+
}
330+
}
331+
249332
// MARK: - Import
250333

251334
static func decodeFile(at url: URL) throws -> ConnectionExportEnvelope {
@@ -255,9 +338,53 @@ enum ConnectionExportService {
255338
} catch {
256339
throw ConnectionExportError.fileReadFailed(url.path)
257340
}
341+
342+
if ConnectionExportCrypto.isEncrypted(data) {
343+
throw ConnectionExportError.requiresPassphrase
344+
}
345+
258346
return try decodeData(data)
259347
}
260348

349+
nonisolated static func decodeEncryptedData(_ data: Data, passphrase: String) throws -> ConnectionExportEnvelope {
350+
let decryptedData: Data
351+
do {
352+
decryptedData = try ConnectionExportCrypto.decrypt(data: data, passphrase: passphrase)
353+
} catch {
354+
throw ConnectionExportError.decryptionFailed(error.localizedDescription)
355+
}
356+
return try decodeData(decryptedData)
357+
}
358+
359+
static func restoreCredentials(from envelope: ConnectionExportEnvelope, connectionIdMap: [Int: UUID]) {
360+
guard let credentials = envelope.credentials else { return }
361+
362+
for (indexString, creds) in credentials {
363+
guard let index = Int(indexString),
364+
let connectionId = connectionIdMap[index] else { continue }
365+
366+
if let password = creds.password {
367+
ConnectionStorage.shared.savePassword(password, for: connectionId)
368+
}
369+
if let sshPassword = creds.sshPassword {
370+
ConnectionStorage.shared.saveSSHPassword(sshPassword, for: connectionId)
371+
}
372+
if let keyPassphrase = creds.keyPassphrase {
373+
ConnectionStorage.shared.saveKeyPassphrase(keyPassphrase, for: connectionId)
374+
}
375+
if let totpSecret = creds.totpSecret {
376+
ConnectionStorage.shared.saveTOTPSecret(totpSecret, for: connectionId)
377+
}
378+
if let secureFields = creds.pluginSecureFields {
379+
for (fieldId, value) in secureFields {
380+
ConnectionStorage.shared.savePluginSecureField(value, fieldId: fieldId, for: connectionId)
381+
}
382+
}
383+
}
384+
385+
logger.info("Restored credentials for \(credentials.count) connections")
386+
}
387+
261388
/// Decode an envelope from raw JSON data. Can be called from any thread.
262389
nonisolated static func decodeData(_ data: Data) throws -> ConnectionExportEnvelope {
263390
let decoder = JSONDecoder()
@@ -343,11 +470,16 @@ enum ConnectionExportService {
343470
return ConnectionImportPreview(envelope: envelope, items: items)
344471
}
345472

473+
struct ImportResult {
474+
let importedCount: Int
475+
let connectionIdMap: [Int: UUID] // envelope index -> new connection UUID
476+
}
477+
346478
@discardableResult
347479
static func performImport(
348480
_ preview: ConnectionImportPreview,
349481
resolutions: [UUID: ImportResolution]
350-
) -> Int {
482+
) -> ImportResult {
351483
// Create missing groups
352484
let existingGroups = GroupStorage.shared.loadGroups()
353485
if let envelopeGroups = preview.envelope.groups {
@@ -387,9 +519,17 @@ enum ConnectionExportService {
387519
}
388520

389521
var importedCount = 0
522+
var connectionIdMap: [Int: UUID] = [:]
523+
524+
// Build a lookup from item.id to envelope index
525+
let itemIndexMap: [UUID: Int] = Dictionary(
526+
uniqueKeysWithValues: preview.items.enumerated().map { ($1.id, $0) }
527+
)
390528

391529
for item in preview.items {
392530
let resolution = resolutions[item.id] ?? .skip
531+
guard let envelopeIndex = itemIndexMap[item.id] else { continue }
532+
393533
switch resolution {
394534
case .skip:
395535
continue
@@ -406,6 +546,7 @@ enum ConnectionExportService {
406546
name: name
407547
)
408548
ConnectionStorage.shared.addConnection(connection, password: nil)
549+
connectionIdMap[envelopeIndex] = connectionId
409550
importedCount += 1
410551

411552
case .replace(let existingId):
@@ -415,6 +556,7 @@ enum ConnectionExportService {
415556
name: item.connection.name
416557
)
417558
ConnectionStorage.shared.updateConnection(connection, password: nil)
559+
connectionIdMap[envelopeIndex] = existingId
418560
importedCount += 1
419561
}
420562
}
@@ -424,7 +566,7 @@ enum ConnectionExportService {
424566
logger.info("Imported \(importedCount) connections")
425567
}
426568

427-
return importedCount
569+
return ImportResult(importedCount: importedCount, connectionIdMap: connectionIdMap)
428570
}
429571

430572
// MARK: - Deeplink Builder

0 commit comments

Comments
 (0)