Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#2438 improve handling of invalid keys on message compose #2458

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .semaphore/semaphore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion FlowCrypt/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ extension AppDelegate: BlursTopView {
removeBlurView()
}
if let topViewController = UIApplication.topViewController() {
ekmVcHelper?.refreshKeysFromEKMIfNeeded(in: topViewController)
Task {
await ekmVcHelper?.refreshKeysFromEKMIfNeeded(in: topViewController)
}
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions FlowCrypt/Controllers/Compose/ComposeViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ final class ComposeViewController: TableNodeViewController {

var selectedRecipientType: RecipientType? = .to
var shouldShowAllRecipientTypes = false
var isKeyUpdatedFromEKM = false
var popoverVC: ComposeRecipientPopupViewController!

var sectionsList: [Section] = []
Expand All @@ -95,6 +96,7 @@ final class ComposeViewController: TableNodeViewController {
var sendAsList: [SendAsModel] = []

let handleAction: ((ComposeMessageAction) -> Void)?
let ekmVcHelper: EKMVcHelper

init(
appContext: AppContextWithUser,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
96 changes: 47 additions & 49 deletions FlowCrypt/Functionality/Services/EKMVcHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
)
}
}

Expand Down
1 change: 1 addition & 0 deletions appium/tests/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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('[email protected]');
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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down