Skip to content

Commit

Permalink
Merge pull request #1383 from Electric-Coin-Company/address-book-encr…
Browse files Browse the repository at this point in the history
…yption

Address book encryption
  • Loading branch information
LukasKorba authored Nov 17, 2024
2 parents 8321708 + 7bcce7e commit b3ab535
Show file tree
Hide file tree
Showing 22 changed files with 729 additions and 385 deletions.
70 changes: 35 additions & 35 deletions modules/Package.swift

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
//
// AddressBookEncryption.swift
// Zashi
//
// Created by Lukáš Korba on 11-11-2024.
//

import ComposableArchitecture

import Models
import Foundation
import CryptoKit
import WalletStorage

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(_ abContacts: AddressBookContacts) throws -> Data {
@Dependency(\.walletStorage) var walletStorage

guard let encryptionKeys = try? walletStorage.exportAddressBookEncryptionKeys(), let addressBookKey = encryptionKeys.getCached(account: 0) else {
throw AddressBookClient.AddressBookClientError.missingEncryptionKey
}

var encryptionVersionData = Data()
encryptionVersionData.append(contentsOf: intToBytes(AddressBookEncryptionKeys.Constants.version))

let dataForEncryption = AddressBookClient.serializeContacts(abContacts)

// 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) throws -> AddressBookContacts {
@Dependency(\.walletStorage) var walletStorage

guard let encryptionKeys = try? walletStorage.exportAddressBookEncryptionKeys(), let addressBookKey = encryptionKeys.getCached(account: 0) 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..<encryptedData.count)

// Derive the sub-key for decrypting the address book.
let salt = encryptedSubData.prefix(upTo: 32)
let subKey = addressBookKey.deriveEncryptionKey(salt: salt)

// Unseal the encrypted address book.
let sealed = try ChaChaPoly.SealedBox.init(combined: encryptedSubData.suffix(from: 32))
let data = try ChaChaPoly.open(sealed, using: subKey)

return try contactsFrom(plainData: data)
} else {
throw AddressBookClientError.encryptionVersionNotSupported
}
}

/// Tries to decode the unencrypted data
/// [Unencrypted data] `address book version`
/// [Unencrypted data] `timestamp`
/// [Unencrypted data] `contacts`
static func contactsFrom(plainData: Data) throws -> 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..<contactsCount {
if let contact = try AddressBookClient.deserializeContact(from: plainData, at: &offset) {
contacts.append(contact)
}
}

let abContacts = AddressBookContacts(
lastUpdated: lastUpdated,
version: version,
contacts: IdentifiedArrayOf(uniqueElements: contacts)
)

return abContacts
}
}

// MARK: - Helper methods for the data handling

extension AddressBookClient {
private static func serializeContact(_ contact: Contact) -> 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<Data.Index>) throws -> Data {
guard data.count >= range.upperBound else {
throw AddressBookClient.AddressBookClientError.subdataRange
}

return data.subdata(in: range)
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ extension DependencyValues {

@DependencyClient
public struct AddressBookClient {
public let allLocalContacts: () throws -> AddressBookContacts
public let syncContacts: (AddressBookContacts?) async throws -> AddressBookContacts
public let storeContact: (Contact) throws -> AddressBookContacts
public let deleteContact: (Contact) throws -> AddressBookContacts
public let allLocalContacts: () throws -> (contacts: AddressBookContacts, remoteStoreResult: RemoteStoreResult)
public let syncContacts: (AddressBookContacts?) async throws -> (contacts: AddressBookContacts, remoteStoreResult: RemoteStoreResult)
public let storeContact: (Contact) throws -> (contacts: AddressBookContacts, remoteStoreResult: RemoteStoreResult)
public let deleteContact: (Contact) throws -> (contacts: AddressBookContacts, remoteStoreResult: RemoteStoreResult)
}
Loading

0 comments on commit b3ab535

Please sign in to comment.