// // AddressBookEncryption.swift // Zashi // // Created by Lukáš Korba on 11-11-2024. // import ComposableArchitecture import Models import Foundation import CryptoKit import WalletStorage import ZcashLightClientKit extension AddressBookClient { static func serializeContacts(_ abContacts: AddressBookContacts) -> Data { var dataForEncryption = Data() // Serialize `address book version` dataForEncryption.append(contentsOf: intToBytes(abContacts.version)) // Serialize `lastUpdated` dataForEncryption.append(contentsOf: AddressBookClient.serializeDate(Date())) // Serialize `contacts.count` dataForEncryption.append(contentsOf: intToBytes(abContacts.contacts.count)) // Serialize `contacts` abContacts.contacts.forEach { contact in let serializedContact = serializeContact(contact) dataForEncryption.append(serializedContact) } return dataForEncryption } /// Encrypts address book contacts. The structure: /// [Unencrypted data] `encryption version` /// [Unencrypted data] `salt` /// [Encrypted data] `address book version` /// [Encrypted data] `timestamp` /// [Encrypted data] `contacts` /// /// This method always produces the latest structure with the latest encryption version. static func encryptContacts(_ contacts: AddressBookContacts, account: Account) throws -> Data { @Dependency(\.walletStorage) var walletStorage guard let encryptionKeys = try? walletStorage.exportAddressBookEncryptionKeys(), let addressBookKey = encryptionKeys.getCached(account: account) else { throw AddressBookClient.AddressBookClientError.missingEncryptionKey } var encryptionVersionData = Data() encryptionVersionData.append(contentsOf: intToBytes(AddressBookEncryptionKeys.Constants.version)) let dataForEncryption = AddressBookClient.serializeContacts(contacts) // Generate a fresh one-time sub-key for encrypting the address book. let salt = SymmetricKey(size: SymmetricKeySize.bits256) return try salt.withUnsafeBytes { salt in let salt = Data(salt) let subKey = addressBookKey.deriveEncryptionKey(salt: salt) // Encrypt the serialized address book. // CryptoKit encodes the SealedBox as `nonce || ciphertext || tag`. let sealed = try ChaChaPoly.seal(dataForEncryption, using: subKey) // Prepend the encryption version & salt to the SealedBox so we can re-derive the sub-key. // unencrypted data return encryptionVersionData + salt // encrypted data + sealed.combined } } /// Tries to decrypt the data with the structure: /// [Unencrypted data] `encryption version` /// [Unencrypted data] `salt` /// [Encrypted data] `address book version` /// [Encrypted data] `timestamp` /// [Encrypted data] `contacts` static func contactsFrom(encryptedData: Data, account: Account) throws -> AddressBookContacts { @Dependency(\.walletStorage) var walletStorage guard let encryptionKeys = try? walletStorage.exportAddressBookEncryptionKeys(), let addressBookKey = encryptionKeys.getCached(account: account) else { throw AddressBookClient.AddressBookClientError.missingEncryptionKey } var offset = 0 // Deserialize `encryption version` let encryptionVersionBytes = try AddressBookClient.subdata(of: encryptedData, in: offset..<(offset + Constants.int64Size)) offset += Constants.int64Size guard let encryptionVersion = AddressBookClient.bytesToInt(Array(encryptionVersionBytes)) else { return .empty } if encryptionVersion == AddressBookEncryptionKeys.Constants.version { let encryptedSubData = try AddressBookClient.subdata(of: encryptedData, in: offset.. AddressBookContacts { var offset = 0 // Deserialize `version` let versionBytes = try AddressBookClient.subdata(of: plainData, in: offset..<(offset + Constants.int64Size)) offset += Constants.int64Size // Deserialize and check `address book version` guard let version = AddressBookClient.bytesToInt(Array(versionBytes)), version == AddressBookContacts.Constants.version else { return .empty } // Deserialize `lastUpdated` guard let lastUpdated = try AddressBookClient.deserializeDate(from: plainData, at: &offset) else { return .empty } // Deserialize `contactsCount` let contactsCountBytes = try AddressBookClient.subdata(of: plainData, in: offset..<(offset + Constants.int64Size)) offset += Constants.int64Size guard let contactsCount = AddressBookClient.bytesToInt(Array(contactsCountBytes)) else { return .empty } var contacts: [Contact] = [] for _ in 0.. Data { var data = Data() // Serialize `lastUpdated` data.append(contentsOf: AddressBookClient.serializeDate(contact.lastUpdated)) // Serialize `address` (length + UTF-8 bytes) let addressBytes = stringToBytes(contact.id) data.append(contentsOf: intToBytes(addressBytes.count)) data.append(contentsOf: addressBytes) // Serialize `name` (length + UTF-8 bytes) let nameBytes = stringToBytes(contact.name) data.append(contentsOf: intToBytes(nameBytes.count)) data.append(contentsOf: nameBytes) return data } private static func deserializeContact(from data: Data, at offset: inout Int) throws -> Contact? { // Deserialize `lastUpdated` guard let lastUpdated = try AddressBookClient.deserializeDate(from: data, at: &offset) else { return nil } // Deserialize `address` guard let address = try readString(from: data, at: &offset) else { return nil } // Deserialize `name` guard let name = try readString(from: data, at: &offset) else { return nil } return Contact(address: address, name: name, lastUpdated: lastUpdated) } private static func stringToBytes(_ string: String) -> [UInt8] { return Array(string.utf8) } private static func bytesToString(_ bytes: [UInt8]) -> String? { return String(bytes: bytes, encoding: .utf8) } private static func intToBytes(_ value: Int) -> [UInt8] { withUnsafeBytes(of: value.bigEndian, Array.init) } private static func bytesToInt(_ bytes: [UInt8]) -> Int? { guard bytes.count == Constants.int64Size else { return nil } return bytes.withUnsafeBytes { ptr -> Int? in Int.init(exactly: ptr.loadUnaligned(as: Int64.self).bigEndian) } } private static func serializeDate(_ date: Date) -> [UInt8] { // Convert Date to Unix time (number of seconds since 1970) let timestamp = Int(date.timeIntervalSince1970) // Convert the timestamp to bytes return AddressBookClient.intToBytes(timestamp) } private static func deserializeDate(from data: Data, at offset: inout Int) throws -> Date? { // Extract the bytes for the timestamp (assume it's stored as an Int) let timestampBytes = try AddressBookClient.subdata(of: data, in: offset..<(offset + Constants.int64Size)) offset += Constants.int64Size // Convert the bytes back to an Int guard let timestamp = AddressBookClient.bytesToInt(Array(timestampBytes)) else { return nil } // Convert the timestamp back to a Date return Date(timeIntervalSince1970: TimeInterval(timestamp)) } // Helper function to read a string from the data using a length prefix private static func readString(from data: Data, at offset: inout Int) throws -> String? { // Read the length first (assumes the length is stored as an Int) let lengthBytes = try AddressBookClient.subdata(of: data, in: offset..<(offset + Constants.int64Size)) offset += Constants.int64Size guard let length = AddressBookClient.bytesToInt(Array(lengthBytes)), length > 0 else { return nil } // Read the string bytes let stringBytes = try AddressBookClient.subdata(of: data, in: offset..<(offset + length)) offset += length return AddressBookClient.bytesToString(Array(stringBytes)) } private static func subdata(of data: Data, in range: Range) throws -> Data { guard data.count >= range.upperBound else { throw AddressBookClient.AddressBookClientError.subdataRange } return data.subdata(in: range) } }