@@ -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