From e6149a8936002daceabcbabb432760100d9a60a3 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 9 Apr 2025 15:49:37 +0200 Subject: [PATCH] Add more scaffolding (#771) --- mobile-app/ios/CredentialManager.swift | 39 +++++-- mobile-app/ios/SharedCredentialStore.swift | 107 +++++++++++------- .../autofill/CredentialIdentityStore.swift | 48 ++++++++ .../CredentialProviderViewController.swift | 99 ++++++++++++++-- 4 files changed, 233 insertions(+), 60 deletions(-) create mode 100644 mobile-app/ios/autofill/CredentialIdentityStore.swift diff --git a/mobile-app/ios/CredentialManager.swift b/mobile-app/ios/CredentialManager.swift index 209489911..96fb0a636 100644 --- a/mobile-app/ios/CredentialManager.swift +++ b/mobile-app/ios/CredentialManager.swift @@ -1,12 +1,21 @@ import Foundation import React +import CryptoKit @objc(CredentialManager) class CredentialManager: NSObject { @objc func addCredential(_ username: String, password: String, service: String) { - let credential = Credential(username: username, password: password, service: service) - SharedCredentialStore.shared.addCredential(credential) + do { + let credential = Credential(username: username, password: password, service: service) + try SharedCredentialStore.shared.addCredential(credential) + } catch let error as CryptoKitError { + print("Encryption error: \(error)") + // Handle encryption errors + } catch { + print("Failed to add credential: \(error)") + // Handle other errors + } } @objc @@ -16,15 +25,25 @@ class CredentialManager: NSObject { @objc func getCredentials() -> [[String: String]] { - let credentials = SharedCredentialStore.shared.getAllCredentials() - let credentialDicts = credentials.map { credential in - return [ - "username": credential.username, - "password": credential.password, - "service": credential.service - ] + do { + let credentials = try SharedCredentialStore.shared.getAllCredentials() + let credentialDicts = credentials.map { credential in + return [ + "username": credential.username, + "password": credential.password, + "service": credential.service + ] + } + return credentialDicts + } catch let error as CryptoKitError { + print("Decryption error: \(error)") + // Handle decryption errors + return [] + } catch { + print("Failed to get credentials: \(error)") + // Handle other errors + return [] } - return credentialDicts } @objc diff --git a/mobile-app/ios/SharedCredentialStore.swift b/mobile-app/ios/SharedCredentialStore.swift index 58380c3eb..5f0025a7f 100644 --- a/mobile-app/ios/SharedCredentialStore.swift +++ b/mobile-app/ios/SharedCredentialStore.swift @@ -16,15 +16,17 @@ public struct Credential: Codable { public class SharedCredentialStore { public static let shared = SharedCredentialStore() + private let userDefaults: UserDefaults private let encryptionKeyKey = "encryptionKey" private let credentialsKey = "storedCredentials" private var cachedEncryptionKey: SymmetricKey? private init() { - + // Use the app group identifier + userDefaults = UserDefaults(suiteName: "group.net.aliasvault.autofill")! } - private func getEncryptionKey() throws -> SymmetricKey { + private func getEncryptionKey(createKeyIfNeeded: Bool = true) throws -> SymmetricKey { print("Getting encryption key") if let cached = cachedEncryptionKey { @@ -41,76 +43,99 @@ public class SharedCredentialStore { print("No cached key, accessing keychain") // Create a new keychain instance with authentication required for this specific access let authKeychain = Keychain(service: "net.aliasvault.autofill", accessGroup: "group.net.aliasvault.autofill") - .accessibility(.whenPasscodeSetThisDeviceOnly, authenticationPolicy: [.biometryAny]) + .accessibility(.whenPasscodeSetThisDeviceOnly, authenticationPolicy: [.biometryAny]) + + if let keyData = try? authKeychain .authenticationPrompt("Authenticate to unlock your vault") - - // Try to get existing key from keychain - if let keyData = try? authKeychain.getData(encryptionKeyKey) { + .getData(encryptionKeyKey) { + print("Found existing key in keychain") let key = SymmetricKey(data: keyData) cachedEncryptionKey = key + print("Returning existing key") return key } - print("Creating new encryption key") - // Create new key if none exists - let key = SymmetricKey(size: .bits256) - try authKeychain.set(key.withUnsafeBytes { Data($0) }, key: encryptionKeyKey) - cachedEncryptionKey = key - return key + if createKeyIfNeeded { + print("Creating new encryption key") + // Create new key if none exists + let key = SymmetricKey(size: .bits256) + do { + try authKeychain + .authenticationPrompt("Authenticate to unlock your vault") + .set(key.withUnsafeBytes { Data($0) }, key: encryptionKeyKey) + print("New key saved in keychain") + } catch { + print("Failed to save key to keychain: \(error)") + } + + cachedEncryptionKey = key + return key + } else { + print("No encryption key found in keychain") + throw NSError(domain: "SharedCredentialStore", code: -1, userInfo: [NSLocalizedDescriptionKey: "No encryption key found in keychain"]) + } } - private func encrypt(_ data: Data) throws -> Data { + private func encrypt(_ data: Data, createKeyIfNeeded: Bool = true) throws -> Data { print("Encrypting data") - let key = try getEncryptionKey() + let key = try getEncryptionKey(createKeyIfNeeded: createKeyIfNeeded) + print("Using key with bit length: \(key.bitCount)") let sealedBox = try AES.GCM.seal(data, using: key) - return sealedBox.combined ?? Data() + guard let combined = sealedBox.combined else { + print("Failed to get combined data from sealed box") + throw NSError(domain: "SharedCredentialStore", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to get combined data from sealed box"]) + } + print("Successfully encrypted data, combined length: \(combined.count)") + return combined } - private func decrypt(_ data: Data) throws -> Data { - print("Decrypting data") - let key = try getEncryptionKey() - let sealedBox = try AES.GCM.SealedBox(combined: data) - return try AES.GCM.open(sealedBox, using: key) + private func decrypt(_ data: Data, createKeyIfNeeded: Bool = true) throws -> Data { + print("Decrypting data with length: \(data.count)") + let key = try getEncryptionKey(createKeyIfNeeded: createKeyIfNeeded) + print("Using key with bit length: \(key.bitCount)") + do { + let sealedBox = try AES.GCM.SealedBox(combined: data) + print("Successfully created sealed box") + let decryptedData = try AES.GCM.open(sealedBox, using: key) + print("Successfully decrypted data, length: \(decryptedData.count)") + return decryptedData + } catch let error as CryptoKitError { + print("CryptoKit error during decryption: \(error)") + throw error + } catch { + print("Unexpected error during decryption: \(error)") + throw error + } } - public func getAllCredentials() -> [Credential] { + public func getAllCredentials(createKeyIfNeeded: Bool = true) throws -> [Credential] { print("Getting all credentials") - guard let encryptedData = UserDefaults.standard.data(forKey: credentialsKey) else { + guard let encryptedData = userDefaults.data(forKey: credentialsKey) else { print("No encrypted data found in UserDefaults") return [] } - do { - let decryptedData = try decrypt(encryptedData) - return try JSONDecoder().decode([Credential].self, from: decryptedData) - } catch { - print("Failed to decrypt credentials: \(error)") - return [] - } + let decryptedData = try decrypt(encryptedData, createKeyIfNeeded: createKeyIfNeeded) + return try JSONDecoder().decode([Credential].self, from: decryptedData) } - public func addCredential(_ credential: Credential) { + public func addCredential(_ credential: Credential, createKeyIfNeeded: Bool = true) throws { print("Adding new credential") - var credentials = getAllCredentials() + var credentials = try getAllCredentials(createKeyIfNeeded: createKeyIfNeeded) credentials.append(credential) - do { - let data = try JSONEncoder().encode(credentials) - let encryptedData = try encrypt(data) - UserDefaults.standard.set(encryptedData, forKey: credentialsKey) - } catch { - print("Failed to save credentials: \(error)") - } + let data = try JSONEncoder().encode(credentials) + let encryptedData = try encrypt(data, createKeyIfNeeded: createKeyIfNeeded) + userDefaults.set(encryptedData, forKey: credentialsKey) } public func clearAllCredentials() { print("Clearing all credentials") - UserDefaults.standard.removeObject(forKey: credentialsKey) + userDefaults.removeObject(forKey: credentialsKey) let authKeychain = Keychain(service: "net.aliasvault.autofill", accessGroup: "group.net.aliasvault.autofill") .accessibility(.whenPasscodeSetThisDeviceOnly, authenticationPolicy: [.biometryAny]) - .authenticationPrompt("Authenticate to unlock your vault") - try? authKeychain.removeAll() + try? authKeychain.authenticationPrompt("Authenticate to unlock your vault").removeAll() } public func clearCache() { diff --git a/mobile-app/ios/autofill/CredentialIdentityStore.swift b/mobile-app/ios/autofill/CredentialIdentityStore.swift new file mode 100644 index 000000000..37a78f2bd --- /dev/null +++ b/mobile-app/ios/autofill/CredentialIdentityStore.swift @@ -0,0 +1,48 @@ +import AuthenticationServices + +class CredentialIdentityStore { + static let shared = CredentialIdentityStore() + private let store = ASCredentialIdentityStore.shared + + private init() {} + + func saveCredentialIdentities(_ credentials: [Credential]) async throws { + let identities = credentials.map { credential in + let serviceIdentifier = ASCredentialServiceIdentifier( + identifier: credential.service, + type: .domain + ) + + return ASPasswordCredentialIdentity( + serviceIdentifier: serviceIdentifier, + user: credential.username, + // TODO: Use the actual record identifier when implementing the actual vault + recordIdentifier: UUID().uuidString + ) + } + + try await store.saveCredentialIdentities(identities) + } + + func removeAllCredentialIdentities() async throws { + try await store.removeAllCredentialIdentities() + } + + func removeCredentialIdentities(_ credentials: [Credential]) async throws { + let identities = credentials.map { credential in + let serviceIdentifier = ASCredentialServiceIdentifier( + identifier: credential.service, + type: .domain + ) + + return ASPasswordCredentialIdentity( + serviceIdentifier: serviceIdentifier, + user: credential.username, + // TODO: Use the actual record identifier when implementing the actual vault + recordIdentifier: UUID().uuidString + ) + } + + try await store.removeCredentialIdentities(identities) + } +} \ No newline at end of file diff --git a/mobile-app/ios/autofill/CredentialProviderViewController.swift b/mobile-app/ios/autofill/CredentialProviderViewController.swift index d052ca513..5e3f63883 100644 --- a/mobile-app/ios/autofill/CredentialProviderViewController.swift +++ b/mobile-app/ios/autofill/CredentialProviderViewController.swift @@ -12,16 +12,34 @@ class CredentialProviderViewController: ASCredentialProviderViewController { private var credentials: [Credential] = [] private let tableView = UITableView() private let addButton = UIButton(type: .system) + private let loadButton = UIButton(type: .system) + private let loadingIndicator = UIActivityIndicatorView(style: .large) override func viewDidLoad() { super.viewDidLoad() setupUI() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) loadCredentials() } + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) + } + private func setupUI() { view.backgroundColor = .systemBackground + // Setup Loading Indicator + loadingIndicator.hidesWhenStopped = false + loadingIndicator.color = .systemBlue + view.addSubview(loadingIndicator) + loadingIndicator.translatesAutoresizingMaskIntoConstraints = false + loadingIndicator.startAnimating() + // Setup TableView tableView.delegate = self tableView.dataSource = self @@ -35,12 +53,26 @@ class CredentialProviderViewController: ASCredentialProviderViewController { view.addSubview(addButton) addButton.translatesAutoresizingMaskIntoConstraints = false + // Setup Load Button + loadButton.setTitle("Load Credentials", for: .normal) + loadButton.addTarget(self, action: #selector(loadCredentials), for: .touchUpInside) + view.addSubview(loadButton) + loadButton.translatesAutoresizingMaskIntoConstraints = false + // Setup Constraints NSLayoutConstraint.activate([ + loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: addButton.topAnchor), + tableView.bottomAnchor.constraint(equalTo: loadButton.topAnchor), + + loadButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + loadButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + loadButton.bottomAnchor.constraint(equalTo: addButton.topAnchor, constant: -8), + loadButton.heightAnchor.constraint(equalToConstant: 44), addButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), addButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), @@ -49,9 +81,30 @@ class CredentialProviderViewController: ASCredentialProviderViewController { ]) } - private func loadCredentials() { - credentials = SharedCredentialStore.shared.getAllCredentials() - tableView.reloadData() + @objc private func loadCredentials() { + do { + credentials = try SharedCredentialStore.shared.getAllCredentials(createKeyIfNeeded: false) + // Update credential identities in the system + Task { + try await CredentialIdentityStore.shared.saveCredentialIdentities(credentials) + DispatchQueue.main.async { [weak self] in + self?.loadingIndicator.stopAnimating() + self?.tableView.isHidden = false + self?.tableView.reloadData() + } + } + } catch let error as NSError { + loadingIndicator.stopAnimating() + let errorAlert = UIAlertController( + title: "Error Loading Credentials", + message: error.localizedDescription, + preferredStyle: .alert + ) + errorAlert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in + self?.extensionContext.cancelRequest(withError: error) + }) + present(errorAlert, animated: true) + } } @objc private func addCredentialTapped() { @@ -76,24 +129,52 @@ class CredentialProviderViewController: ASCredentialProviderViewController { !username.isEmpty, !password.isEmpty, !service.isEmpty else { return } let credential = Credential(username: username, password: password, service: service) - SharedCredentialStore.shared.addCredential(credential) + do { + try SharedCredentialStore.shared.addCredential(credential, createKeyIfNeeded: false) + // Update credential identities in the system + Task { + try await CredentialIdentityStore.shared.saveCredentialIdentities([credential]) + } + } catch let error as NSError { + let errorAlert = UIAlertController( + title: "Error Adding Credential", + message: error.localizedDescription, + preferredStyle: .alert + ) + errorAlert.addAction(UIAlertAction(title: "OK", style: .default)) + self?.present(errorAlert, animated: true) + return + } self?.loadCredentials() }) present(alert, animated: true) } - override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { - loadCredentials() + @objc private func cancelTapped() { + extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) } - @IBAction func cancel(_ sender: AnyObject?) { - self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) + override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { + // Loading it from here doesn't play well with the + //loadCredentials() } override func prepareInterfaceForUserChoosingTextToInsert() { loadCredentials() } + + override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) { + loadCredentials() + + // For testing purposes: we just return the first credential. + if let firstCredential = credentials.first { + let passwordCredential = ASPasswordCredential(user: firstCredential.username, password: firstCredential.password) + self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil) + } else { + self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.credentialIdentityNotFound.rawValue)) + } + } } extension CredentialProviderViewController: UITableViewDelegate, UITableViewDataSource {