Skip to content

Commit

Permalink
Implement AddressBook encryption
Browse files Browse the repository at this point in the history
  • Loading branch information
str4d committed Nov 2, 2024
1 parent 7f4cbf6 commit 532d31a
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import RemoteStorage
import Combine

import WalletStorage
import CryptoKit

extension AddressBookClient: DependencyKey {
private enum Constants {
Expand Down Expand Up @@ -157,14 +158,13 @@ extension AddressBookClient: DependencyKey {
}
)
}

private static func encryptContacts(_ abContacts: AddressBookContacts) throws -> Data {
@Dependency(\.walletStorage) var walletStorage

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

// here you have an array of all contacts
// you also have a key from the keychain
Expand All @@ -185,20 +185,36 @@ extension AddressBookClient: DependencyKey {
data.append(serializedContact)
}

return data
// 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(data, using: subKey)

// Prepend the salt to the SealedBox so we can re-derive the sub-key.
return salt + sealed.combined
}
}

private static func decryptData(_ data: Data) throws -> AddressBookContacts {
private static func decryptData(_ encrypted: Data) throws -> AddressBookContacts {
@Dependency(\.walletStorage) var walletStorage

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

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

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

// here you have the encrypted data from the cloud, the blob
// you also have a key from the keychain

var offset = 0

// Deserialize `version`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,30 @@ public struct DerivationToolClient {

/// Checks if given address is a valid zcash address.
public var isZcashAddress: (String, NetworkType) -> Bool = { _, _ in false }

/// Derives and returns a ZIP 32 Arbitrary Key from the given seed at the "wallet level", i.e.
/// directly from the seed with no ZIP 32 path applied.
///
/// The resulting key will be the same across all networks (Zcash mainnet, Zcash testnet,
/// OtherCoin mainnet, and so on). You can think of it as a context-specific seed fingerprint
/// that can be used as (static) key material.
///
/// - Parameter contextString: a globally-unique non-empty sequence of at most 252 bytes that identifies the desired context.
/// - Parameter seed: `[Uint8]` seed bytes
/// - Throws:
/// - `derivationToolInvalidAccount` if the `accountIndex` is invalid.
/// - some `ZcashError.rust*` error if the derivation fails.
/// - Returns a `[Uint8]`
public var deriveArbitraryWalletKey: ([UInt8], [UInt8]) throws -> [UInt8]

/// Derives and returns a ZIP 32 Arbitrary Key from the given seed at the account level.
///
/// - Parameter contextString: a globally-unique non-empty sequence of at most 252 bytes that identifies the desired context.
/// - Parameter seed: `[Uint8]` seed bytes
/// - Parameter accountNumber: `Int` with the account number
/// - Throws:
/// - `derivationToolInvalidAccount` if the `accountIndex` is invalid.
/// - some `ZcashError.rust*` error if the derivation fails.
/// - Returns a `[Uint8]`
public var deriveArbitraryAccountKey: ([UInt8], [UInt8], Int, NetworkType) throws -> [UInt8]
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ extension DerivationToolClient: DependencyKey {
} catch {
return false
}
},
deriveArbitraryWalletKey: { contextString, seed in
try DerivationTool.deriveArbitraryWalletKey(
contextString: contextString,
seed: seed
)
},
deriveArbitraryAccountKey: { contextString, seed, accountIndex, networkType in
try DerivationTool(networkType: networkType).deriveArbitraryAccountKey(
contextString: contextString,
seed: seed,
accountIndex: accountIndex
)
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ extension DerivationToolClient: TestDependencyKey {
isSaplingAddress: unimplemented("\(Self.self).isSaplingAddress", placeholder: false),
isTransparentAddress: unimplemented("\(Self.self).isTransparentAddress", placeholder: false),
isTexAddress: unimplemented("\(Self.self).isTexAddress", placeholder: false),
isZcashAddress: unimplemented("\(Self.self).isZcashAddress", placeholder: false)
isZcashAddress: unimplemented("\(Self.self).isZcashAddress", placeholder: false),
deriveArbitraryWalletKey: unimplemented("\(Self.self).deriveArbitraryWalletKey"),
deriveArbitraryAccountKey: unimplemented("\(Self.self).deriveArbitraryAccountKey")
)
}

Expand All @@ -32,6 +34,8 @@ extension DerivationToolClient {
isSaplingAddress: { _, _ in return false },
isTransparentAddress: { _, _ in return false },
isTexAddress: { _, _ in return false },
isZcashAddress: { _, _ in return false }
isZcashAddress: { _, _ in return false },
deriveArbitraryWalletKey: { _, _ in throw "NotImplemented" },
deriveArbitraryAccountKey: { _, _, _, _ in throw "NotImplemented" }
)
}
18 changes: 10 additions & 8 deletions modules/Sources/Features/Root/RootInitialization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -296,14 +296,16 @@ extension Root {

let addressBookEncryptionKeys = try? walletStorage.exportAddressBookEncryptionKeys()
if addressBookEncryptionKeys == nil {
// TODO: str4d
// here you know the encryption key for the address book is missing, we need to generate one

// here you have `storedWallet.seedPhrase.seedPhrase`, a seed as String

// once the key is prepared, store it
// let keys == AddressBookEncryptionKeys(key: "")
// try walletStorage.importAddressBookEncryptionKeys(keys)
var keys = AddressBookEncryptionKeys.empty
keys.cacheFor(
seed: seedBytes,
account: 0,
network: zcashSDKEnvironment.network
)
// TODO: Why are the address book keys stored in wallet storage?
// Doesn't that mean they require the same authentication as the
// seed phrase, instead of being accessible without biometric auth?
try walletStorage.importAddressBookEncryptionKeys(keys)
}

return .run { send in
Expand Down
29 changes: 29 additions & 0 deletions modules/Sources/Generated/XCAssets+Generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public enum Asset {
public static let shieldTick = ImageAsset(name: "shieldTick")
public static let shieldedFunds = ImageAsset(name: "shieldedFunds")
public static let surroundedShield = ImageAsset(name: "surroundedShield")
public static let test = DataAsset(name: "test")
public static let tooltip = ImageAsset(name: "tooltip")
public static let zashiTitle = ImageAsset(name: "zashiTitle")
}
Expand Down Expand Up @@ -305,6 +306,34 @@ public extension ColorAsset.SystemColor {
}
}

public struct DataAsset {
public fileprivate(set) var name: String

#if os(iOS) || os(tvOS) || os(macOS)
@available(iOS 9.0, macOS 10.11, *)
public var data: NSDataAsset {
guard let data = NSDataAsset(asset: self) else {
fatalError("Unable to load data asset named \(name).")
}
return data
}
#endif
}

#if os(iOS) || os(tvOS) || os(macOS)
@available(iOS 9.0, macOS 10.11, *)
public extension NSDataAsset {
convenience init?(asset: DataAsset) {
let bundle = BundleToken.bundle
#if os(iOS) || os(tvOS)
self.init(name: asset.name, bundle: bundle)
#elseif os(macOS)
self.init(name: NSDataAsset.Name(asset.name), bundle: bundle)
#endif
}
}
#endif

public struct ImageAsset {
public fileprivate(set) var name: String

Expand Down
63 changes: 59 additions & 4 deletions modules/Sources/Models/AddressBookEncryptionKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,73 @@
//

import Foundation
import CryptoKit
import Utils
import DerivationTool
import ZcashLightClientKit

/// Representation of the address book encryption keys
public struct AddressBookEncryptionKeys: Codable, Equatable {
public let key: String
var keys: [Int: AddressBookKey]

public init(key: String) {
self.key = key
public mutating func cacheFor(seed: [UInt8], account: Int, network: NetworkType) throws{
keys[account] = try AddressBookKey(seed: seed, account: account, network: network)
}

public func getCached(account: Int) -> AddressBookKey? {
keys[account]
}
}

extension AddressBookEncryptionKeys {
public static let empty = Self(
key: ""
keys: [:]
)
}

public struct AddressBookKey: Codable, Equatable, Redactable {
let key: SymmetricKey

public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
key = SymmetricKey(data: try container.decode(Data.self))
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try key.withUnsafeBytes { key in
let key = Data(key)
try container.encode(key)
}
}

/**
* Derives the long-term key that can decrypt the given account's encrypted
* address book.
*
* This requires access to the seed phrase. If the app has separate access
* control requirements for the seed phrase and the address book, this key
* should be cached in the app's keystore.
*/
public init(seed: [UInt8], account: Int, network: NetworkType) throws {
self.key = try SymmetricKey(data: DerivationToolClient.live().deriveArbitraryAccountKey(
[UInt8]("ZashiAddressBookEncryptionV1".utf8),
seed,
account,
network
))
}

/**
* Derives a one-time address book encryption key.
*
* At encryption time, the one-time property MUST be ensured by generating a
* random 32-byte salt.
*/
public func deriveEncryptionKey(
salt: Data
) -> SymmetricKey {
assert(salt.count == 32)
return HKDF<SHA256>.deriveKey(inputKeyMaterial: key, salt: salt, outputByteCount: 32)
}
}

0 comments on commit 532d31a

Please sign in to comment.