diff --git a/extension/chrome/settings/modules/add_key.ts b/extension/chrome/settings/modules/add_key.ts index d88ea3f300a..a0e1fcb0fc5 100644 --- a/extension/chrome/settings/modules/add_key.ts +++ b/extension/chrome/settings/modules/add_key.ts @@ -13,13 +13,11 @@ import { Ui } from '../../../js/common/browser/ui.js'; import { View } from '../../../js/common/view.js'; import { Xss } from '../../../js/common/platform/xss.js'; import { initPassphraseToggle } from '../../../js/common/ui/passphrase-ui.js'; -import { PassphraseStore } from '../../../js/common/platform/store/passphrase-store.js'; -import { KeyStore } from '../../../js/common/platform/store/key-store.js'; -import { KeyUtil, UnexpectedKeyTypeError } from '../../../js/common/core/crypto/key.js'; +import { UnexpectedKeyTypeError } from '../../../js/common/core/crypto/key.js'; import { ClientConfiguration } from '../../../js/common/client-configuration.js'; -import { StorageType } from '../../../js/common/platform/store/abstract-store.js'; import { Lang } from '../../../js/common/lang.js'; import { AcctStore } from '../../../js/common/platform/store/acct-store.js'; +import { saveKeysAndPassPhrase, setPassphraseForPrvs } from '../../../js/common/helpers.js'; View.run(class AddKeyView extends View { @@ -100,9 +98,13 @@ View.run(class AddKeyView extends View { try { const checked = await this.keyImportUi.checkPrv(this.acctEmail, String($('.input_private_key').val()), String($('.input_passphrase').val())); if (checked) { - await KeyStore.add(this.acctEmail, checked.encrypted); // resulting new_key checked above - const storageType: StorageType = ($('.input_passphrase_save').prop('checked') && !this.clientConfiguration.forbidStoringPassPhrase()) ? 'local' : 'session'; - await PassphraseStore.set(storageType, this.acctEmail, { longid: KeyUtil.getPrimaryLongid(checked.encrypted) }, checked.passphrase); + await saveKeysAndPassPhrase(this.acctEmail, [checked.encrypted]); // resulting new_key checked above + await setPassphraseForPrvs( + this.clientConfiguration, + this.acctEmail, + [checked.encrypted], + { passphrase: checked.passphrase, passphrase_save: !!$('.input_passphrase_save').prop('checked') } + ); BrowserMsg.send.reload(this.parentTabId, { advanced: true }); } } catch (e) { diff --git a/extension/chrome/settings/setup.ts b/extension/chrome/settings/setup.ts index d9e5d6989a1..f7d4ff0bc5d 100644 --- a/extension/chrome/settings/setup.ts +++ b/extension/chrome/settings/setup.ts @@ -48,7 +48,7 @@ export class SetupView extends View { public readonly acctEmail: string; public readonly parentTabId: string | undefined; - public readonly action: 'add_key' | undefined; + public readonly action: 'add_key' | 'update_from_ekm' | undefined; public readonly idToken: string | undefined; // only needed for initial setup, not for add_key public readonly keyImportUi = new KeyImportUi({ checkEncryption: true }); @@ -77,7 +77,7 @@ export class SetupView extends View { super(); const uncheckedUrlParams = Url.parse(['acctEmail', 'action', 'idToken', 'parentTabId']); this.acctEmail = Assert.urlParamRequire.string(uncheckedUrlParams, 'acctEmail'); - this.action = Assert.urlParamRequire.oneof(uncheckedUrlParams, 'action', ['add_key', undefined]) as 'add_key' | undefined; + this.action = Assert.urlParamRequire.oneof(uncheckedUrlParams, 'action', ['add_key', 'update_from_ekm', undefined]) as 'add_key' | 'update_from_ekm' | undefined; if (this.action === 'add_key') { this.parentTabId = Assert.urlParamRequire.string(uncheckedUrlParams, 'parentTabId'); } else { diff --git a/extension/chrome/settings/setup/setup-key-manager-autogen.ts b/extension/chrome/settings/setup/setup-key-manager-autogen.ts index 16cc72c5857..83bcef0d70b 100644 --- a/extension/chrome/settings/setup/setup-key-manager-autogen.ts +++ b/extension/chrome/settings/setup/setup-key-manager-autogen.ts @@ -68,7 +68,7 @@ export class SetupWithEmailKeyManagerModule { // generate keys on client and store them on key manager await this.autoGenerateKeyAndStoreBothLocallyAndToEkm(setupOptions); } else { - await Ui.modal.error(`Keys for your account were not set up yet - please ask your systems administrator.`); + await Ui.modal.error(Lang.setup.noKeys); window.location.href = Url.create('index.htm', { acctEmail: this.view.acctEmail }); return; } diff --git a/extension/chrome/settings/setup/setup-render.ts b/extension/chrome/settings/setup/setup-render.ts index 541c2fa3d1d..3ce71440dc7 100644 --- a/extension/chrome/settings/setup/setup-render.ts +++ b/extension/chrome/settings/setup/setup-render.ts @@ -32,7 +32,7 @@ export class SetupRenderModule { return await Settings.promptToRetry(e, Lang.setup.failedToLoadEmailAliases, () => this.renderInitial(), Lang.general.contactIfNeedAssistance(this.view.isFesUsed())); } } - if (this.view.storage!.setup_done) { + if (this.view.storage!.setup_done && this.view.action !== 'update_from_ekm') { if (this.view.action !== 'add_key') { await this.renderSetupDone(); } else if (this.view.clientConfiguration.mustAutoImportOrAutogenPrvWithKeyManager()) { @@ -66,8 +66,13 @@ export class SetupRenderModule { $('.private_key_count').text(storedKeys.length); $('.backups_count').text(this.view.fetchedKeyBackupsUniqueLongids.length); } else { // successful and complete setup - this.displayBlock(this.view.action !== 'add_key' ? 'step_4_done' : 'step_4_close'); - $('h1').text(this.view.action !== 'add_key' ? 'You\'re all set!' : 'Recovered all keys!'); + if (this.view.action === 'add_key') { + this.displayBlock('step_4_close'); + $('h1').text('Recovered all keys!'); + } else { + this.displayBlock('step_4_done'); + $('h1').text('You\'re all set!'); + } $('.email').text(this.view.acctEmail); } }; diff --git a/extension/js/common/browser/browser-msg.ts b/extension/js/common/browser/browser-msg.ts index 0df6487ef1e..16fea837803 100644 --- a/extension/js/common/browser/browser-msg.ts +++ b/extension/js/common/browser/browser-msg.ts @@ -84,7 +84,7 @@ export namespace Bm { export type AjaxGmailAttachmentGetChunk = { chunk: Buf }; export type _tab_ = { tabId: string | null | undefined }; export type SaveFetchedPubkeys = boolean; - export type ProcessAndStoreKeysFromEkmLocally = { needPassphrase: boolean, updateCount: number }; + export type ProcessAndStoreKeysFromEkmLocally = { needPassphrase?: boolean, updateCount?: number, noKeysSetup?: boolean }; export type Db = any; // not included in Any below export type Ajax = any; // not included in Any below diff --git a/extension/js/common/helpers.ts b/extension/js/common/helpers.ts index 4d0db23567a..fcc6421de08 100644 --- a/extension/js/common/helpers.ts +++ b/extension/js/common/helpers.ts @@ -11,21 +11,41 @@ import { ContactStore } from './platform/store/contact-store.js'; import { KeyStore } from './platform/store/key-store.js'; import { PassphraseStore } from './platform/store/passphrase-store.js'; import { Bm } from './browser/browser-msg.js'; +import { PgpPwd } from './core/crypto/pgp/pgp-password.js'; export const isFesUsed = async (acctEmail: string) => { const { fesUrl } = await AcctStore.get(acctEmail, ['fesUrl']); return Boolean(fesUrl); }; -export const saveKeysAndPassPhrase = async (acctEmail: string, prvs: Key[], ppOptions?: PassphraseOptions) => { - const clientConfiguration = await ClientConfiguration.newInstance(acctEmail); +export const setPassphraseForPrvs = async (clientConfiguration: ClientConfiguration, acctEmail: string, prvs: Key[], ppOptions: PassphraseOptions) => { + const storageType = (ppOptions.passphrase_save && !clientConfiguration.forbidStoringPassPhrase()) ? 'local' : 'session'; for (const prv of prvs) { - await KeyStore.add(acctEmail, prv); - if (ppOptions !== undefined) { - await PassphraseStore.set((ppOptions.passphrase_save && !clientConfiguration.forbidStoringPassPhrase()) ? 'local' : 'session', - acctEmail, { longid: KeyUtil.getPrimaryLongid(prv) }, ppOptions.passphrase); + await PassphraseStore.set(storageType, acctEmail, { longid: KeyUtil.getPrimaryLongid(prv) }, ppOptions.passphrase); + } +}; + +// note: for `replaceKeys = true` need to make sure that `prvs` don't have duplicate identities, +// they is currently guaranteed by filterKeysToSave() +// todo: perhaps split into two different functions for add or replace as part of #4545? +const addOrReplaceKeysAndPassPhrase = async (acctEmail: string, prvs: Key[], ppOptions?: PassphraseOptions, replaceKeys: boolean = false) => { + if (replaceKeys) { + // track longids to remove related passhprases + const existingKeys = await KeyStore.get(acctEmail); + const deletedKeys = existingKeys.filter(old => !prvs.some(prvIdentity => KeyUtil.identityEquals(prvIdentity, old))); + // set actually replaces the set of keys in storage with the new set + await KeyStore.set(acctEmail, await Promise.all(prvs.map(KeyUtil.keyInfoObj))); + await PassphraseStore.removeMany(acctEmail, deletedKeys); + } else { + for (const prv of prvs) { + await KeyStore.add(acctEmail, prv); } } + if (ppOptions !== undefined) { + // todo: it would be good to check that the passphrase isn't present in the other storage type + // though this situation is not possible with current use cases + await setPassphraseForPrvs(await ClientConfiguration.newInstance(acctEmail), acctEmail, prvs, ppOptions); + } const { sendAs, full_name: name } = await AcctStore.get(acctEmail, ['sendAs', 'full_name']); const myOwnEmailsAddrs: string[] = [acctEmail].concat(Object.keys(sendAs!)); for (const email of myOwnEmailsAddrs) { @@ -39,6 +59,8 @@ export const saveKeysAndPassPhrase = async (acctEmail: string, prvs: Key[], ppOp } }; +export const saveKeysAndPassPhrase: (acctEmail: string, prvs: Key[], ppOptions?: PassphraseOptions) => Promise = addOrReplaceKeysAndPassPhrase; + const parseAndCheckPrivateKeys = async (decryptedPrivateKeys: string[]) => { const unencryptedPrvs: Key[] = []; // parse and check that all the keys are valid @@ -64,58 +86,81 @@ const parseAndCheckPrivateKeys = async (decryptedPrivateKeys: string[]) => { }; const filterKeysToSave = async (candidateKeys: Key[], existingKeys: KeyInfoWithIdentity[]) => { + // todo: check for uniqueness of candidateKeys identities here? if (!existingKeys.length) { - return candidateKeys; + return { keysToRetain: [], newUnencryptedKeysToSave: candidateKeys }; } - const result: Key[] = []; - for (const candidate of candidateKeys) { - const longid = KeyUtil.getPrimaryLongid(candidate); - const keyToUpdate = existingKeys.filter(ki => ki.longid === longid && ki.family === candidate.family); - if (keyToUpdate.length === 1) { - const oldKey = await KeyUtil.parse(keyToUpdate[0].private); - if (!candidate.lastModified || (oldKey.lastModified && oldKey.lastModified >= candidate.lastModified)) { + const keysToRetain: Key[] = []; + const newUnencryptedKeysToSave: Key[] = []; + for (const candidateKey of candidateKeys) { + const existingKey = existingKeys.find(ki => KeyUtil.identityEquals(ki, candidateKey)); + if (existingKey) { + const parsedExistingKey = await KeyUtil.parse(existingKey.private); + if (!candidateKey.lastModified || (parsedExistingKey.lastModified && parsedExistingKey.lastModified >= candidateKey.lastModified)) { + keysToRetain.push(parsedExistingKey); continue; } - } else if (keyToUpdate.length > 1) { - throw new Error(`Unexpected error: key search by longid=${longid} yielded ${keyToUpdate.length} results`); } - result.push(candidate); + newUnencryptedKeysToSave.push(candidateKey); } - return result; + return { keysToRetain, newUnencryptedKeysToSave }; }; export const processAndStoreKeysFromEkmLocally = async ( - { acctEmail, decryptedPrivateKeys, ppOptions }: { acctEmail: string, decryptedPrivateKeys: string[], ppOptions?: PassphraseOptions } + { acctEmail, decryptedPrivateKeys, ppOptions: originalOptions }: Bm.ProcessAndStoreKeysFromEkmLocally & { ppOptions?: PassphraseOptions } ): Promise => { const { unencryptedPrvs } = await parseAndCheckPrivateKeys(decryptedPrivateKeys); const existingKeys = await KeyStore.get(acctEmail); + let { keysToRetain, newUnencryptedKeysToSave } = await filterKeysToSave(unencryptedPrvs, existingKeys); + if (!newUnencryptedKeysToSave.length && keysToRetain.length === existingKeys.length) { + // nothing to update + return { needPassphrase: false, noKeysSetup: !existingKeys.length }; + } + let ppOptions: PassphraseOptions | undefined; // the options to pass to saveKeysAndPassPhrase + if (!originalOptions?.passphrase && (await ClientConfiguration.newInstance(acctEmail)).mustAutogenPassPhraseQuietly()) { + ppOptions = { passphrase: PgpPwd.random(), passphrase_save: true }; + } else { + ppOptions = originalOptions; + } let passphrase = ppOptions?.passphrase; + let passphraseInLocalStorage = !!ppOptions?.passphrase_save; if (passphrase === undefined && !existingKeys.length) { - return { needPassphrase: false, updateCount: 0 }; // return success as we can't possibly validate a passphrase - // this can only happen on misconfiguration - // todo: or should we throw? + return { needPassphrase: true, noKeysSetup: true }; } - let unencryptedKeysToSave = await filterKeysToSave(unencryptedPrvs, existingKeys); - let encryptedKeys: Key[] = []; - if (unencryptedKeysToSave.length) { + let encryptedKeys: { passphrase: string, keys: Key[] } | undefined; + if (newUnencryptedKeysToSave.length) { if (passphrase === undefined) { // trying to find a passphrase that unlocks at least one key const passphrases = await PassphraseStore.getMany(acctEmail, existingKeys); - passphrase = passphrases.find(pp => pp !== undefined); + const foundPassphrase = passphrases.find(pp => pp !== undefined); + if (foundPassphrase) { + passphrase = foundPassphrase.value; + passphraseInLocalStorage = foundPassphrase.source === 'local'; + } } if (passphrase !== undefined) { - const pp = passphrase; - // todo: some more fancy conversion, preserving a passphrase for a particual longid? - await Promise.all(unencryptedKeysToSave.map(prv => KeyUtil.encrypt(prv, pp))); - encryptedKeys = unencryptedKeysToSave; - unencryptedKeysToSave = []; + const pp = passphrase; // explicitly defined constant string for the mapping function + await Promise.all(newUnencryptedKeysToSave.map(prv => KeyUtil.encrypt(prv, pp))); + encryptedKeys = { keys: newUnencryptedKeysToSave, passphrase }; + newUnencryptedKeysToSave = []; } } - if (encryptedKeys.length) { - // also updates `name`, todo: refactor in #4545 - await saveKeysAndPassPhrase(acctEmail, encryptedKeys, ppOptions); - return { needPassphrase: false, updateCount: encryptedKeys.length }; - } else { - return { needPassphrase: unencryptedKeysToSave.length > 0, updateCount: 0 }; + if (newUnencryptedKeysToSave.length > 0) { + return { needPassphrase: true }; + } + // stage 1. Clear all existingKeys, except for keysToRetain + if (existingKeys.length !== keysToRetain.length) { + await addOrReplaceKeysAndPassPhrase(acctEmail, keysToRetain, undefined, true); + } + // stage 2. Adding new keys + if (encryptedKeys?.keys.length) { + // new keys are about to be added, they must be accompanied with the passphrase setting + const effectivePpOptions = { passphrase: encryptedKeys.passphrase, passphrase_save: passphraseInLocalStorage }; + // ppOptions have special meaning in saveKeysAndPassPhrase(), they trigger `name` updates, todo: refactor in #4545 + await saveKeysAndPassPhrase(acctEmail, encryptedKeys.keys, ppOptions ? effectivePpOptions : undefined); + if (!ppOptions) { + await setPassphraseForPrvs(await ClientConfiguration.newInstance(acctEmail), acctEmail, encryptedKeys.keys, effectivePpOptions); + } } + return { updateCount: encryptedKeys?.keys.length ?? 0 + (existingKeys.length - keysToRetain.length), noKeysSetup: !(encryptedKeys?.keys.length || keysToRetain.length) }; }; diff --git a/extension/js/common/lang.ts b/extension/js/common/lang.ts index be1665f9177..29e42e57bbe 100644 --- a/extension/js/common/lang.ts +++ b/extension/js/common/lang.ts @@ -35,6 +35,7 @@ export const Lang = { // tslint:disable-line:variable-name keyBackupsNotAllowed: 'Key backups are not allowed on this domain.', prvHasFixableCompatIssue: 'This key has minor usability issues that can be fixed. This commonly happens when importing keys from Symantec™ PGP Desktop or other legacy software. It may be missing User IDs, or it may be missing a self-signature. It is also possible that the key is simply expired.', ppMatchAllSet: 'Your pass phrase matches. Good job! You\'re all set.', + noKeys: 'Keys for your account were not set up yet - please ask your systems administrator.', }, account: { googleAcctDisabledOrPolicy: `Your Google Account or Google Email seems to be disabled, or access to this app is disabled by your organisation admin policy. Contact your email administrator.`, diff --git a/extension/js/common/platform/store/in-memory-store.ts b/extension/js/common/platform/store/in-memory-store.ts index 6c5e9f5815e..b75c41d1868 100644 --- a/extension/js/common/platform/store/in-memory-store.ts +++ b/extension/js/common/platform/store/in-memory-store.ts @@ -4,7 +4,7 @@ import { AbstractStore } from './abstract-store.js'; import { BrowserMsg } from '../../browser/browser-msg.js'; /** - * Temporrary In-Memory store for sensitive values, expiring after clientConfiguration.in_memory_pass_phrase_session_length (or default 4 hours) + * Temporary In-Memory store for sensitive values, expiring after clientConfiguration.in_memory_pass_phrase_session_length (or default 4 hours) * see background_page.ts for the other end, also ExpirationCache class */ export class InMemoryStore extends AbstractStore { diff --git a/extension/js/common/platform/store/key-store.ts b/extension/js/common/platform/store/key-store.ts index 0ab66a954aa..4527f8d5a14 100644 --- a/extension/js/common/platform/store/key-store.ts +++ b/extension/js/common/platform/store/key-store.ts @@ -41,7 +41,7 @@ export class KeyStore extends AbstractStore { throw new Error('Cannot import plain, unprotected key.'); } for (const i in keyinfos) { - if (prv.id === keyinfos[i].fingerprints[0]) { // replacing a key + if (KeyUtil.identityEquals(prv, keyinfos[i])) { // replacing a key keyinfos[i] = await KeyUtil.keyInfoObj(prv); updated = true; } diff --git a/extension/js/common/platform/store/passphrase-store.ts b/extension/js/common/platform/store/passphrase-store.ts index f800949ef18..fcd79c66959 100644 --- a/extension/js/common/platform/store/passphrase-store.ts +++ b/extension/js/common/platform/store/passphrase-store.ts @@ -14,15 +14,24 @@ export class PassphraseStore extends AbstractStore { // if we implement (and migrate) password storage to use KeyIdentity instead of longid, we'll have `keyInfo: KeyIdentity` here public static get = async (acctEmail: string, keyInfo: { longid: string }, ignoreSession: boolean = false): Promise => { - return (await PassphraseStore.getMany(acctEmail, [keyInfo], ignoreSession))[0]; + return (await PassphraseStore.getMany(acctEmail, [keyInfo], ignoreSession))[0]?.value; }; // if we implement (and migrate) password storage to use KeyIdentity instead of longid, we'll have `keyInfo: KeyIdentity` here - public static getMany = async (acctEmail: string, keyInfos: { longid: string }[], ignoreSession: boolean = false): Promise<(string | undefined)[]> => { + public static getMany = async (acctEmail: string, keyInfos: { longid: string }[], ignoreSession: boolean = false): + Promise<({ value: string, source: StorageType } | undefined)[]> => { const storageIndexes = keyInfos.map(keyInfo => PassphraseStore.getIndex(keyInfo.longid)); return await PassphraseStore.getByIndexes(acctEmail, storageIndexes, ignoreSession); }; + public static removeMany = async (acctEmail: string, keyInfos: { longid: string }[]) => { + const storageIndexes = keyInfos.map(keyInfo => PassphraseStore.getIndex(keyInfo.longid)); + await Promise.all([ + AcctStore.remove(acctEmail, storageIndexes), // remove from local storage + ...storageIndexes.map(storageIndex => InMemoryStore.set(acctEmail, storageIndex, undefined)) // remove from session + ]); + }; + // if we implement (and migrate) password storage to use KeyIdentity instead of longid, we'll have `keyInfo: KeyIdentity` here public static set = async (storageType: StorageType, acctEmail: string, keyInfo: { longid: string }, passphrase: string | undefined): Promise => { const storageIndex = PassphraseStore.getIndex(keyInfo.longid); @@ -56,17 +65,22 @@ export class PassphraseStore extends AbstractStore { return `passphrase_${longid}` as unknown as AccountIndex; }; - private static getByIndexes = async (acctEmail: string, storageIndexes: AccountIndex[], ignoreSession: boolean = false): Promise<(string | undefined)[]> => { + private static getByIndexes = async (acctEmail: string, storageIndexes: AccountIndex[], ignoreSession: boolean = false): + Promise<({ value: string, source: StorageType } | undefined)[]> => { const storage = await AcctStore.get(acctEmail, storageIndexes); const results = await Promise.all(storageIndexes.map(async storageIndex => { const found = storage[storageIndex]; if (typeof found === 'string') { - return found; + return { value: found, source: 'local' as StorageType }; } if (ignoreSession) { return undefined; } - return await InMemoryStore.get(acctEmail, storageIndex) ?? undefined; + const value = await InMemoryStore.get(acctEmail, storageIndex); + if (typeof value === 'undefined') { + return undefined; + } + return { value, source: 'session' as StorageType }; })); return results; }; @@ -80,7 +94,7 @@ export class PassphraseStore extends AbstractStore { await AcctStore.remove(acctEmail, [storageIndex]); } else { const toSave: AcctStoreDict = {}; - toSave[storageIndex] = passphrase as any; + (toSave as Dict)[storageIndex] = passphrase; await AcctStore.set(acctEmail, toSave); } } diff --git a/extension/js/content_scripts/webmail/setup-webmail-content-script.ts b/extension/js/content_scripts/webmail/setup-webmail-content-script.ts index 962331a1b68..0594adca92a 100644 --- a/extension/js/content_scripts/webmail/setup-webmail-content-script.ts +++ b/extension/js/content_scripts/webmail/setup-webmail-content-script.ts @@ -20,6 +20,8 @@ import { KeyManager } from '../../common/api/key-server/key-manager.js'; import { InMemoryStore } from '../../common/platform/store/in-memory-store.js'; import { AccountServer } from '../../common/api/account-server.js'; import { ApiErr, BackendAuthErr } from '../../common/api/shared/api-error.js'; +import { Url } from '../../common/core/common.js'; +import { Lang } from '../../common/lang.js'; export type WebmailVariantObject = { newDataLayer: undefined | boolean, newUi: undefined | boolean, email: undefined | string, gmailVariant: WebmailVariantString }; export type IntervalFunction = { interval: number, handler: () => void }; @@ -244,9 +246,26 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi await factory.showPassphraseDialog(longids, type, initiatorFrameId); }; - const processKeysFromEkm = async (acctEmail: string, decryptedPrivateKeys: string[], factory: XssSafeFactory, ppEvent: { entered?: boolean }) => { + const processKeysFromEkm = async ( + acctEmail: string, + decryptedPrivateKeys: string[], + clientConfiguration: ClientConfiguration, + factory: XssSafeFactory, + idToken: string, + ppEvent: { entered?: boolean } + ) => { try { - const { needPassphrase, updateCount } = await BrowserMsg.send.bg.await.processAndStoreKeysFromEkmLocally({ acctEmail, decryptedPrivateKeys }); + const { needPassphrase, updateCount, noKeysSetup } = + await BrowserMsg.send.bg.await.processAndStoreKeysFromEkmLocally({ acctEmail, decryptedPrivateKeys }); + if (noKeysSetup) { + if (!needPassphrase && !clientConfiguration.canCreateKeys()) { + await Ui.modal.error(Lang.setup.noKeys); + BrowserMsg.send.bg.settings({ acctEmail, path: 'index.htm' }); + } else { + BrowserMsg.send.bg.settings({ acctEmail, path: Url.create('setup.htm', { idToken, action: 'update_from_ekm' }) }); + } + return; + } if (needPassphrase) { ppEvent.entered = undefined; await showPassphraseDialog(factory, { longids: [], type: 'update_key' }); @@ -254,11 +273,11 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi await Ui.time.sleep(100); } if (ppEvent.entered) { - await processKeysFromEkm(acctEmail, decryptedPrivateKeys, factory, ppEvent); + await processKeysFromEkm(acctEmail, decryptedPrivateKeys, clientConfiguration, factory, idToken, ppEvent); } else { return; } - } else if (updateCount > 0) { + } else if (updateCount && updateCount > 0) { Ui.toast('Account keys updated'); } } catch (e) { @@ -274,9 +293,7 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi const keyManager = new KeyManager(clientConfiguration.getKeyManagerUrlForPrivateKeys()!); Catch.setHandledTimeout(async () => { const { privateKeys } = await keyManager.getPrivateKeys(idToken); - if (privateKeys.length) { - await processKeysFromEkm(acctEmail, privateKeys.map(entry => entry.decryptedPrivateKey), factory, ppEvent); - } + await processKeysFromEkm(acctEmail, privateKeys.map(entry => entry.decryptedPrivateKey), clientConfiguration, factory, idToken, ppEvent); }, 0); } } diff --git a/test/source/mock/key-manager/key-manager-endpoints.ts b/test/source/mock/key-manager/key-manager-endpoints.ts index eccef726c57..c32ee83c9a2 100644 --- a/test/source/mock/key-manager/key-manager-endpoints.ts +++ b/test/source/mock/key-manager/key-manager-endpoints.ts @@ -206,7 +206,7 @@ yeSm0uVPwODhwX7ezB9jW6uVt0R8S8iM3rQdEMsA/jDep5LNn47K6o8VrDt0zYo6 export const MOCK_KM_LAST_INSERTED_KEY: { [acct: string]: { privateKey: string } } = {}; // accessed from test runners -export const MOCK_KM_UPDATING_KEY: { response?: { privateKeys: { decryptedPrivateKey: string }[] }, badRequestError?: string } = {}; +export const MOCK_KM_UPDATING_KEY: { [acct: string]: { response?: { privateKeys: { decryptedPrivateKey: string }[] }, badRequestError?: string } } = {}; export const mockKeyManagerEndpoints: HandlersDefinition = { '/flowcrypt-email-key-manager/v1/keys/private': async ({ body }, req) => { @@ -218,11 +218,12 @@ export const mockKeyManagerEndpoints: HandlersDefinition = { if (acctEmail === 'get.key@key-manager-autogen.flowcrypt.test') { return { privateKeys: [{ decryptedPrivateKey: testConstants.existingPrv }] }; } - if (acctEmail === 'get.updating.key@key-manager-choose-passphrase-forbid-storing.flowcrypt.test') { - if (MOCK_KM_UPDATING_KEY.response !== undefined && MOCK_KM_UPDATING_KEY.badRequestError === undefined) { - return MOCK_KM_UPDATING_KEY.response; + if (acctEmail.includes('updating.key')) { + const { response, badRequestError } = MOCK_KM_UPDATING_KEY[acctEmail]; + if (response !== undefined && badRequestError === undefined) { + return response; } - throw new HttpClientErr(MOCK_KM_UPDATING_KEY.badRequestError || 'Vague error', Status.BAD_REQUEST); + throw new HttpClientErr(badRequestError || 'Vague error', Status.BAD_REQUEST); } if (acctEmail === 'get.key@no-submit-client-configuration.key-manager-autogen.flowcrypt.test') { return { privateKeys: [{ decryptedPrivateKey: prvNoSubmit }] }; @@ -303,6 +304,17 @@ export const mockKeyManagerEndpoints: HandlersDefinition = { MOCK_KM_LAST_INSERTED_KEY[acctEmail] = { privateKey }; return {}; } + if (acctEmail.includes('updating.key')) { + const prv = await KeyUtil.parseMany(privateKey); + expect(prv).to.have.length(1); + expect(prv[0].algo.bits).to.equal(2048); + expect(prv[0].identities).to.have.length(1); + expect(prv[0].isPrivate).to.be.true; + expect(prv[0].fullyDecrypted).to.be.true; + expect(prv[0].expiration).to.not.exist; + MOCK_KM_LAST_INSERTED_KEY[acctEmail] = { privateKey }; + return {}; + } throw new HttpClientErr(`Unexpectedly calling mockKeyManagerEndpoints:/v1/keys/private PUT with acct ${acctEmail}`); } throw new HttpClientErr(`Unknown method: ${req.method}`); diff --git a/test/source/tests/page-recipe/abstract-page-recipe.ts b/test/source/tests/page-recipe/abstract-page-recipe.ts index b94849be86f..22d86e0b88b 100644 --- a/test/source/tests/page-recipe/abstract-page-recipe.ts +++ b/test/source/tests/page-recipe/abstract-page-recipe.ts @@ -47,7 +47,7 @@ export abstract class PageRecipe { } }; - public static sendMessage = async (controllable: Controllable, msg: any) => { + public static sendMessage = async (controllable: Controllable, msg: unknown) => { return await controllable.target.evaluate(async (msg) => await new Promise((resolve) => { chrome.runtime.sendMessage(msg, resolve); }), msg); diff --git a/test/source/tests/setup.ts b/test/source/tests/setup.ts index a8b0d92c196..ea5b2d229aa 100644 --- a/test/source/tests/setup.ts +++ b/test/source/tests/setup.ts @@ -8,7 +8,7 @@ import { TestWithBrowser } from './../test'; import { expect } from 'chai'; import { SettingsPageRecipe } from './page-recipe/settings-page-recipe'; import { ComposePageRecipe } from './page-recipe/compose-page-recipe'; -import { Str } from './../core/common'; +import { Str, emailKeyIndex } from './../core/common'; import { MOCK_KM_LAST_INSERTED_KEY, MOCK_KM_UPDATING_KEY } from './../mock/key-manager/key-manager-endpoints'; import { MOCK_ATTESTER_LAST_INSERTED_PUB } from './../mock/attester/attester-endpoints'; import { BrowserRecipe } from './tooling/browser-recipe'; @@ -17,6 +17,7 @@ import { testConstants } from './tooling/consts'; import { InboxPageRecipe } from './page-recipe/inbox-page-recipe'; import { PageRecipe } from './page-recipe/abstract-page-recipe'; import { TestUrls } from '../browser/test-urls'; +import { ControllablePage } from '../browser'; import { OauthPageRecipe } from './page-recipe/oauth-page-recipe'; // tslint:disable:no-blank-lines-func @@ -581,9 +582,32 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== await securityFrame.notPresent(['@action-change-passphrase-begin', '@action-test-passphrase-begin', '@action-forget-pp']); })); + const getPassphrase = async (page: ControllablePage, acctEmail: string, longid: string) => { + const key = `cryptup_${emailKeyIndex(acctEmail, 'passphrase')}_${longid}`; + const passphrase = (await page.getFromLocalStorage([key]))[key] || await BrowserRecipe.getPassphraseFromInMemoryStore(page, acctEmail, longid); + expect(passphrase).to.be.a.string; + return passphrase as string; + }; + + const retrieveAndCheckKeys = async (page: ControllablePage, acctEmail: string, expectedKeyCount: number, passphrase?: string) => { + const key = `cryptup_${emailKeyIndex(acctEmail, 'keys')}`; + const keyset = (await page.getFromLocalStorage([key]))[key]; + const kis = keyset as KeyInfoWithIdentity[]; + expect(kis.length).to.equal(expectedKeyCount); + return await Promise.all(kis.map(async ki => { + const prv = await KeyUtil.parse(ki.private); + expect(prv.fullyEncrypted).to.be.true; + const passphraseToDecrypt = passphrase || await getPassphrase(page, acctEmail, KeyUtil.getPrimaryLongid(prv)); + expect(passphraseToDecrypt).to.be.not.empty; + expect(await KeyUtil.decrypt(prv, passphraseToDecrypt, undefined, undefined)).to.be.true; + expect(prv.lastModified).to.not.be.an.undefined; + return prv; + })); + }; + ava.default('get.updating.key@key-manager-choose-passphrase-forbid-storing.flowcrypt.test - automatic update of key found on key manager', testWithBrowser(undefined, async (t, browser) => { - MOCK_KM_UPDATING_KEY.response = { privateKeys: [{ decryptedPrivateKey: testConstants.updatingPrv }] }; const acct = 'get.updating.key@key-manager-choose-passphrase-forbid-storing.flowcrypt.test'; + MOCK_KM_UPDATING_KEY[acct] = { response: { privateKeys: [{ decryptedPrivateKey: testConstants.updatingPrv }] } }; const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct); const passphrase = 'long enough to suit requirements'; await SetupPageRecipe.autoSetupWithEKM(settingsPage, { @@ -591,43 +615,30 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== }); const accessToken = await BrowserRecipe.getGoogleAccessToken(settingsPage, acct); const extraAuthHeaders = { Authorization: `Bearer ${accessToken}` }; - const retrieveAndCheckKeys = async (expectedKeyCount: number) => { - const { cryptup_getupdatingkeykeymanagerchoosepassphraseforbidstoringflowcrypttest_keys: keyset } - = await settingsPage.getFromLocalStorage(['cryptup_getupdatingkeykeymanagerchoosepassphraseforbidstoringflowcrypttest_keys']); - const kis = keyset as KeyInfoWithIdentity[]; - expect(kis.length).to.equal(expectedKeyCount); - return await Promise.all(kis.map(async ki => { - const prv = await KeyUtil.parse(ki.private); - expect(prv.fullyEncrypted).to.be.true; - expect(await KeyUtil.decrypt(prv, passphrase as string, undefined, undefined)).to.be.true; - expect(prv.lastModified).to.not.be.an.undefined; - return prv; - })); - }; const updateAndArmorKey = async (prv: Key) => { return KeyUtil.armor(await KeyUtil.reformatKey(prv, undefined, [{ name: 'Full Name', email: acct }], 6000)); }; - const set1 = await retrieveAndCheckKeys(1); + const set1 = await retrieveAndCheckKeys(settingsPage, acct, 1); // 1. EKM returns the same key, no update, no toast let gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); await PageRecipe.noToastAppears(gmailPage); await gmailPage.notPresent('@dialog-passphrase'); - const set2 = await retrieveAndCheckKeys(1); + const set2 = await retrieveAndCheckKeys(settingsPage, acct, 1); expect(set2[0].lastModified).to.equal(set1[0].lastModified); // no update await gmailPage.close(); // 2. EKM returns a newer version of the existing key const someOlderVersion = await updateAndArmorKey(set2[0]); - MOCK_KM_UPDATING_KEY.response = { privateKeys: [{ decryptedPrivateKey: someOlderVersion }] }; + MOCK_KM_UPDATING_KEY[acct].response = { privateKeys: [{ decryptedPrivateKey: someOlderVersion }] }; gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); await PageRecipe.waitForToastToAppearAndDisappear(gmailPage, 'Account keys updated'); - const set3 = await retrieveAndCheckKeys(1); + const set3 = await retrieveAndCheckKeys(settingsPage, acct, 1); expect(set3[0].lastModified!).to.be.greaterThan(set2[0].lastModified!); // an update happened await gmailPage.close(); // 3. EKM returns the same version of the existing key, no toast, no update gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); await PageRecipe.noToastAppears(gmailPage); await gmailPage.notPresent('@dialog-passphrase'); - const set4 = await retrieveAndCheckKeys(1); + const set4 = await retrieveAndCheckKeys(settingsPage, acct, 1); expect(set4[0].lastModified).to.equal(set3[0].lastModified); // no update // 4. Forget the passphrase, EKM the same version of the existing key, no prompt await InboxPageRecipe.finishSessionOnInboxPage(gmailPage); @@ -635,16 +646,16 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); await PageRecipe.noToastAppears(gmailPage); await gmailPage.notPresent('@dialog-passphrase'); - const set5 = await retrieveAndCheckKeys(1); + const set5 = await retrieveAndCheckKeys(settingsPage, acct, 1, passphrase); expect(set5[0].lastModified).to.equal(set4[0].lastModified); // no update await gmailPage.close(); // 5. EKM returns a newer version of the existing key, canceling passphrase prompt, no update - MOCK_KM_UPDATING_KEY.response = { privateKeys: [{ decryptedPrivateKey: await updateAndArmorKey(set5[0]) }] }; + MOCK_KM_UPDATING_KEY[acct].response = { privateKeys: [{ decryptedPrivateKey: await updateAndArmorKey(set5[0]) }] }; gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); await gmailPage.waitAll('@dialog-passphrase'); await ComposePageRecipe.cancelPassphraseDialog(gmailPage, 'keyboard'); await PageRecipe.noToastAppears(gmailPage); - const set6 = await retrieveAndCheckKeys(1); + const set6 = await retrieveAndCheckKeys(settingsPage, acct, 1, passphrase); expect(set6[0].lastModified).to.equal(set5[0].lastModified); // no update await gmailPage.close(); // 6. EKM returns a newer version of the existing key, entering the passphrase, update toast @@ -658,42 +669,43 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== } await gmailPage.waitTillGone('@dialog-passphrase'); await PageRecipe.waitForToastToAppearAndDisappear(gmailPage, 'Account keys updated'); - const set7 = await retrieveAndCheckKeys(1); + const set7 = await retrieveAndCheckKeys(settingsPage, acct, 1); expect(set7[0].lastModified!).to.be.greaterThan(set6[0].lastModified!); // an update happened await gmailPage.close(); // 7. EKM returns an older version of the existing key, no toast, no update - MOCK_KM_UPDATING_KEY.response = { privateKeys: [{ decryptedPrivateKey: someOlderVersion }] }; + MOCK_KM_UPDATING_KEY[acct].response = { privateKeys: [{ decryptedPrivateKey: someOlderVersion }] }; gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); await PageRecipe.noToastAppears(gmailPage); await gmailPage.notPresent('@dialog-passphrase'); - const set8 = await retrieveAndCheckKeys(1); + const set8 = await retrieveAndCheckKeys(settingsPage, acct, 1); expect(set8[0].lastModified).to.equal(set7[0].lastModified); // no update await gmailPage.close(); // 8. EKM returns an older version of the existing key, and a new key, toast, new key gets added encrypted with the same passphrase - MOCK_KM_UPDATING_KEY.response = { privateKeys: [{ decryptedPrivateKey: someOlderVersion }, { decryptedPrivateKey: testConstants.existingPrv }] }; + MOCK_KM_UPDATING_KEY[acct].response = { privateKeys: [{ decryptedPrivateKey: someOlderVersion }, { decryptedPrivateKey: testConstants.existingPrv }] }; gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); await PageRecipe.waitForToastToAppearAndDisappear(gmailPage, 'Account keys updated'); await gmailPage.notPresent('@dialog-passphrase'); - const set9 = await retrieveAndCheckKeys(2); + const set9 = await retrieveAndCheckKeys(settingsPage, acct, 2); const mainKey9 = KeyUtil.filterKeysByIdentity(set9, [{ family: 'openpgp', id: '392FB1E9FF4184659AB6A246835C0141B9ECF536' }]); expect(mainKey9.length).to.equal(1); - expect(KeyUtil.filterKeysByIdentity(set9, [{ family: 'openpgp', id: 'FAFB7D675AC74E87F84D169F00B0115807969D75' }]).length).to.equal(1); + const secondaryKey9 = KeyUtil.filterKeysByIdentity(set9, [{ family: 'openpgp', id: 'FAFB7D675AC74E87F84D169F00B0115807969D75' }]); + expect(secondaryKey9.length).to.equal(1); expect(mainKey9[0].lastModified).to.equal(set8[0].lastModified); // no update await gmailPage.close(); - // 9. EKM returns a newer version of one key, fully omitting the other one, a toast, and update, no removal - MOCK_KM_UPDATING_KEY.response = { privateKeys: [{ decryptedPrivateKey: await updateAndArmorKey(mainKey9[0]) }] }; + // 9. EKM returns a newer version of one key, fully omitting the other one, a toast, an update and removal + MOCK_KM_UPDATING_KEY[acct].response = { privateKeys: [{ decryptedPrivateKey: await updateAndArmorKey(mainKey9[0]) }] }; gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); await PageRecipe.waitForToastToAppearAndDisappear(gmailPage, 'Account keys updated'); await gmailPage.notPresent('@dialog-passphrase'); - const set10 = await retrieveAndCheckKeys(2); - const mainKey10 = KeyUtil.filterKeysByIdentity(set10, [{ family: 'openpgp', id: '392FB1E9FF4184659AB6A246835C0141B9ECF536' }]); + const set10 = await retrieveAndCheckKeys(settingsPage, acct, 1); + const mainKey10 = KeyUtil.filterKeysByIdentity(set10, [mainKey9[0]]); + expect(await getPassphrase(settingsPage, acct, KeyUtil.getPrimaryLongid(secondaryKey9[0]))).to.be.an.undefined; // the passphrase for the old key was deleted expect(mainKey10.length).to.equal(1); - expect(KeyUtil.filterKeysByIdentity(set10, [{ family: 'openpgp', id: 'FAFB7D675AC74E87F84D169F00B0115807969D75' }]).length).to.equal(1); expect(mainKey10[0].lastModified!).to.be.greaterThan(mainKey9[0].lastModified!); // updated this key // 10. Forget the passphrase, EKM returns a third key, we enter a passphrase that doesn't match any of the existing keys, no update await InboxPageRecipe.finishSessionOnInboxPage(gmailPage); await gmailPage.close(); - MOCK_KM_UPDATING_KEY.response = { privateKeys: [{ decryptedPrivateKey: testConstants.unprotectedPrvKey }] }; + MOCK_KM_UPDATING_KEY[acct].response = { privateKeys: [{ decryptedPrivateKey: testConstants.unprotectedPrvKey }] }; gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); await gmailPage.waitAll('@dialog-passphrase'); { @@ -706,10 +718,10 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== } await ComposePageRecipe.cancelPassphraseDialog(gmailPage, 'keyboard'); await PageRecipe.noToastAppears(gmailPage); - const set11 = await retrieveAndCheckKeys(2); - expect(set11.map(entry => entry.id)).to.eql(['392FB1E9FF4184659AB6A246835C0141B9ECF536', 'FAFB7D675AC74E87F84D169F00B0115807969D75']); + const set11 = await retrieveAndCheckKeys(settingsPage, acct, 1, passphrase); + expect(set11.map(entry => entry.id)).to.eql(['392FB1E9FF4184659AB6A246835C0141B9ECF536']); await gmailPage.close(); - // 11. EKM returns a new third key, we enter a passphrase matching an existing key, update happens + // 11. EKM returns a new third key, we enter a passphrase matching an existing key, update happens, the old key is removed gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); await gmailPage.waitAll('@dialog-passphrase'); { @@ -720,16 +732,15 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== } await gmailPage.waitTillGone('@dialog-passphrase'); await PageRecipe.waitForToastToAppearAndDisappear(gmailPage, 'Account keys updated'); - const set12 = await retrieveAndCheckKeys(3); - expect(set12.map(entry => entry.id)).to.eql([ - '392FB1E9FF4184659AB6A246835C0141B9ECF536', - 'FAFB7D675AC74E87F84D169F00B0115807969D75', - '277D1ADA213881F4ABE0415395E783DC0289E2E2' - ]); + const set12 = await retrieveAndCheckKeys(settingsPage, acct, 1); + expect(await getPassphrase(settingsPage, acct, KeyUtil.getPrimaryLongid(set11[0]))).to.be.an.undefined; // the passphrase for the old key was deleted + expect(set12.map(entry => entry.id)).to.eql(['277D1ADA213881F4ABE0415395E783DC0289E2E2']); + const mainKey12 = KeyUtil.filterKeysByIdentity(set12, [{ family: 'openpgp', id: '277D1ADA213881F4ABE0415395E783DC0289E2E2' }]); + expect(mainKey12.length).to.equal(1); // 12. Forget the passphrase, EKM sends a broken key, no passphrase dialog, no updates await InboxPageRecipe.finishSessionOnInboxPage(gmailPage); await gmailPage.close(); - MOCK_KM_UPDATING_KEY.response.privateKeys = [ + MOCK_KM_UPDATING_KEY[acct].response.privateKeys = [ { decryptedPrivateKey: await updateAndArmorKey(set2[0]) }, // update the main key // only include a half of another armored key { decryptedPrivateKey: testConstants.unprotectedPrvKey.substring(0, testConstants.unprotectedPrvKey.length / 2) } @@ -738,33 +749,110 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== await PageRecipe.waitForToastToAppearAndDisappear(gmailPage, 'Could not update keys from EKM due to error: BrowserMsg(processAndStoreKeysFromEkmLocally) sendRawResponse::Error: Some keys could not be parsed'); await gmailPage.notPresent('@dialog-passphrase'); - const set13 = await retrieveAndCheckKeys(3); - expect(set13.map(entry => entry.id)).to.eql([ - '392FB1E9FF4184659AB6A246835C0141B9ECF536', - 'FAFB7D675AC74E87F84D169F00B0115807969D75', - '277D1ADA213881F4ABE0415395E783DC0289E2E2' - ]); - const mainKey13 = KeyUtil.filterKeysByIdentity(set13, [{ family: 'openpgp', id: '392FB1E9FF4184659AB6A246835C0141B9ECF536' }]); + const set13 = await retrieveAndCheckKeys(settingsPage, acct, 1, passphrase); + expect(set13.map(entry => entry.id)).to.eql(['277D1ADA213881F4ABE0415395E783DC0289E2E2']); + const mainKey13 = KeyUtil.filterKeysByIdentity(set13, [{ family: 'openpgp', id: '277D1ADA213881F4ABE0415395E783DC0289E2E2' }]); expect(mainKey13.length).to.equal(1); - expect(mainKey13[0].lastModified).to.equal(mainKey10[0].lastModified); // no update + expect(mainKey13[0].lastModified).to.equal(mainKey12[0].lastModified); // no update await gmailPage.close(); // 13. EKM down, no toast, no passphrase dialog, no updates - MOCK_KM_UPDATING_KEY.badRequestError = 'RequestTimeout'; + MOCK_KM_UPDATING_KEY[acct] = { badRequestError: 'RequestTimeout' }; gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); await PageRecipe.noToastAppears(gmailPage); await gmailPage.notPresent('@dialog-passphrase'); - const set14 = await retrieveAndCheckKeys(3); - expect(set14.map(entry => entry.id)).to.eql([ - '392FB1E9FF4184659AB6A246835C0141B9ECF536', - 'FAFB7D675AC74E87F84D169F00B0115807969D75', - '277D1ADA213881F4ABE0415395E783DC0289E2E2' - ]); - const mainKey14 = KeyUtil.filterKeysByIdentity(set14.map(ki => ki), [{ family: 'openpgp', id: '392FB1E9FF4184659AB6A246835C0141B9ECF536' }]); + const set14 = await retrieveAndCheckKeys(settingsPage, acct, 1, passphrase); + expect(set14.map(entry => entry.id)).to.eql(['277D1ADA213881F4ABE0415395E783DC0289E2E2']); + const mainKey14 = KeyUtil.filterKeysByIdentity(set14.map(ki => ki), [{ family: 'openpgp', id: '277D1ADA213881F4ABE0415395E783DC0289E2E2' }]); expect(mainKey14.length).to.equal(1); expect(mainKey14[0].lastModified).to.equal(mainKey13[0].lastModified); // no update await gmailPage.close(); })); + ava.default('put.updating.key@key-manager-choose-passphrase-forbid-storing.flowcrypt.test - updates of key found on key manager via setup page (with passphrase)', + testWithBrowser(undefined, async (t, browser) => { + const acct = 'put.updating.key@key-manager-choose-passphrase-forbid-storing.flowcrypt.test'; + MOCK_KM_UPDATING_KEY[acct] = { response: { privateKeys: [{ decryptedPrivateKey: testConstants.updatingPrv }] } }; + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct); + const passphrase = 'long enough to suit requirements'; + await SetupPageRecipe.autoSetupWithEKM(settingsPage, { + enterPp: { passphrase, checks: { isSavePassphraseChecked: false, isSavePassphraseHidden: true } } + }); + const accessToken = await BrowserRecipe.getGoogleAccessToken(settingsPage, acct); + const extraAuthHeaders = { Authorization: `Bearer ${accessToken}` }; + const set1 = await retrieveAndCheckKeys(settingsPage, acct, 1); + // 1. EKM returns the empty set, forcing to auto-generate + MOCK_KM_UPDATING_KEY[acct].response = { privateKeys: [] }; + let gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); + // The new settingsPage is loaded in place of the existing settings tab (this is by design) + // However, after a second the newly-activated (old) settings tab loses focus in favour of the gmailPage, why is that? + // Looks like Puppeteer's misbehaviour + await PageRecipe.noToastAppears(gmailPage); + await gmailPage.notPresent('@dialog-passphrase'); + await gmailPage.close(); + await retrieveAndCheckKeys(settingsPage, acct, 0); // no keys, auto-generation + expect(await getPassphrase(settingsPage, acct, KeyUtil.getPrimaryLongid(set1[0]))).to.be.an.undefined; // the passphrase for the old key was deleted + delete MOCK_KM_LAST_INSERTED_KEY[acct]; + await SetupPageRecipe.autoSetupWithEKM(settingsPage, { + enterPp: { passphrase, checks: { isSavePassphraseChecked: false, isSavePassphraseHidden: true } } + }); + expect(MOCK_KM_LAST_INSERTED_KEY[acct]).to.exist; + const set2 = await retrieveAndCheckKeys(settingsPage, acct, 1); + expect(set2[0].id).to.not.equal(set1[0].id); // entirely new key was generated + // 2. Adding a new key from the key manager when there is none in the storage + // First, erase the keys by supplying an empty set from mock EKM + MOCK_KM_UPDATING_KEY[acct].response = { privateKeys: [] }; + gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); + await PageRecipe.noToastAppears(gmailPage); + await gmailPage.notPresent('@dialog-passphrase'); + await gmailPage.close(); + await retrieveAndCheckKeys(settingsPage, acct, 0); // no keys, auto-generation + expect(await getPassphrase(settingsPage, acct, KeyUtil.getPrimaryLongid(set2[0]))).to.be.an.undefined; // the passphrase for the old key was deleted + await settingsPage.close(); + // Secondly, configure mock EKM to return a key and re-load the gmail page + MOCK_KM_UPDATING_KEY[acct] = { response: { privateKeys: [{ decryptedPrivateKey: testConstants.updatingPrv }] } }; + gmailPage = await browser.newPage(t, undefined, undefined, extraAuthHeaders); + const newSettingsPage = await browser.newPageTriggeredBy(t, () => gmailPage.goto(TestUrls.mockGmailUrl())); + await SetupPageRecipe.autoSetupWithEKM(newSettingsPage, { + enterPp: { passphrase, checks: { isSavePassphraseChecked: false, isSavePassphraseHidden: true } } + }); + expect(MOCK_KM_LAST_INSERTED_KEY[acct]).to.exist; + const set3 = await retrieveAndCheckKeys(newSettingsPage, acct, 1); + expect(set3[0].id).to.equal(set1[0].id); // the key was received from the EKM + await newSettingsPage.close(); + await gmailPage.close(); + })); + + ava.default('get.updating.key@key-manager-autoimport-no-prv-create.flowcrypt.test - updates of key found on key manager when NO_PRV_CREATE', + testWithBrowser(undefined, async (t, browser) => { + const acct = 'get.updating.key@key-manager-autoimport-no-prv-create.flowcrypt.test'; + MOCK_KM_UPDATING_KEY[acct] = { response: { privateKeys: [{ decryptedPrivateKey: testConstants.updatingPrv }] } }; + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct); + await SetupPageRecipe.autoSetupWithEKM(settingsPage); + const accessToken = await BrowserRecipe.getGoogleAccessToken(settingsPage, acct); + const extraAuthHeaders = { Authorization: `Bearer ${accessToken}` }; + const set1 = await retrieveAndCheckKeys(settingsPage, acct, 1); + MOCK_KM_UPDATING_KEY[acct].response = { privateKeys: [] }; + // 1. EKM returns the empty set, auto-generation is not allowed, hence the error modal + let gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); + await gmailPage.waitAndRespondToModal('error', 'confirm', 'Keys for your account were not set up yet - please ask your systems administrator'); await PageRecipe.noToastAppears(gmailPage); + await gmailPage.close(); + await retrieveAndCheckKeys(settingsPage, acct, 0); // no keys + expect(await getPassphrase(settingsPage, acct, KeyUtil.getPrimaryLongid(set1[0]))).to.be.an.undefined; // the passphrase for the old key was deleted + await settingsPage.close(); + // 2. Adding a new key from the key manager when there is none in the storage + MOCK_KM_UPDATING_KEY[acct] = { response: { privateKeys: [{ decryptedPrivateKey: testConstants.updatingPrv }] } }; + gmailPage = await browser.newPage(t, TestUrls.mockGmailUrl(), undefined, extraAuthHeaders); + await PageRecipe.waitForToastToAppearAndDisappear(gmailPage, 'Account keys updated'); + await gmailPage.close(); + const dbPage = await browser.newPage(t, TestUrls.extension('chrome/dev/ci_unit_test.htm')); + const set2 = await retrieveAndCheckKeys(dbPage, acct, 1); + expect(set2[0].id).to.equal(set1[0].id); // the key was received from the EKM + await dbPage.close(); + })); + + ava.default.todo('DEFAULT_REMEMBER_PASS_PHRASE with auto-generation when all keys are removed by EKM'); + // should we re-use the known passphrase or delete it from the storage in this scenario? + ava.default('get.key@key-manager-choose-passphrase.flowcrypt.test - passphrase chosen by user with key found on key manager', testWithBrowser(undefined, async (t, browser) => { const acct = 'get.key@key-manager-choose-passphrase.flowcrypt.test'; const passphrase = 'Long and complicated pass PHRASE'; diff --git a/test/source/tests/tooling/browser-recipe.ts b/test/source/tests/tooling/browser-recipe.ts index 2076db1d57f..9d917ba46c3 100644 --- a/test/source/tests/tooling/browser-recipe.ts +++ b/test/source/tests/tooling/browser-recipe.ts @@ -100,15 +100,18 @@ export class BrowserRecipe { return (result as { result: string }).result; }; - public static getPassphraseFromInMemoryStore = async (controllable: Controllable, acctEmail: string, longid: string): Promise => { + public static getFromInMemoryStore = async (controllable: Controllable, acctEmail: string, key: string): Promise => { const result = await PageRecipe.sendMessage(controllable, { name: 'inMemoryStoreGet', // tslint:disable-next-line:no-null-keyword - data: { bm: { acctEmail, key: `passphrase_${longid}` }, objUrls: {} }, to: null, uid: '2' + data: { bm: { acctEmail, key }, objUrls: {} }, to: null, uid: '2' // todo: random uid? }); return (result as { result: string }).result; }; + public static getPassphraseFromInMemoryStore = (controllable: Controllable, acctEmail: string, longid: string): Promise => + BrowserRecipe.getFromInMemoryStore(controllable, acctEmail, `passphrase_${longid}`); + public static deleteAllDraftsInGmailAccount = async (settingsPage: ControllablePage): Promise => { const accessToken = await BrowserRecipe.getGoogleAccessToken(settingsPage, 'ci.tests.gmail@flowcrypt.dev'); const gmail = google.gmail({ version: 'v1' });