1// FullExportManager.swift
2// SophaxChatCore
3//
4// Combined identity + message backup in a single encrypted file.
5//
6// File format (.sxfe — "SophaxChat Full Export"):
7// "SXFE" (4B) | 0x01 (version, 1B) | iterations (4B big-endian UInt32)
8// | salt (32B) | AES-256-GCM nonce+ciphertext+tag
9//
10// Plaintext payload:
11// JSON { signingKeyPrivate, dhKeyPrivate, username, backup: SophaxBackup }
12//
13// KDF: PBKDF2-HMAC-SHA256, 720 000 iterations.
14// Minimum passphrase: 16 characters (same policy as identity export).
15//
16// Security notes:
17// • All sensitive intermediate buffers are zeroed with resetBytes before deallocation.
18// • Restoring overwrites both identity keys AND messages — use with care.
19// • DR session states are NOT included (forward secrecy — regenerated on next contact).
20
21import Foundation
22import CryptoKit
23import CommonCrypto
24
25// MARK: - Payload
26
27private struct FullExportPayload: Codable {
28 let signingKeyPrivate: Data // Ed25519 raw 32B
29 let dhKeyPrivate: Data // X25519 raw 32B
30 let username: String
31 let exportedAt: Date
32 let backup: SophaxBackup
33}
34
35// MARK: - Errors
36
37public enum FullExportError: Error, LocalizedError {
38 case wrongPassphrase
39 case corruptFile
40 case exportFailed(String)
41
42 public var errorDescription: String? {
43 switch self {
44 case .wrongPassphrase: return "Wrong passphrase — export file could not be decrypted."
45 case .corruptFile: return "File is corrupt or was not created by SophaxChat."
46 case .exportFailed(let r): return "Export failed: \(r)"
47 }
48 }
49}
50
51// MARK: - Manager
52
53public final class FullExportManager: Sendable {
54
55 private static let magic: [UInt8] = [0x53, 0x58, 0x46, 0x45] // "SXFE"
56 private static let fileVersion: UInt8 = 1
57 private static let pbkdf2Iterations = 720_000
58 private static let minPassphraseLength = 16
59
60 // MARK: - Export
61
62 /// Encrypt and serialize identity keys + full message backup into a single blob.
63 ///
64 /// - Parameters:
65 /// - identity: The local `IdentityManager`.
66 /// - backup: A `SophaxBackup` built from the current message store and peer list.
67 /// - passphrase: User-supplied passphrase (min 16 chars, enforced here).
68 /// - Returns: Raw encrypted `Data` to be saved as a `.sxfe` file.
69 public static func export(
70 identity: IdentityManager,
71 backup: SophaxBackup,
72 passphrase: String
73 ) throws -> Data {
74 guard passphrase.count >= minPassphraseLength else {
75 throw FullExportError.exportFailed("Passphrase must be at least \(minPassphraseLength) characters")
76 }
77
78 let sigKey = try identity.signingPrivateKeyData()
79 let dhKey = try identity.dhPrivateKeyData()
80
81 let payload = FullExportPayload(
82 signingKeyPrivate: sigKey,
83 dhKeyPrivate: dhKey,
84 username: identity.publicIdentity.username,
85 exportedAt: Date(),
86 backup: backup
87 )
88
89 let salt = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
90 let iters = pbkdf2Iterations
91
92 var jsonData = try JSONEncoder().encode(payload)
93 defer { jsonData.resetBytes(in: jsonData.startIndex..<jsonData.endIndex) }
94
95 var derivedBuf = Data(repeating: 0, count: 32)
96 defer { derivedBuf.resetBytes(in: derivedBuf.startIndex..<derivedBuf.endIndex) }
97 let key = try deriveKey(passphrase: passphrase, salt: salt, iters: iters, buf: &derivedBuf)
98
99 let sealed = try AES.GCM.seal(jsonData, using: key)
100 guard let combined = sealed.combined else {
101 throw FullExportError.exportFailed("AES-GCM combined output unavailable")
102 }
103
104 var out = Data()
105 out.append(contentsOf: magic)
106 out.append(fileVersion)
107 withUnsafeBytes(of: UInt32(iters).bigEndian) { out.append(contentsOf: $0) }
108 out.append(salt)
109 out.append(combined) // 12B nonce + ciphertext + 16B GCM tag
110 return out
111 }
112
113 // MARK: - Restore
114
115 /// Decrypt and restore a full export — writes identity keys to Keychain and
116 /// replaces the current message store.
117 ///
118 /// - Parameters:
119 /// - data: Raw blob previously produced by `export(identity:backup:passphrase:)`.
120 /// - passphrase: The passphrase used during export.
121 /// - keychain: The Keychain manager to write restored identity keys into.
122 /// - messageStore: The message store to restore conversation history into.
123 /// - Returns: The restored username.
124 /// - Throws: `FullExportError.wrongPassphrase` or `.corruptFile`.
125 @discardableResult
126 public static func restore(
127 data: Data,
128 passphrase: String,
129 keychain: KeychainManager,
130 messageStore: MessageStore
131 ) throws -> String {
132 // magic(4) + version(1) + iters(4) + salt(32) + nonce(12) + tag(16) = 69 min
133 let minSize = 4 + 1 + 4 + 32 + 12 + 16
134 guard data.count > minSize else { throw FullExportError.corruptFile }
135
136 let fileMagic = [UInt8](data[0..<4])
137 guard fileMagic == magic else { throw FullExportError.corruptFile }
138 guard data[4] == fileVersion else { throw FullExportError.corruptFile }
139
140 let iters = Int(UInt32(bigEndian: data[5..<9].withUnsafeBytes { $0.load(as: UInt32.self) }))
141 guard iters >= 100_000 else { throw FullExportError.corruptFile }
142
143 let salt = Data(data[9..<41])
144 let combined = Data(data[41...])
145
146 var derivedBuf = Data(repeating: 0, count: 32)
147 defer { derivedBuf.resetBytes(in: derivedBuf.startIndex..<derivedBuf.endIndex) }
148 let key = try deriveKey(passphrase: passphrase, salt: salt, iters: iters, buf: &derivedBuf)
149
150 var jsonData: Data
151 do {
152 let box = try AES.GCM.SealedBox(combined: combined)
153 jsonData = try AES.GCM.open(box, using: key)
154 } catch {
155 throw FullExportError.wrongPassphrase
156 }
157 defer { jsonData.resetBytes(in: jsonData.startIndex..<jsonData.endIndex) }
158
159 let payload: FullExportPayload
160 do {
161 payload = try JSONDecoder().decode(FullExportPayload.self, from: jsonData)
162 } catch {
163 throw FullExportError.corruptFile
164 }
165
166 // Restore identity keys
167 let signingKey = try Curve25519.Signing.PrivateKey(rawRepresentation: payload.signingKeyPrivate)
168 let dhKey = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: payload.dhKeyPrivate)
169 try keychain.saveSigningKey(signingKey)
170 try keychain.saveDHIdentityKey(dhKey)
171 try keychain.saveUsername(payload.username)
172
173 // Restore messages and peers
174 let backup = payload.backup
175 for (peerID, msgs) in backup.messages {
176 for msg in msgs {
177 try? messageStore.append(message: msg)
178 }
179 _ = peerID // peerID is the partition key embedded in each StoredMessage
180 }
181
182 return payload.username
183 }
184
185 // MARK: - Suggested filename
186
187 public static func suggestedFilename() -> String {
188 let fmt = DateFormatter()
189 fmt.dateFormat = "yyyy-MM-dd"
190 return "sophaxchat-full-export-\(fmt.string(from: Date())).sxfe"
191 }
192
193 // MARK: - Private: Key derivation
194
195 private static func deriveKey(
196 passphrase: String,
197 salt: Data,
198 iters: Int,
199 buf: inout Data
200 ) throws -> SymmetricKey {
201 let passData = Data(passphrase.utf8)
202 let status = buf.withUnsafeMutableBytes { derivedPtr in
203 salt.withUnsafeBytes { saltPtr in
204 passData.withUnsafeBytes { passPtr in
205 CCKeyDerivationPBKDF(
206 CCPBKDFAlgorithm(kCCPBKDF2),
207 passPtr.baseAddress, passData.count,
208 saltPtr.baseAddress, salt.count,
209 CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
210 UInt32(iters),
211 derivedPtr.baseAddress, 32
212 )
213 }
214 }
215 }
216 guard status == kCCSuccess else {
217 throw FullExportError.exportFailed("PBKDF2 derivation failed (\(status))")
218 }
219 return SymmetricKey(data: buf)
220 }
221}