mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 20:39:24 +00:00
7c812201cb
* Read and import the secrets from ClassicAppAccounts. * Record snapshots. * Add some documentation, tidy up tests and fix the dismissal of the backup instructions. * Workaround flakey tests (the fulfilments weren't always firing). * Allow a custom Classic App deep link URL to be configured.
115 lines
5.5 KiB
Swift
115 lines
5.5 KiB
Swift
//
|
|
// Copyright 2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Foundation
|
|
import KeychainAccess
|
|
import MatrixRustSDK
|
|
|
|
// sourcery: AutoMockable
|
|
protocol ClassicAppManagerProtocol {
|
|
/// Loads all of the accounts found in the Classic app's file store.
|
|
func loadAccounts() throws -> [ClassicAppAccount]
|
|
/// Determines which secrets will be available when loading the secrets bundle for a given account.
|
|
func availableSecrets(for account: ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets
|
|
/// Loads the secrets bundle for a given account.
|
|
func secretsBundle(for account: ClassicAppAccount) async throws -> SecretsBundleWithUserId
|
|
}
|
|
|
|
enum ClassicAppManagerError: Error {
|
|
case invalidAppGroupIdentifier(String)
|
|
case missingAccountKeys
|
|
case missingCryptoStorePassphrase
|
|
case missingKeyBackupVersion
|
|
}
|
|
|
|
/// Reads accounts from Element Classic's shared storage.
|
|
final class ClassicAppManager: ClassicAppManagerProtocol {
|
|
private enum KeychainKeys: String {
|
|
case cryptoSDKStoreKey
|
|
case accountIV = "accountIv"
|
|
case accountAESKey = "accountAesKey"
|
|
}
|
|
|
|
private let classicAppGroupIdentifier: String
|
|
private let keychain: Keychain
|
|
|
|
/// Creates an instance using the Classic app identifiers specified in the `Info.plist` file.
|
|
/// Returns `nil` when a Classic app has not been configured in the project.
|
|
init?(classicAppGroupIdentifier: String? = InfoPlistReader.main.classicAppGroupIdentifier,
|
|
classicAppKeychainServiceIdentifier: String? = InfoPlistReader.main.classicAppKeychainServiceIdentifier,
|
|
classicAppKeychainAccessGroupIdentifier: String? = InfoPlistReader.main.classicAppKeychainAccessGroupIdentifier) {
|
|
guard let classicAppGroupIdentifier, let classicAppKeychainServiceIdentifier, let classicAppKeychainAccessGroupIdentifier else {
|
|
MXLog.info("Classic App IDs not available, skipping initialisation.")
|
|
return nil
|
|
}
|
|
|
|
self.classicAppGroupIdentifier = classicAppGroupIdentifier
|
|
keychain = Keychain(service: classicAppKeychainServiceIdentifier, accessGroup: classicAppKeychainAccessGroupIdentifier)
|
|
}
|
|
|
|
func loadAccounts() throws -> [ClassicAppAccount] {
|
|
// The account data is stored in the App Group container.
|
|
guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: classicAppGroupIdentifier) else {
|
|
throw ClassicAppManagerError.invalidAppGroupIdentifier(classicAppGroupIdentifier)
|
|
}
|
|
|
|
// And the data is encrypted with keys that are stored in the Keychain.
|
|
guard let accountIV = try keychain.getData(KeychainKeys.accountIV.rawValue),
|
|
let accountAESKey = try keychain.getData(KeychainKeys.accountAESKey.rawValue) else {
|
|
throw ClassicAppManagerError.missingAccountKeys
|
|
}
|
|
|
|
guard let cryptoStorePassphrase = try keychain.getData(KeychainKeys.cryptoSDKStoreKey.rawValue) else {
|
|
throw ClassicAppManagerError.missingCryptoStorePassphrase
|
|
}
|
|
|
|
let accountManager = ClassicAppAccountManager(cacheFolder: url,
|
|
aesKey: accountAESKey,
|
|
iv: accountIV,
|
|
cryptoStorePassphrase: cryptoStorePassphrase)
|
|
accountManager.loadAccounts()
|
|
return accountManager.accounts
|
|
}
|
|
|
|
func availableSecrets(for account: ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets {
|
|
switch try await databaseContainsSecretsBundle(databasePath: account.cryptoStoreURL.path(percentEncoded: false),
|
|
passphrase: account.cryptoStorePassphrase,
|
|
backupInfo: keyBackupVersion(for: account)) {
|
|
case .complete: .complete
|
|
case .none: .unavailable
|
|
case .withoutBackup, .unusedBackup: .requiresBackup
|
|
}
|
|
}
|
|
|
|
func secretsBundle(for account: ClassicAppAccount) async throws -> SecretsBundleWithUserId {
|
|
guard let keyBackupVersion = try await keyBackupVersion(for: account) else {
|
|
throw ClassicAppManagerError.missingKeyBackupVersion
|
|
}
|
|
|
|
return try await SecretsBundleWithUserId.fromDatabase(databasePath: account.cryptoStoreURL.path(percentEncoded: false),
|
|
passphrase: account.cryptoStorePassphrase,
|
|
backupInfo: keyBackupVersion)
|
|
}
|
|
|
|
/// Fetches the current key backup version from the homeserver. This is needed to determine whether
|
|
/// the backup key from the crypto store is for the backup currently being used by the account.
|
|
private func keyBackupVersion(for account: ClassicAppAccount) async throws -> String? {
|
|
let url = account.homeserverURL.appending(path: "_matrix/client/v3/room_keys/version")
|
|
var request = URLRequest(url: url)
|
|
request.setValue("Bearer \(account.accessToken)", forHTTPHeaderField: "Authorization")
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse,
|
|
httpResponse.statusCode != 200 {
|
|
return nil
|
|
}
|
|
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
}
|