Verify Element X with an existing Element Classic account. (#5374)

* 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.
This commit is contained in:
Doug
2026-04-13 15:30:09 +01:00
committed by GitHub
parent 001a389f51
commit 7c812201cb
40 changed files with 947 additions and 103 deletions
@@ -6,6 +6,7 @@
//
import Foundation
import Observation
struct ClassicAppAccount: Equatable, CustomStringConvertible {
let userID: String
@@ -24,6 +25,27 @@ struct ClassicAppAccount: Equatable, CustomStringConvertible {
var description: String {
"ClassicAppAccount(userID: \(userID), homeserverURL: \(homeserverURL))"
}
enum AvailableSecrets { case complete, requiresBackup, unavailable }
@Observable
class State: Equatable {
static func == (lhs: State, rhs: State) -> Bool {
lhs.isServerSupported == rhs.isServerSupported && lhs.availableSecrets == rhs.availableSecrets
}
/// Whether or not the account's server is supported by Element X (or `nil` whilst determining support).
///
/// The account will be hidden when this value is `false`.
var isServerSupported: Bool?
/// Information about the secrets available from Element X (or `nil` whilst determining availability).
///
/// See ``AuthenticationService.refreshClassicAppAccountState`` for details about how
/// this property's value affects the authentication flow.
var availableSecrets: AvailableSecrets?
}
let state = State()
}
// MARK: NSCoding Types
@@ -11,13 +11,19 @@ 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.
@@ -45,7 +51,6 @@ final class ClassicAppManager: ClassicAppManagerProtocol {
keychain = Keychain(service: classicAppKeychainServiceIdentifier, accessGroup: classicAppKeychainAccessGroupIdentifier)
}
/// Loads all of the active accounts from the Classic app.
func loadAccounts() throws -> [ClassicAppAccount] {
// The account data is stored in the App Group container.
guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: classicAppGroupIdentifier) else {
@@ -69,4 +74,41 @@ final class ClassicAppManager: ClassicAppManagerProtocol {
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)
}
}