03 · Who you are
Sources/SophaxChatCore/Crypto/IdentityExport.swift
213 lines · Swift
1// IdentityExport.swift
2// SophaxChatCore
3//
4// Encrypted identity key backup — lets users transfer their Ed25519 + X25519
5// keypair to a new device without losing their peerID.
6//
7// What is exported:
8// • Ed25519 signing private key (32 bytes)
9// • X25519 DH private key (32 bytes)
10// • Username
11//
12// What is intentionally NOT exported:
13// • DR session states (ephemeral, forward secrecy)
14// • Prekeys (regenerated on restore)
15// • Messages and contacts (use BackupManager for those)
16//
17// File format (version 2):
18// "SXID" (4B) | 0x02 (version, 1B) | iterations (4B big-endian UInt32) | salt (32B) | nonce+ciphertext+tag (AES-256-GCM)
19//
20// KDF: PBKDF2-HMAC-SHA256, 720 000 iterations stored in file (minimum 600 000 on import).
21// Minimum passphrase: 16 characters (enforced here and in UI).
22//
23// Security note:
24// Importing an identity backup replaces current Keychain keys.
25// All existing DR sessions become invalid — peers will see a key change alert
26// and must re-verify Safety Numbers.
27
28import Foundation
29import CryptoKit
30import CommonCrypto
31
32// MARK: - Payload
33
34private struct IdentityPayload: Codable {
35 let signingKeyPrivate: Data // Ed25519 raw 32B
36 let dhKeyPrivate: Data // X25519 raw 32B
37 let username: String
38 let exportedAt: Date
39}
40
41// MARK: - Errors
42
43public enum IdentityExportError: Error, LocalizedError {
44 case wrongPassphrase
45 case corruptFile
46 case exportFailed(String)
47
48 public var errorDescription: String? {
49 switch self {
50 case .wrongPassphrase: return "Wrong passphrase — identity file could not be decrypted."
51 case .corruptFile: return "Identity file is corrupt or was not created by SophaxChat."
52 case .exportFailed(let r): return "Export failed: \(r)"
53 }
54 }
55}
56
57// MARK: - Manager
58
59public final class IdentityExportManager: Sendable {
60
61 private static let magic: [UInt8] = [0x53, 0x58, 0x49, 0x44] // "SXID"
62 private static let fileVersion: UInt8 = 2
63 private static let pbkdf2Iterations = 720_000
64 private static let minPassphraseLength = 16
65
66 // MARK: - Export
67
68 /// Serialize and encrypt the local identity keys + username.
69 /// - Parameters:
70 /// - identity: The local `IdentityManager` holding keypair and username.
71 /// - passphrase: User-supplied passphrase (min 16 chars, enforced here).
72 /// - Returns: Raw encrypted blob suitable for file export / share sheet.
73 public static func export(identity: IdentityManager, passphrase: String) throws -> Data {
74 guard passphrase.count >= minPassphraseLength else {
75 throw IdentityExportError.exportFailed("Passphrase must be at least \(minPassphraseLength) characters")
76 }
77
78 let sigKey = try identity.signingPrivateKeyData()
79 let dhKey = try identity.dhPrivateKeyData()
80 let uname = identity.publicIdentity.username
81
82 let payload = IdentityPayload(
83 signingKeyPrivate: sigKey,
84 dhKeyPrivate: dhKey,
85 username: uname,
86 exportedAt: Date()
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 derivedBytes = Data(repeating: 0, count: 32)
96 defer { derivedBytes.resetBytes(in: derivedBytes.startIndex..<derivedBytes.endIndex) }
97 let key = try deriveKey(passphrase: passphrase, salt: salt, iterations: iters, derivedBytes: &derivedBytes)
98
99 let sealed = try AES.GCM.seal(jsonData, using: key)
100 guard let combined = sealed.combined else {
101 throw IdentityExportError.exportFailed("AES-GCM combined output unavailable")
102 }
103
104 var out = Data()
105 out.append(contentsOf: magic)
106 out.append(fileVersion)
107 // Store iteration count (big-endian UInt32) so future imports can adapt without
108 // breaking existing backups when the constant is increased.
109 withUnsafeBytes(of: UInt32(iters).bigEndian) { out.append(contentsOf: $0) }
110 out.append(salt)
111 out.append(combined) // 12B nonce + ciphertext + 16B GCM tag
112 return out
113 }
114
115 // MARK: - Import
116
117 /// Decrypt an identity backup and write the keys into the Keychain.
118 /// - Parameters:
119 /// - data: Raw blob previously produced by `export(identity:passphrase:)`.
120 /// - passphrase: The passphrase used during export.
121 /// - keychain: The Keychain manager to write restored keys into.
122 /// - Returns: The restored username.
123 /// - Throws: `IdentityExportError.wrongPassphrase` or `.corruptFile`.
124 @discardableResult
125 public static func `import`(
126 data: Data,
127 passphrase: String,
128 keychain: KeychainManager
129 ) throws -> String {
130 // magic(4) + version(1) + iterations(4) + salt(32) + nonce(12) + tag(16) = 69 min
131 let minSize = 4 + 1 + 4 + 32 + 12 + 16
132 guard data.count > minSize else { throw IdentityExportError.corruptFile }
133 let fileMagic = [UInt8](data[0..<4])
134 guard fileMagic == magic else { throw IdentityExportError.corruptFile }
135 guard data[4] == fileVersion else { throw IdentityExportError.corruptFile }
136
137 // Read PBKDF2 iteration count from bytes 5–8 (big-endian UInt32)
138 let iterBytes = data[5..<9]
139 let iterations = iterBytes.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
140 // Reject iteration counts below the current export default — prevents a crafted
141 // backup from forcing a weaker KDF than the app itself would produce.
142 guard iterations >= 600_000 else { throw IdentityExportError.corruptFile }
143
144 let salt = data[9..<41]
145 let combined = data[41...]
146
147 return try autoreleasepool {
148 var derivedBytes = Data(repeating: 0, count: 32)
149 defer { derivedBytes.resetBytes(in: derivedBytes.startIndex..<derivedBytes.endIndex) }
150 let key = try deriveKey(passphrase: passphrase, salt: Data(salt), iterations: Int(iterations), derivedBytes: &derivedBytes)
151
152 var jsonData: Data
153 do {
154 let box = try AES.GCM.SealedBox(combined: Data(combined))
155 jsonData = try AES.GCM.open(box, using: key)
156 } catch {
157 throw IdentityExportError.wrongPassphrase
158 }
159 defer { jsonData.resetBytes(in: jsonData.startIndex..<jsonData.endIndex) }
160
161 let payload: IdentityPayload
162 do {
163 payload = try JSONDecoder().decode(IdentityPayload.self, from: jsonData)
164 } catch {
165 throw IdentityExportError.corruptFile
166 }
167
168 // Write private keys into Keychain (overwrites existing identity)
169 let signingKey = try Curve25519.Signing.PrivateKey(rawRepresentation: payload.signingKeyPrivate)
170 let dhKey = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: payload.dhKeyPrivate)
171 try keychain.saveSigningKey(signingKey)
172 try keychain.saveDHIdentityKey(dhKey)
173 try keychain.saveUsername(payload.username)
174
175 return payload.username
176 }
177 }
178
179 // MARK: - Suggested filename
180
181 public static func suggestedFilename() -> String {
182 let fmt = DateFormatter()
183 fmt.dateFormat = "yyyy-MM-dd"
184 return "sophaxchat-identity-\(fmt.string(from: Date())).sophaxid"
185 }
186
187 // MARK: - Private: Key derivation
188
189 private static func deriveKey(passphrase: String, salt: Data, iterations: Int, derivedBytes: inout Data) throws -> SymmetricKey {
190 var passData = Data(passphrase.utf8)
191 defer { passData.resetBytes(in: 0..<passData.count) }
192
193 let status = derivedBytes.withUnsafeMutableBytes { derivedPtr in
194 salt.withUnsafeBytes { saltPtr in
195 passData.withUnsafeBytes { passPtr in
196 CCKeyDerivationPBKDF(
197 CCPBKDFAlgorithm(kCCPBKDF2),
198 passPtr.baseAddress, passData.count,
199 saltPtr.baseAddress, salt.count,
200 CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
201 UInt32(iterations),
202 derivedPtr.baseAddress, 32
203 )
204 }
205 }
206 }
207
208 guard status == kCCSuccess else {
209 throw IdentityExportError.exportFailed("PBKDF2 derivation failed (\(status))")
210 }
211 return SymmetricKey(data: derivedBytes)
212 }
213}