diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index 365c7a62c..040122156 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -78,7 +78,7 @@ blocks: commands: - checkout && cd ~/git/flowcrypt-ios/ - mv ~/appium-env ~/git/flowcrypt-ios/appium/.env - - sem-version node 18 && cache restore appium-npm && cd ./appium && npm i && cd .. && cache store appium-npm appium/node_modules + - sem-version node 20 && cache restore appium-npm && cd ./appium && npm i && cd .. && cache store appium-npm appium/node_modules - cd appium - cache restore FlowCrypt-$SEMAPHORE_GIT_SHA.app epilogue: diff --git a/FlowCrypt/App/AppDelegate.swift b/FlowCrypt/App/AppDelegate.swift index 11800e4da..0ddccfcd8 100644 --- a/FlowCrypt/App/AppDelegate.swift +++ b/FlowCrypt/App/AppDelegate.swift @@ -64,7 +64,9 @@ extension AppDelegate: BlursTopView { removeBlurView() } if let topViewController = UIApplication.topViewController() { - ekmVcHelper?.refreshKeysFromEKMIfNeeded(in: topViewController) + Task { + await ekmVcHelper?.refreshKeysFromEKMIfNeeded(in: topViewController) + } } } } diff --git a/FlowCrypt/Controllers/Compose/ComposeViewController.swift b/FlowCrypt/Controllers/Compose/ComposeViewController.swift index 8cd9c0836..c973d00fb 100644 --- a/FlowCrypt/Controllers/Compose/ComposeViewController.swift +++ b/FlowCrypt/Controllers/Compose/ComposeViewController.swift @@ -87,6 +87,7 @@ final class ComposeViewController: TableNodeViewController { var selectedRecipientType: RecipientType? = .to var shouldShowAllRecipientTypes = false + var isKeyUpdatedFromEKM = false var popoverVC: ComposeRecipientPopupViewController! var sectionsList: [Section] = [] @@ -95,6 +96,7 @@ final class ComposeViewController: TableNodeViewController { var sendAsList: [SendAsModel] = [] let handleAction: ((ComposeMessageAction) -> Void)? + let ekmVcHelper: EKMVcHelper init( appContext: AppContextWithUser, @@ -141,6 +143,7 @@ final class ComposeViewController: TableNodeViewController { ) self.router = appContext.globalRouter self.clientConfiguration = clientConfiguration + self.ekmVcHelper = EKMVcHelper(appContext: appContext) let mailProvider = try appContext.getRequiredMailProvider() self.messageHelper = try messageHelper ?? MessageHelper( diff --git a/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+ErrorHandling.swift b/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+ErrorHandling.swift index c940beb01..b0dbc272f 100644 --- a/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+ErrorHandling.swift +++ b/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+ErrorHandling.swift @@ -71,6 +71,14 @@ extension ComposeViewController { MessageValidationError.expiredKeyRecipients, MessageValidationError.notUsableForEncryptionKeyRecipients: processSendMessageWithNoValidKeys(error: error) + case MessageValidationError.noUsableAccountKeys, + KeypairError.noAccountKeysAvailable: + if isKeyUpdatedFromEKM { + showAlert(message: "compose_error".localized + "\n\n" + error.errorMessage) + isKeyUpdatedFromEKM = false // Set to false so that it will be fetched next time + } else { + refreshEKMAndProceed(error: error) + } case MessageValidationError.notUniquePassword, MessageValidationError.subjectContainsPassword, MessageValidationError.weakPassword: @@ -80,6 +88,14 @@ extension ComposeViewController { } } + private func refreshEKMAndProceed(error: Error) { + Task { + isKeyUpdatedFromEKM = true + await ekmVcHelper.refreshKeysFromEKMIfNeeded(in: self, forceRefresh: true) + handleSendTap() + } + } + private func processSendMessageWithNoValidKeys(error: Error) { let alert = UIAlertController( title: "compose_message_encryption".localized, diff --git a/FlowCrypt/Controllers/Inbox/Container/InboxViewContainerController.swift b/FlowCrypt/Controllers/Inbox/Container/InboxViewContainerController.swift index 7013e9f96..ce9069622 100644 --- a/FlowCrypt/Controllers/Inbox/Container/InboxViewContainerController.swift +++ b/FlowCrypt/Controllers/Inbox/Container/InboxViewContainerController.swift @@ -126,7 +126,9 @@ final class InboxViewContainerController: TableNodeViewController { viewModel: input ) navigationController?.setViewControllers([inboxViewController], animated: false) - ekmVcHelper.refreshKeysFromEKMIfNeeded(in: inboxViewController, forceRefresh: true) + Task { + await ekmVcHelper.refreshKeysFromEKMIfNeeded(in: inboxViewController, forceRefresh: true) + } } catch { showAlert(message: error.errorMessage) } diff --git a/FlowCrypt/Functionality/Services/EKMVcHelper.swift b/FlowCrypt/Functionality/Services/EKMVcHelper.swift index 6e9c16df2..f99681d08 100644 --- a/FlowCrypt/Functionality/Services/EKMVcHelper.swift +++ b/FlowCrypt/Functionality/Services/EKMVcHelper.swift @@ -9,7 +9,7 @@ import UIKit protocol EKMVcHelperType { - func refreshKeysFromEKMIfNeeded(in viewController: UIViewController, forceRefresh: Bool) + func refreshKeysFromEKMIfNeeded(in viewController: UIViewController, forceRefresh: Bool) async } final class EKMVcHelper: EKMVcHelperType { @@ -26,59 +26,57 @@ final class EKMVcHelper: EKMVcHelperType { self.alertsFactory = AlertsFactory(encryptedStorage: appContext.encryptedStorage) } - func refreshKeysFromEKMIfNeeded(in viewController: UIViewController, forceRefresh: Bool = false) { - Task { - do { - let lastUpdateTime = UserDefaults.standard.object(forKey: LAST_EKM_UPDATE_TIME_KEY) as? Date + func refreshKeysFromEKMIfNeeded(in viewController: UIViewController, forceRefresh: Bool = false) async { + do { + let lastUpdateTime = UserDefaults.standard.object(forKey: LAST_EKM_UPDATE_TIME_KEY) as? Date - // Only proceed if last update time is more than 8 hours ago or not set - // Or force refresh when forceRefresh is set - guard lastUpdateTime == nil || lastUpdateTime!.addingTimeInterval(8 * 60 * 60) < Date() || forceRefresh else { - return - } - - let configuration = try await appContext.clientConfigurationProvider.configuration - guard try configuration.checkUsesEKM() == .usesEKM else { - return - } - let passPhraseStorageMethod: PassPhraseStorageMethod = configuration.forbidStoringPassPhrase ? .memory : .persistent - let emailKeyManagerApi = EmailKeyManagerApi(clientConfiguration: configuration) - let idToken = try await IdTokenUtils.getIdToken(userEmail: appContext.user.email) - let fetchedKeys = try await emailKeyManagerApi.getPrivateKeys(idToken: idToken) - let localKeys = try appContext.encryptedStorage.getKeypairs(by: appContext.user.email) - - try removeLocalKeysIfNeeded(from: fetchedKeys, localKeys: localKeys) - - let keysToUpdate = try findKeysToUpdate(from: fetchedKeys, localKeys: localKeys) - // Set last update time - UserDefaults.standard.set(Date(), forKey: LAST_EKM_UPDATE_TIME_KEY) - guard keysToUpdate.isNotEmpty else { - return - } - guard let passPhrase = try await getPassphrase(in: viewController), passPhrase.isNotEmpty else { - return - } + // Only proceed if last update time is more than 8 hours ago or not set + // Or force refresh when forceRefresh is set + guard lastUpdateTime == nil || lastUpdateTime!.addingTimeInterval(8 * 60 * 60) < Date() || forceRefresh else { + return + } - for keyDetail in keysToUpdate { - try await saveKeyToLocal( - context: appContext, - keyDetail: keyDetail, - passPhrase: passPhrase, - passPhraseStorageMethod: passPhraseStorageMethod - ) - } + let configuration = try await appContext.clientConfigurationProvider.configuration + guard try configuration.checkUsesEKM() == .usesEKM else { + return + } + let passPhraseStorageMethod: PassPhraseStorageMethod = configuration.forbidStoringPassPhrase ? .memory : .persistent + let emailKeyManagerApi = EmailKeyManagerApi(clientConfiguration: configuration) + let idToken = try await IdTokenUtils.getIdToken(userEmail: appContext.user.email) + let fetchedKeys = try await emailKeyManagerApi.getPrivateKeys(idToken: idToken) + let localKeys = try appContext.encryptedStorage.getKeypairs(by: appContext.user.email) + + try removeLocalKeysIfNeeded(from: fetchedKeys, localKeys: localKeys) + + let keysToUpdate = try findKeysToUpdate(from: fetchedKeys, localKeys: localKeys) + // Set last update time + UserDefaults.standard.set(Date(), forKey: LAST_EKM_UPDATE_TIME_KEY) + guard keysToUpdate.isNotEmpty else { + return + } + guard let passPhrase = try await getPassphrase(in: viewController), passPhrase.isNotEmpty else { + return + } - await viewController.showToast("refresh_key_success".localized) - } catch { - // since this is an update function that happens on every startup - // it's ok if it's skipped sometimes - keys will be updated next time - if error is ApiError { - return - } - await viewController.showAlert( - message: "refresh_key_error".localizeWithArguments(error.errorMessage) + for keyDetail in keysToUpdate { + try await saveKeyToLocal( + context: appContext, + keyDetail: keyDetail, + passPhrase: passPhrase, + passPhraseStorageMethod: passPhraseStorageMethod ) } + + await viewController.showToast("refresh_key_success".localized) + } catch { + // since this is an update function that happens on every startup + // it's ok if it's skipped sometimes - keys will be updated next time + if error is ApiError { + return + } + await viewController.showAlert( + message: "refresh_key_error".localizeWithArguments(error.errorMessage) + ) } } diff --git a/appium/tests/data/index.ts b/appium/tests/data/index.ts index 739afdae5..c68c4b57c 100644 --- a/appium/tests/data/index.ts +++ b/appium/tests/data/index.ts @@ -228,6 +228,7 @@ export const CommonData = { wrongPassPhrase: 'Error\n' + 'Could not compose message\n' + '\n' + 'This pass phrase did not match your signing private key.', notUsableEncryptionPublicKey: `Message Encryption\nOne or more of your recipients have sign-only public keys (marked in yellow).\n\nPlease ask them to send you updated public key. If this is an enterprise installation, please ask your systems admin.`, + noPrivateKey: 'Error\n' + 'Could not compose message\n\n' + 'Your account keys are not usable for encryption.', expiredPublicKey: `Message Encryption\nOne or more of your recipients have expired public keys (marked in orange).\n\nPlease ask them to send you updated public key. If this is an enterprise installation, please ask your systems admin.`, revokedPublicKey: `Message Encryption\nOne or more of your recipients have revoked public keys (marked in red).\n\nPlease ask them to send you a new public key. If this is an enterprise installation, please ask your systems admin.`, wrongPassPhraseOnLogin: 'Error\n' + 'Wrong pass phrase, please try again', diff --git a/appium/tests/specs/mock/composeEmail/CheckEKMRefreshAfterComposeError.spec.ts b/appium/tests/specs/mock/composeEmail/CheckEKMRefreshAfterComposeError.spec.ts new file mode 100644 index 000000000..f21a72a89 --- /dev/null +++ b/appium/tests/specs/mock/composeEmail/CheckEKMRefreshAfterComposeError.spec.ts @@ -0,0 +1,49 @@ +import { MockApi } from 'api-mocks/mock'; +import { MockApiConfig } from 'api-mocks/mock-config'; +import { MockUserList } from 'api-mocks/mock-data'; +import { ekmKeySamples } from 'api-mocks/apis/ekm/ekm-endpoints'; +import { MailFolderScreen, NewMessageScreen, SetupKeyScreen, SplashScreen } from '../../../screenobjects/all-screens'; +import { CommonData } from 'tests/data'; +import BaseScreen from 'tests/screenobjects/base.screen'; + +describe('COMPOSE EMAIL: ', () => { + it('check handling of invalid keys on message compose', async () => { + const mockApi = new MockApi(); + + const recipient = MockUserList.robot; + const subject = 'check revoked key after from ekm'; + const message = 'check revoked key after from ekm'; + const noPrivateKeyError = CommonData.errors.noPrivateKey; + + mockApi.fesConfig = MockApiConfig.defaultEnterpriseFesConfiguration; + mockApi.ekmConfig = { + returnKeys: [ekmKeySamples.e2eRevokedKey.prv], + }; + mockApi.addGoogleAccount('e2e.enterprise.test@flowcrypt.com'); + mockApi.attesterConfig = { + servedPubkeys: { + [recipient.email]: recipient.pub!, + }, + }; + + await mockApi.withMockedApis(async () => { + await SplashScreen.mockLogin(); + await SetupKeyScreen.setPassPhrase(); + await MailFolderScreen.checkInboxScreen(); + await MailFolderScreen.clickCreateEmail(); + + // Stage1: Try to compose message with revoked encryption key + await NewMessageScreen.composeEmail(recipient.email, subject, message); + await NewMessageScreen.clickSendButton(); + await BaseScreen.checkModalMessage(noPrivateKeyError); + await BaseScreen.clickOkButtonOnError(); + + // Now update ekm to return valid key and check if message is sent correctly + mockApi.ekmConfig = { + returnKeys: [ekmKeySamples.e2e.prv], + }; + await NewMessageScreen.clickSendButton(); + await MailFolderScreen.checkInboxScreen(); + }); + }); +}); diff --git a/appium/tests/specs/mock/setup/SetupOnlyRevokedKeyFromEKM.spec.ts b/appium/tests/specs/mock/setup/SetupOnlyRevokedKeyFromEKM.spec.ts index 465cd8c3b..a3c0e9ace 100644 --- a/appium/tests/specs/mock/setup/SetupOnlyRevokedKeyFromEKM.spec.ts +++ b/appium/tests/specs/mock/setup/SetupOnlyRevokedKeyFromEKM.spec.ts @@ -17,8 +17,7 @@ describe('SETUP: ', () => { const emailText = CommonData.simpleEmail.message; // When private key is revoked key, there are no public keys to pick. So missing sender public key error occurs. - const noPrivateKeyError = - 'Error\n' + 'Could not compose message\n\n' + 'Your account keys are not usable for encryption.'; + const noPrivateKeyError = CommonData.errors.noPrivateKey; mockApi.fesConfig = MockApiConfig.defaultEnterpriseFesConfiguration; mockApi.ekmConfig = {