SophaxChat · Code Richard Metzner

09 · What stays on the phone

Sources/SophaxChatCore/Storage/BackupManager.swift

172 lines · Swift

// BackupManager.swift
// SophaxChatCore
//
// Encrypted local backup — no cloud, no server, no third party.
//
// Philosophy: You own your data. The backup file is encrypted with a key derived
// from a passphrase only you know. Nobody else — not us, not Apple — can open it.
//
// What is backed up:
//   • All messages (text, reactions, status, timestamps)
//   • Contacts (known peers: username, public keys, onion address)
//   • Your username
//
// What is intentionally NOT backed up:
//   • Identity private keys (they are device-bound by design)
//   • Session states (ephemeral — forward secrecy means they SHOULD not survive)
//   • Prekeys (regenerated automatically on new device)
//
// When restoring on a new device:
//   • A fresh identity is generated
//   • Message history is restored
//   • Contacts are restored (but sessions must be re-established — each peer will
//     see a new safety number, consistent with key continuity principles)
//
// Encryption:
//   • Key derivation: PBKDF2-HMAC-SHA256, 720 000 iterations (replaces HKDF — adds brute-force resistance)
//   • Cipher: AES-256-GCM
//   • File format: 4B magic | 1B version | 32B salt | 12B nonce | ciphertext+tag
//
// Security notes:
//   • identityFingerprint: SHA256(signingKeyPublic + dhKeyPublic) included in backup.
//     Checked on restore to warn if backup belongs to a different identity.
//   • Minimum passphrase enforced in UI (16 characters).

import Foundation
import CryptoKit
import CommonCrypto

public struct SophaxBackup: Codable {
    public let version:             Int
    public let createdAt:           Date
    public let username:            String
    public let peers:               [KnownPeer]
    public let messages:            [String: [StoredMessage]]
    /// SHA256 hex of the identity keys at backup time. Used to warn on cross-identity restore.
    public let identityFingerprint: String?

    public init(version: Int, createdAt: Date, username: String,
                peers: [KnownPeer], messages: [String: [StoredMessage]],
                identityFingerprint: String? = nil) {
        self.version              = version
        self.createdAt            = createdAt
        self.username             = username
        self.peers                = peers
        self.messages             = messages
        self.identityFingerprint  = identityFingerprint
    }
}

public enum BackupError: Error, LocalizedError {
    case wrongPassphrase
    case corruptFile
    case exportFailed(String)

    public var errorDescription: String? {
        switch self {
        case .wrongPassphrase:   return "Wrong passphrase — backup could not be decrypted."
        case .corruptFile:       return "Backup file is corrupt or was not created by SophaxChat."
        case .exportFailed(let r): return "Export failed: \(r)"
        }
    }
}

public final class BackupManager: Sendable {

    private static let magic:      [UInt8] = [0x53, 0x58, 0x42, 0x4B]  // "SXBK"
    private static let fileVersion: UInt8  = 2   // v2: PBKDF2 KDF + identityFingerprint
    // 720k iterations: OWASP 2024 baseline is 600k for SHA-256; 720k raises the offline-attack
    // bar by 20% while staying well within 1–2 s on A15+ hardware (measured ~0.8 s on A16).
    private static let pbkdf2Iterations    = 720_000

    // MARK: - Export

    /// Build an encrypted backup blob from the given data.
    /// Returns raw Data — caller decides where to save it (file, share sheet, etc.).
    public static func export(
        backup: SophaxBackup,
        passphrase: String
    ) throws -> Data {
        let json     = try JSONEncoder().encode(backup)
        let salt     = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
        let key      = try deriveKey(passphrase: passphrase, salt: salt)
        let sealed   = try AES.GCM.seal(json, using: key)
        guard let combined = sealed.combined else {
            throw BackupError.exportFailed("AES-GCM combined output unavailable")
        }

        var out = Data()
        out.append(contentsOf: magic)
        out.append(fileVersion)
        out.append(salt)
        out.append(combined)   // 12B nonce + ciphertext + 16B tag
        return out
    }

    // MARK: - Import

    /// Decrypt and decode a backup blob.
    /// - Returns: The decoded backup.
    /// - Throws: `BackupError.wrongPassphrase`, `.corruptFile`, or `.identityMismatch`.
    public static func `import`(
        data: Data,
        passphrase: String
    ) throws -> SophaxBackup {
        // Validate magic + version
        guard data.count > 4 + 1 + 32 + 12 + 16 else { throw BackupError.corruptFile }
        let fileMagic = [UInt8](data[0..<4])
        guard fileMagic == magic else { throw BackupError.corruptFile }
        // version byte at index 4 — reserved for future migration
        guard data[4] == fileVersion else { throw BackupError.corruptFile }
        let salt     = data[5..<37]
        let combined = data[37...]

        let key = try deriveKey(passphrase: passphrase, salt: Data(salt))
        do {
            let box  = try AES.GCM.SealedBox(combined: Data(combined))
            let json = try AES.GCM.open(box, using: key)
            return try JSONDecoder().decode(SophaxBackup.self, from: json)
        } catch {
            throw BackupError.wrongPassphrase
        }
    }

    // MARK: - Suggested filename

    public static func suggestedFilename() -> String {
        let fmt = DateFormatter()
        fmt.dateFormat = "yyyy-MM-dd"
        return "sophaxchat-backup-\(fmt.string(from: Date())).sophaxbackup"
    }

    // MARK: - Private: Key derivation

    /// PBKDF2-HMAC-SHA256 with 720 000 iterations.
    /// Replaces HKDF (single round, no brute-force resistance) — makes GPU attacks ~600 000× slower.
    private static func deriveKey(passphrase: String, salt: Data) throws -> SymmetricKey {
        var passData = Data(passphrase.utf8)
        defer { passData.resetBytes(in: 0..<passData.count) }
        var derived = Data(repeating: 0, count: 32)
        defer { derived.resetBytes(in: 0..<derived.count) }

        let status = derived.withUnsafeMutableBytes { derivedPtr in
            salt.withUnsafeBytes { saltPtr in
                passData.withUnsafeBytes { passPtr in
                    CCKeyDerivationPBKDF(
                        CCPBKDFAlgorithm(kCCPBKDF2),
                        passPtr.baseAddress, passData.count,
                        saltPtr.baseAddress, salt.count,
                        CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
                        UInt32(pbkdf2Iterations),
                        derivedPtr.baseAddress, 32
                    )
                }
            }
        }

        guard status == kCCSuccess else {
            throw BackupError.exportFailed("PBKDF2 derivation failed (\(status))")
        }
        return SymmetricKey(data: derived)
    }
}