diff --git a/extension/chrome/elements/compose-modules/compose-draft-module.ts b/extension/chrome/elements/compose-modules/compose-draft-module.ts index 552ae8bcf00..3100a11cb8a 100644 --- a/extension/chrome/elements/compose-modules/compose-draft-module.ts +++ b/extension/chrome/elements/compose-modules/compose-draft-module.ts @@ -13,7 +13,7 @@ import { Env } from '../../../js/common/browser/env.js'; import { GlobalStore } from '../../../js/common/platform/store/global-store.js'; import { GmailRes } from '../../../js/common/api/email-provider/gmail/gmail-parser.js'; import { MsgBlockParser } from '../../../js/common/core/msg-block-parser.js'; -import { MsgUtil } from '../../../js/common/core/crypto/pgp/msg-util.js'; +import { DecryptErrTypes, MsgUtil } from '../../../js/common/core/crypto/pgp/msg-util.js'; import { Ui } from '../../../js/common/browser/ui.js'; import { Str, Url } from '../../../js/common/core/common.js'; import { Xss } from '../../../js/common/platform/xss.js'; @@ -22,6 +22,7 @@ import { ComposeView } from '../compose.js'; import { KeyStore } from '../../../js/common/platform/store/key-store.js'; import { KeyUtil } from '../../../js/common/core/crypto/key.js'; import { SendableMsg, InvalidRecipientError } from '../../../js/common/api/email-provider/sendable-msg.js'; +import { PassphraseStore } from '../../../js/common/platform/store/passphrase-store.js'; export class ComposeDraftModule extends ViewModule { @@ -288,28 +289,27 @@ export class ComposeDraftModule extends ViewModule { return await this.abortAndRenderReplyMsgComposeTableIfIsReplyBox('!rawBlock'); } const encryptedData = rawBlock.content instanceof Buf ? rawBlock.content : Buf.fromUtfStr(rawBlock.content); - const passphrase = await this.view.storageModule.passphraseGet(); - if (typeof passphrase !== 'undefined') { - const decrypted = await MsgUtil.decryptMessage({ kisWithPp: await KeyStore.getAllWithOptionalPassPhrase(this.view.acctEmail), encryptedData }); - if (!decrypted.success) { - return await this.abortAndRenderReplyMsgComposeTableIfIsReplyBox('!decrypted.success'); + const decrypted = await MsgUtil.decryptMessage({ kisWithPp: await KeyStore.getAllWithOptionalPassPhrase(this.view.acctEmail), encryptedData }); + if (!decrypted.success) { + if (decrypted.error.type === DecryptErrTypes.needPassphrase) { + // "close" button will wipe this frame out, so no need to exit the recursion + await this.renderPPDialogAndWaitWhenPPEntered(decrypted.longids.needPassphrase); + await this.decryptAndRenderDraft(encrypted); } - this.wasMsgLoadedFromDraft = true; - this.view.S.cached('prompt').css({ display: 'none' }); - const { blocks, isRichText } = await MsgBlockParser.fmtDecryptedAsSanitizedHtmlBlocks(decrypted.content, 'IMG-KEEP'); - const sanitizedContent = blocks.find(b => b.type === 'decryptedHtml')?.content; - if (!sanitizedContent) { - return await this.abortAndRenderReplyMsgComposeTableIfIsReplyBox('!sanitizedContent'); - } - if (isRichText) { - this.view.sendBtnModule.popover.toggleItemTick($('.action-toggle-richtext-sending-option'), 'richtext', true); - } - this.view.inputModule.inputTextHtmlSetSafely(sanitizedContent.toString()); - this.view.inputModule.squire.focus(); - } else { - await this.renderPPDialogAndWaitWhenPPEntered(); - await this.decryptAndRenderDraft(encrypted); + return await this.abortAndRenderReplyMsgComposeTableIfIsReplyBox('!decrypted.success'); + } + this.wasMsgLoadedFromDraft = true; + this.view.S.cached('prompt').css({ display: 'none' }); + const { blocks, isRichText } = await MsgBlockParser.fmtDecryptedAsSanitizedHtmlBlocks(decrypted.content, 'IMG-KEEP'); + const sanitizedContent = blocks.find(b => b.type === 'decryptedHtml')?.content; + if (!sanitizedContent) { + return await this.abortAndRenderReplyMsgComposeTableIfIsReplyBox('!sanitizedContent'); + } + if (isRichText) { + this.view.sendBtnModule.popover.toggleItemTick($('.action-toggle-richtext-sending-option'), 'richtext', true); } + this.view.inputModule.inputTextHtmlSetSafely(sanitizedContent.toString()); + this.view.inputModule.squire.focus(); }; private hasBodyChanged = (msgBody: string) => { @@ -335,8 +335,8 @@ export class ComposeDraftModule extends ViewModule { return false; }; - private renderPPDialogAndWaitWhenPPEntered = async () => { - const promptText = `
Waiting for pass phrase to open draft..
`; + private renderPPDialogAndWaitWhenPPEntered = async (longids: string[]) => { + const promptText = `
Waiting for pass phrase to open draft..
`; if (this.view.isReplyBox) { Xss.sanitizeRender(this.view.S.cached('prompt'), promptText).css({ display: 'block' }); this.view.sizeModule.resizeComposeBox(); @@ -345,11 +345,10 @@ export class ComposeDraftModule extends ViewModule { BrowserMsg.send.setActiveWindow(this.view.parentTabId, { frameId: this.view.frameId }); } this.view.S.cached('prompt').find('a.action_open_passphrase_dialog').click(this.view.setHandler(async () => { - const primaryKi = await KeyStore.getFirstRequired(this.view.acctEmail); - BrowserMsg.send.passphraseDialog(this.view.parentTabId, { type: 'draft', longids: [primaryKi.longid] }); + BrowserMsg.send.passphraseDialog(this.view.parentTabId, { type: 'draft', longids }); })); this.view.S.cached('prompt').find('a.action_close').click(this.view.setHandler(() => this.view.renderModule.closeMsg())); - await this.view.storageModule.whenMasterPassphraseEntered(); + await PassphraseStore.waitUntilPassphraseChanged(this.view.acctEmail, longids, 1000, this.view.ppChangedPromiseCancellation); }; private abortAndRenderReplyMsgComposeTableIfIsReplyBox = async (reason: string) => { diff --git a/extension/chrome/elements/compose-modules/compose-storage-module.ts b/extension/chrome/elements/compose-modules/compose-storage-module.ts index f0b013d51ef..ded7e951b96 100644 --- a/extension/chrome/elements/compose-modules/compose-storage-module.ts +++ b/extension/chrome/elements/compose-modules/compose-storage-module.ts @@ -2,7 +2,7 @@ 'use strict'; -import { Bm, BrowserMsg } from '../../../js/common/browser/browser-msg.js'; +import { BrowserMsg } from '../../../js/common/browser/browser-msg.js'; import { KeyInfo, KeyUtil, Key, PubkeyResult } from '../../../js/common/core/crypto/key.js'; import { ApiErr } from '../../../js/common/api/shared/api-error.js'; import { Assert } from '../../../js/common/assert.js'; @@ -18,18 +18,6 @@ import { PassphraseStore } from '../../../js/common/platform/store/passphrase-st import { Settings } from '../../../js/common/settings.js'; export class ComposeStorageModule extends ViewModule { - - private passphraseInterval: number | undefined; - - public setHandlers = () => { - BrowserMsg.addListener('passphrase_entry', async ({ entered }: Bm.PassphraseEntry) => { - if (!entered) { - clearInterval(this.passphraseInterval); - this.view.sendBtnModule.resetSendBtn(); - } - }); - }; - // if `type` is supplied, returns undefined if no keys of this type are found public getKeyOptional = async (senderEmail: string | undefined, type?: 'openpgp' | 'x509' | undefined) => { const keys = await KeyStore.getTypedKeyInfos(this.view.acctEmail); @@ -97,7 +85,7 @@ export class ComposeStorageModule extends ViewModule { return Object.entries(resultsPerType).sort((a, b) => rank(a) - rank(b))[0][1]; }; - public passphraseGet = async (senderKi?: { longid: string }) => { + public passphraseGet = async (senderKi: { longid: string }) => { if (!senderKi) { senderKi = await KeyStore.getFirstRequired(this.view.acctEmail); } @@ -108,10 +96,11 @@ export class ComposeStorageModule extends ViewModule { const prv = await KeyUtil.parse(senderKi.private); const passphrase = await this.passphraseGet(senderKi); if (typeof passphrase === 'undefined' && !prv.fullyDecrypted) { - BrowserMsg.send.passphraseDialog(this.view.parentTabId, { type: 'sign', longids: [senderKi.longid] }); - if ((typeof await this.whenMasterPassphraseEntered(60)) !== 'undefined') { // pass phrase entered + const longids = [senderKi.longid]; + BrowserMsg.send.passphraseDialog(this.view.parentTabId, { type: 'sign', longids }); + if (await PassphraseStore.waitUntilPassphraseChanged(this.view.acctEmail, longids, 1000, this.view.ppChangedPromiseCancellation)) { return await this.decryptSenderKey(senderKi); - } else { // timeout - reset - no passphrase entered + } else { // reset - no passphrase entered this.view.sendBtnModule.resetSendBtn(); return undefined; } @@ -206,23 +195,6 @@ export class ComposeStorageModule extends ViewModule { } }; - public whenMasterPassphraseEntered = async (secondsTimeout?: number): Promise => { - clearInterval(this.passphraseInterval); - const timeoutAt = secondsTimeout ? Date.now() + secondsTimeout * 1000 : undefined; - return await new Promise(resolve => { - this.passphraseInterval = Catch.setHandledInterval(async () => { - const passphrase = await this.passphraseGet(); - if (typeof passphrase !== 'undefined') { - clearInterval(this.passphraseInterval); - resolve(passphrase); - } else if (timeoutAt && Date.now() > timeoutAt) { - clearInterval(this.passphraseInterval); - resolve(undefined); - } - }, 1000); - }); - }; - public refreshAccountAndSubscriptionIfLoggedIn = async () => { const auth = await AcctStore.authInfo(this.view.acctEmail); if (auth.uuid) { diff --git a/extension/chrome/elements/compose.ts b/extension/chrome/elements/compose.ts index fd880689692..6434b87a0ed 100644 --- a/extension/chrome/elements/compose.ts +++ b/extension/chrome/elements/compose.ts @@ -8,7 +8,7 @@ import { Assert } from '../../js/common/assert.js'; import { Bm, BrowserMsg } from '../../js/common/browser/browser-msg.js'; import { Gmail } from '../../js/common/api/email-provider/gmail/gmail.js'; import { Ui } from '../../js/common/browser/ui.js'; -import { Url } from '../../js/common/core/common.js'; +import { PromiseCancellation, Url } from '../../js/common/core/common.js'; import { View } from '../../js/common/view.js'; import { XssSafeFactory } from '../../js/common/xss-safe-factory.js'; import { opgp } from '../../js/common/core/crypto/pgp/openpgpjs-custom.js'; @@ -47,6 +47,7 @@ export class ComposeView extends View { public skipClickPrompt: boolean; public draftId: string; public threadId: string = ''; + public ppChangedPromiseCancellation: PromiseCancellation = { cancel: false }; public scopes!: Scopes; public tabId!: string; @@ -187,6 +188,12 @@ export class ComposeView extends View { this.S.cached('input_to').focus(); } }); + BrowserMsg.addListener('passphrase_entry', async ({ entered }: Bm.PassphraseEntry) => { + if (!entered) { + this.ppChangedPromiseCancellation.cancel = true; // update original object which is monitored by a promise + this.ppChangedPromiseCancellation = { cancel: false }; // set to a new, not yet used object + } + }); BrowserMsg.listen(this.parentTabId); const setActiveWindow = this.setHandler(async () => { BrowserMsg.send.setActiveWindow(this.parentTabId, { frameId: this.frameId }); }); this.S.cached('body').on('focusin', setActiveWindow); @@ -197,7 +204,6 @@ export class ComposeView extends View { this.myPubkeyModule.setHandlers(); this.pwdOrPubkeyContainerModule.setHandlers(); this.sizeModule.setHandlers(); - this.storageModule.setHandlers(); this.recipientsModule.setHandlers(); this.sendBtnModule.setHandlers(); this.draftModule.setHandlers(); // must be the last one so that 'onRecipientAdded/draftSave' to works properly diff --git a/extension/chrome/settings/inbox/inbox.ts b/extension/chrome/settings/inbox/inbox.ts index c4b8f97ddfd..aa806c5abfd 100644 --- a/extension/chrome/settings/inbox/inbox.ts +++ b/extension/chrome/settings/inbox/inbox.ts @@ -34,6 +34,7 @@ export class InboxView extends View { public readonly labelId: string; public readonly threadId: string | undefined; public readonly showOriginal: boolean; + public readonly debug: boolean; public readonly S: SelCache; public readonly gmail: Gmail; @@ -45,11 +46,12 @@ export class InboxView extends View { constructor() { super(); - const uncheckedUrlParams = Url.parse(['acctEmail', 'labelId', 'threadId', 'showOriginal']); + const uncheckedUrlParams = Url.parse(['acctEmail', 'labelId', 'threadId', 'showOriginal', 'debug']); this.acctEmail = Assert.urlParamRequire.string(uncheckedUrlParams, 'acctEmail'); this.labelId = uncheckedUrlParams.labelId ? String(uncheckedUrlParams.labelId) : 'INBOX'; this.threadId = Assert.urlParamRequire.optionalString(uncheckedUrlParams, 'threadId'); this.showOriginal = uncheckedUrlParams.showOriginal === true; + this.debug = uncheckedUrlParams.debug === true; this.S = Ui.buildJquerySels({ threads: '.threads', thread: '.thread', body: 'body' }); this.gmail = new Gmail(this.acctEmail); this.inboxMenuModule = new InboxMenuModule(this); @@ -154,6 +156,12 @@ export class InboxView extends View { BrowserMsg.addListener('show_attachment_preview', async ({ iframeUrl }: Bm.ShowAttachmentPreview) => { await Ui.modal.attachmentPreview(iframeUrl); }); + if (this.debug) { + BrowserMsg.addListener('open_compose_window', async ({ draftId }: Bm.ComposeWindowOpenDraft) => { + console.log('received open_compose_window'); + this.injector.openComposeWin(draftId); + }); + } }; } diff --git a/extension/chrome/settings/modules/add_key.htm b/extension/chrome/settings/modules/add_key.htm index a0447b8e5fe..48e97fc1a69 100644 --- a/extension/chrome/settings/modules/add_key.htm +++ b/extension/chrome/settings/modules/add_key.htm @@ -26,9 +26,9 @@

Add Private Key

  • -
  • -
@@ -38,7 +38,7 @@

Add Private Key

- +
This key is unprotected. Create a pass phrase or => { if (typeof DATA[acct] === 'undefined') { - const acctData: AcctDataFile = { drafts: [], messages: [], attachments: {}, labels: [] }; + const acctData: AcctDataFile = { + drafts: [], messages: [], attachments: {}, labels: + [ + { id: 'INBOX', name: 'Inbox', messageListVisibility: 'show', labelListVisibility: 'labelShow', type: 'system' }, + { id: 'DRAFT', name: 'Drafts', messageListVisibility: 'show', labelListVisibility: 'labelShow', type: 'system' } + ] + }; const dir = GoogleData.exportedMsgsPath; const filenames: string[] = await new Promise((res, rej) => readdir(dir, (e, f) => e ? rej(e) : res(f))); const filePromises = filenames.map(f => new Promise((res, rej) => readFile(dir + f, (e, d) => e ? rej(e) : res(d)))); @@ -219,6 +225,10 @@ export class GoogleData { }); }; + public getMessagesAndDraftsByThread = (threadId: string) => { + return this.getMessagesAndDrafts().filter(m => m.threadId === threadId); + }; + public getMessagesByThread = (threadId: string) => { return DATA[this.acct].messages.filter(m => m.threadId === threadId); }; @@ -266,9 +276,11 @@ export class GoogleData { return DATA[this.acct].labels; }; - public getThreads = () => { + public getThreads = (labelIds: string[] = []) => { const threads: GmailThread[] = []; - for (const thread of DATA[this.acct].messages.map(m => ({ historyId: m.historyId, id: m.threadId!, snippet: `MOCK SNIPPET: ${GoogleData.msgSubject(m)}` }))) { + for (const thread of this.getMessagesAndDrafts(). + filter(m => labelIds.length ? (m.labelIds || []).some(l => labelIds.includes(l)) : true). + map(m => ({ historyId: m.historyId, id: m.threadId!, snippet: `MOCK SNIPPET: ${GoogleData.msgSubject(m)}` }))) { if (thread.id && !threads.map(t => t.id).includes(thread.id)) { threads.push(thread); } @@ -276,6 +288,11 @@ export class GoogleData { return threads; }; + // returns ordinary messages and drafts + private getMessagesAndDrafts = () => { + return DATA[this.acct].messages.concat(DATA[this.acct].drafts); + }; + private searchMessagesBySubject = (subject: string) => { subject = subject.trim().toLowerCase(); const messages = DATA[this.acct].messages.filter(m => GoogleData.msgSubject(m).toLowerCase().includes(subject)); diff --git a/test/source/mock/google/google-endpoints.ts b/test/source/mock/google/google-endpoints.ts index ad83c4832b9..043d9063611 100644 --- a/test/source/mock/google/google-endpoints.ts +++ b/test/source/mock/google/google-endpoints.ts @@ -173,10 +173,10 @@ export const mockGoogleEndpoints: HandlersDefinition = { } throw new HttpClientErr(`Method not implemented for ${req.url}: ${req.method}`); }, - '/gmail/v1/users/me/threads': async ({ }, req) => { + '/gmail/v1/users/me/threads': async (parsedReq, req) => { const acct = oauth.checkAuthorizationHeaderWithAccessToken(req.headers.authorization); if (isGet(req)) { - const threads = (await GoogleData.withInitializedData(acct)).getThreads(); + const threads = (await GoogleData.withInitializedData(acct)).getThreads([parsedReq.query.labelIds]); // todo: support arrays? return { threads, resultSizeEstimate: threads.length }; } throw new HttpClientErr(`Method not implemented for ${req.url}: ${req.method}`); @@ -185,7 +185,7 @@ export const mockGoogleEndpoints: HandlersDefinition = { const acct = oauth.checkAuthorizationHeaderWithAccessToken(req.headers.authorization); if (isGet(req) && (format === 'metadata' || format === 'full')) { const id = parseResourceId(req.url!); - const msgs = (await GoogleData.withInitializedData(acct)).getMessagesByThread(id); + const msgs = (await GoogleData.withInitializedData(acct)).getMessagesAndDraftsByThread(id); if (!msgs.length) { const statusCode = id === '16841ce0ce5cb74d' ? 404 : 400; // intentionally testing missing thread throw new HttpClientErr(`MOCK thread not found for ${acct}: ${id}`, statusCode); diff --git a/test/source/tests/compose.ts b/test/source/tests/compose.ts index 14fee16f8d6..253d36068a9 100644 --- a/test/source/tests/compose.ts +++ b/test/source/tests/compose.ts @@ -429,13 +429,7 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te const { inboxPage, replyFrame } = await setRequirePassPhraseAndOpenRepliedMessage(t, browser, pp); // Get Passphrase dialog and cancel confirm passphrase await inboxPage.waitAll('@dialog-passphrase'); - const passPhraseFrame = await inboxPage.getFrame(['passphrase.htm']); - if (inputMethod === 'mouse') { - await passPhraseFrame.waitAndClick('@action-cancel-pass-phrase-entry'); - } else if (inputMethod === 'keyboard') { - await inboxPage.press('Escape'); - } - await inboxPage.waitTillGone('@dialog'); + await ComposePageRecipe.cancelPassphraseDialog(inboxPage, inputMethod); await replyFrame.waitAll(['@action-expand-quoted-text']); const inputBody = await replyFrame.read('@input-body'); expect(inputBody.trim()).to.be.empty; @@ -445,7 +439,72 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te '> (Skipping previous message quote)' ].join('\n')); })); - } + + ava.default(`compose - pass phrase dialog - dialog cancel (${inputMethod})`, testWithBrowser('ci.tests.gmail', async (t, browser) => { + const k = Config.key('ci.tests.gmail'); + const acctEmail = 'ci.tests.gmail@flowcrypt.test'; + const settingsPage = await browser.newPage(t, TestUrls.extensionSettings(acctEmail)); + await SettingsPageRecipe.forgetAllPassPhrasesInStorage(settingsPage, k.passphrase); + const inboxPage = await browser.newPage(t, TestUrls.extensionInbox(acctEmail)); + const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage); + await ComposePageRecipe.fillMsg(composeFrame, { to: 'anyone@recipient.com' }, 'send signed-only message', undefined, { encrypt: false }); + await composeFrame.waitAndClick('@action-send', { delay: 2 }); + const passphraseDialog = await inboxPage.getFrame(['passphrase.htm']); + expect(passphraseDialog.frame.isDetached()).to.equal(false); + await Util.sleep(0.5); + expect(await composeFrame.read('@action-send')).to.eq('Signing...'); + await passphraseDialog.waitForContent('@passphrase-text', 'Enter FlowCrypt pass phrase to sign email'); + await ComposePageRecipe.cancelPassphraseDialog(inboxPage, inputMethod); + await Util.sleep(0.5); + expect(await composeFrame.read('@action-send')).to.eq('Sign and Send'); + })); + + ava.default(`compose - non-primary pass phrase dialog - dialog cancel (${inputMethod})`, testWithBrowser('ci.tests.gmail', async (t, browser) => { + const k = Config.key('ci.tests.gmail'); + const acctEmail = 'ci.tests.gmail@flowcrypt.test'; + const settingsPage = await browser.newPage(t, TestUrls.extensionSettings(acctEmail)); + const forgottenPassphrase = "i'll have to re-enter it"; + await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testKeyMultipleSmimeCEA2D53BB9D24871, forgottenPassphrase, {}, true); + await SettingsPageRecipe.forgetAllPassPhrasesInStorage(settingsPage, k.passphrase); + const inboxPage = await browser.newPage(t, TestUrls.extensionInbox(acctEmail)); + const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage); + await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime@recipient.com' }, 'S/MIME message', undefined); + await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime@recipient.com', + testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871); + await composeFrame.waitAndClick('@action-send', { delay: 2 }); + const passphraseDialog = await inboxPage.getFrame(['passphrase.htm']); + expect(passphraseDialog.frame.isDetached()).to.equal(false); + await Util.sleep(0.5); + expect(await composeFrame.read('@action-send')).to.eq('Loading...'); + await passphraseDialog.waitForContent('@passphrase-text', 'Enter FlowCrypt pass phrase to sign email'); + await passphraseDialog.waitForContent('@which-key', '47FB 0318 3E03 A8ED 44E3 BBFC CEA2 D53B B9D2 4871'); + await ComposePageRecipe.cancelPassphraseDialog(inboxPage, inputMethod); + await Util.sleep(0.5); + expect(await composeFrame.read('@action-send')).to.eq('Encrypt, Sign and Send'); + })); + } // end of tests per inputMethod + + ava.default(`compose - signed and encrypted S/MIME message - pass phrase dialog`, testWithBrowser('ci.tests.gmail', async (t, browser) => { + const k = Config.key('ci.tests.gmail'); + const acctEmail = 'ci.tests.gmail@flowcrypt.test'; + const settingsPage = await browser.newPage(t, TestUrls.extensionSettings(acctEmail)); + const forgottenPassphrase = "i'll have to re-enter it"; + await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testKeyMultipleSmimeCEA2D53BB9D24871, forgottenPassphrase, {}, true); + await SettingsPageRecipe.forgetAllPassPhrasesInStorage(settingsPage, k.passphrase); + const inboxPage = await browser.newPage(t, TestUrls.extensionInbox(acctEmail)); + const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage); + await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime@recipient.com' }, t.title, undefined); + await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime@recipient.com', + testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871); + await composeFrame.waitAndClick('@action-send', { delay: 2 }); + const passphraseDialog = await inboxPage.getFrame(['passphrase.htm']); + expect(passphraseDialog.frame.isDetached()).to.equal(false); + await passphraseDialog.waitForContent('@passphrase-text', 'Enter FlowCrypt pass phrase to sign email'); + await passphraseDialog.waitForContent('@which-key', '47FB 0318 3E03 A8ED 44E3 BBFC CEA2 D53B B9D2 4871'); + await passphraseDialog.waitAndType('@input-pass-phrase', forgottenPassphrase); + await passphraseDialog.waitAndClick('@action-confirm-pass-phrase-entry'); + await inboxPage.waitTillGone('@container-new-message'); + })); ava.default('compose - reply - signed message', testWithBrowser('compatibility', async (t, browser) => { const appendUrl = 'threadId=15f7f5face7101db&skipClickPrompt=___cu_false___&ignoreDraft=___cu_false___&replyMsgId=15f7f5face7101db'; @@ -635,6 +694,46 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te expect((await composePage.read('@input-body')).trim()).to.equal('test text'); })); + ava.default('compose - loading drafts - PKCS#7 encrypted draft with forgotten non-primary pass phrase', testWithBrowser(undefined, async (t, browser) => { + const acctEmail = 'flowcrypt.test.key.imported@gmail.com'; + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acctEmail); + await SetupPageRecipe.manualEnter(settingsPage, 'unused', + { + submitPubkey: false, + usedPgpBefore: false, + key: { + title: '?', + armored: testConstants.testKeyMultiple1b383d0334e38b28, + passphrase: '1234', + longid: '1b383d0334e38b28', + } + }, + { isSavePassphraseChecked: false, isSavePassphraseHidden: false }); + const forgottenPassphrase = 'this passphrase is forgotten'; + await SettingsPageRecipe.addKeyTestEx(t, browser, acctEmail, { filePath: 'test/samples/smime/human-unprotected-PKCS12.p12' }, forgottenPassphrase, {}, false); + const inboxPage = await browser.newPage(t, TestUrls.extensionInbox(acctEmail) + '&labelId=DRAFT&debug=___cu_true___'); + await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); + const inboxTabId = await PageRecipe.getTabId(inboxPage); + // send message from a different tab + await PageRecipe.sendMessage(settingsPage, { name: 'open_compose_window', data: { bm: { draftId: '17c041fd27858466' }, objUrls: {} }, to: inboxTabId, uid: '2' }); + await inboxPage.waitAll('@container-new-message'); + await Util.sleep(0.5); + const composeFrame = await inboxPage.getFrame(['compose.htm']); + await composeFrame.waitAndClick('@action-open-passphrase-dialog'); + const passphraseDialog = await inboxPage.getFrame(['passphrase.htm']); + await passphraseDialog.waitForSelTestState('ready'); + expect(await passphraseDialog.read('@passphrase-text')).to.equal('Enter FlowCrypt pass phrase to load a draft'); + const whichKeyText = await passphraseDialog.read('@which-key'); + expect(whichKeyText).to.include('9B5F CFF5 76A0 3249 5AFE 7780 5354 351B 39AB 3BC6'); + expect(whichKeyText).to.not.include('CB04 85FE 44FC 22FF 09AF 0DB3 1B38 3D03 34E3 8B28'); + await passphraseDialog.waitAndType('@input-pass-phrase', forgottenPassphrase); + await passphraseDialog.waitAndClick('@action-confirm-pass-phrase-entry'); + await composeFrame.waitForContent('@input-body', 'test text'); + await inboxPage.close(); + await settingsPage.close(); + })); + + // todo: load a draft encrypted by non-first key, enetering passphrase for it ava.default('compose - loading drafts - reply', testWithBrowser('compatibility', async (t, browser) => { const appendUrl = 'threadId=16cfa9001baaac0a&skipClickPrompt=___cu_false___&ignoreDraft=___cu_false___&replyMsgId=16cfa9001baaac0a&draftId=draft-3'; const composePage = await ComposePageRecipe.openStandalone(t, browser, 'compatibility', { appendUrl, hasReplyPrompt: true, skipClickPropt: true }); @@ -1057,7 +1156,7 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testKeyMultipleSmimeCEA2D53BB9D24871, passphrase, {}, false); const inboxPage = await browser.newPage(t, TestUrls.extensionInbox(acctEmail)); const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage); - await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime@recipient.com' }, 'send signed and encrypted S/MIME without attachment', + await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime@recipient.com' }, 'send signed and encrypted S/MIME message', 'This text should be encrypted into PKCS#7 data'); await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime@recipient.com', testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871); @@ -1065,6 +1164,25 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te await inboxPage.waitTillGone('@container-new-message'); })); + ava.default('send signed and encrypted S/MIME message entering a non-primary passphrase', testWithBrowser('compatibility', async (t, browser) => { + const acctEmail = 'flowcrypt.compatibility@gmail.com'; + const passphrase = 'pa$$w0rd'; + await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testKeyMultipleSmimeCEA2D53BB9D24871, passphrase, {}, false); + const inboxPage = await browser.newPage(t, TestUrls.extensionInbox(acctEmail)); + await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); + const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage); + await ComposePageRecipe.fillMsg(composeFrame, { to: 'smime@recipient.com' }, 'send signed and encrypted S/MIME message', + 'This text should be encrypted into PKCS#7 data'); + await ComposePageRecipe.pastePublicKeyManually(composeFrame, inboxPage, 'smime@recipient.com', + testConstants.testCertificateMultipleSmimeCEA2D53BB9D24871); + await composeFrame.waitAndClick('@action-send', { delay: 2 }); + const passphraseDialog = await inboxPage.getFrame(['passphrase.htm']); + await passphraseDialog.waitForContent('@which-key', '47FB 0318 3E03 A8ED 44E3 BBFC CEA2 D53B B9D2 4871'); + await passphraseDialog.waitAndType('@input-pass-phrase', passphrase); + await passphraseDialog.waitAndClick('@action-confirm-pass-phrase-entry'); + await inboxPage.waitTillGone('@container-new-message'); + })); + ava.default('send with single S/MIME cert', testWithBrowser('ci.tests.gmail', async (t, browser) => { const inboxPage = await browser.newPage(t, TestUrls.extensionInbox('ci.tests.gmail@flowcrypt.test')); const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage); diff --git a/test/source/tests/page-recipe/compose-page-recipe.ts b/test/source/tests/page-recipe/compose-page-recipe.ts index 136b2252368..c232b56ddc9 100644 --- a/test/source/tests/page-recipe/compose-page-recipe.ts +++ b/test/source/tests/page-recipe/compose-page-recipe.ts @@ -161,4 +161,15 @@ export class ComposePageRecipe extends PageRecipe { await ComposePageRecipe.pastePublicKeyManuallyNoClose(composeFrame, inboxPage, recipient, pub); await inboxPage.waitTillGone('@dialog-add-pubkey'); }; + + public static cancelPassphraseDialog = async (page: ControllablePage, inputMethod: 'mouse' | 'keyboard' | string) => { + const passPhraseFrame = await page.getFrame(['passphrase.htm']); + if (inputMethod === 'mouse') { + await passPhraseFrame.waitAndClick('@action-cancel-pass-phrase-entry'); + } else if (inputMethod === 'keyboard') { + await page.press('Escape'); + } + await page.waitTillGone('@dialog'); + expect(passPhraseFrame.frame.isDetached()).to.equal(true); + }; } diff --git a/test/source/tests/page-recipe/settings-page-recipe.ts b/test/source/tests/page-recipe/settings-page-recipe.ts index ceac1142b48..6d4c0adbe9c 100644 --- a/test/source/tests/page-recipe/settings-page-recipe.ts +++ b/test/source/tests/page-recipe/settings-page-recipe.ts @@ -4,12 +4,13 @@ import { Config, Util } from '../../util'; import { BrowserHandle, ControllableFrame, ControllablePage } from '../../browser'; import { PageRecipe } from './abstract-page-recipe'; -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { Str } from '../../core/common'; import { AvaContext } from '../tooling'; import { TestUrls } from '../../browser/test-urls'; import { Xss } from '../../platform/xss'; -import { KeyUtil } from '../../core/crypto/key'; +import { Key, KeyUtil } from '../../core/crypto/key'; +import { readFileSync } from 'fs'; export type SavePassphraseChecks = { isSavePassphraseHidden?: boolean | undefined, @@ -118,10 +119,35 @@ export class SettingsPageRecipe extends PageRecipe { passphrase: string, checks: SavePassphraseChecks = {}, savePassphrase = true + ) => { + return await SettingsPageRecipe.addKeyTestEx(t, browser, acctEmail, { armoredPrvKey }, passphrase, checks, savePassphrase); + }; + + public static addKeyTestEx = async ( + t: AvaContext, + browser: BrowserHandle, + acctEmail: string, + prvKey: { armoredPrvKey?: string, filePath?: string }, + passphrase: string, + checks: SavePassphraseChecks = {}, + savePassphrase = true ) => { const addPrvPage = await browser.newPage(t, `/chrome/settings/modules/add_key.htm?acctEmail=${Xss.escape(acctEmail)}&parent_tab_id=0`); - await addPrvPage.waitAndClick('#source_paste'); - await addPrvPage.waitAndType('.input_private_key', armoredPrvKey); + let key: Key | undefined; + if (prvKey.armoredPrvKey) { + await addPrvPage.waitAndClick('@source-paste'); + await addPrvPage.waitAndType('@input-armored-key', prvKey.armoredPrvKey); + key = await KeyUtil.parse(prvKey.armoredPrvKey); + } else if (prvKey.filePath) { + const [fileChooser] = await Promise.all([ + addPrvPage.page.waitForFileChooser(), + addPrvPage.waitAndClick('@source-file', { retryErrs: true })]); + await fileChooser.accept([prvKey.filePath]); + [key] = (await KeyUtil.readBinary(readFileSync(prvKey.filePath))).keys; + } else { + assert(false); + } + const fp = Str.spaced(Xss.escape(key!.id)); await addPrvPage.waitAndClick('#toggle_input_passphrase'); await addPrvPage.waitAndType('#input_passphrase', passphrase); if (checks.isSavePassphraseHidden !== undefined) { @@ -140,8 +166,6 @@ export class SettingsPageRecipe extends PageRecipe { await Util.sleep(1); const settingsPage = await browser.newPage(t, TestUrls.extensionSettings(acctEmail)); await SettingsPageRecipe.toggleScreen(settingsPage, 'additional'); - const key = await KeyUtil.parse(armoredPrvKey); - const fp = Str.spaced(Xss.escape(key.id)); await settingsPage.waitForContent('@container-settings-keys-list', fp); // confirm key successfully loaded await settingsPage.close(); };