Skip to content

Commit ac59d1f

Browse files
sosnovskymartgil
andauthored
#5667 Decrypt messages in Thunderbird (#5824)
* wip: refactor * wip: Register content script for Thunderbird * refactor: move Thunderbird event handler and content script registration to an appropriate Class * wip: update manifest.json * wip * wip * wip * wip * wip * wip * added functionality to obtain pgp message from a background script * fix openpgp undefined in content-script * wip * Add "thunderbird_get_current_user" to BrowserMsg * Add "thunderbird_msg_decrypt" to BrowserMsg * fix BrowserMsg listener names * wip * wip * fix missing opgp reference in Thunderbird port * Added pgp message decryption * fix failing test * run element replacer in heartbeat (runIntervalFunctionsPeriodically) * Improved decrypted message rendering in Thunderbird * Add cleartext signed message detection * wip * Add signature verification in Thunderbird (cleartext message) * wip * wip: Better signature verification handling * wip * wip * cleanup * Add passphrase dialog prompt in Thunderbird * Add autoclose for completely unlock passphrase in Thunderbird * add another case for encrypted message detection * fix unsafe assignment of an any value * better content script bundling * cleanup * Add todo in preparation for post thunderbird development * inject scripts to thunderbird * exclude sweetalert2 from thunderbird build * wip * wip * code improvements --------- Co-authored-by: martgil <[email protected]> Co-authored-by: Mart <[email protected]>
1 parent d6d2327 commit ac59d1f

File tree

12 files changed

+274
-47
lines changed

12 files changed

+274
-47
lines changed

extension/chrome/elements/passphrase.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,18 @@ View.run(
200200
BrowserMsg.send.passphraseEntry({ entered, initiatorFrameId });
201201
};
202202

203+
private closeDialogPageOpenedExternally = async () => {
204+
if (Catch.isThunderbirdMail() && window.top === window.self) {
205+
const currentTab = await messenger.tabs.query({ active: true, currentWindow: true });
206+
if (currentTab.length > 0) {
207+
const tabId = currentTab[0].id;
208+
if (tabId) {
209+
await messenger.tabs.remove(tabId);
210+
}
211+
}
212+
}
213+
};
214+
203215
private submitHandler = async () => {
204216
if (await this.bruteForceProtection.shouldDisablePassphraseCheck()) {
205217
return;
@@ -237,10 +249,12 @@ View.run(
237249
}
238250
if (unlockCount && allPrivateKeys.length > 1) {
239251
Ui.toast(`${unlockCount} of ${allPrivateKeys.length} keys ${unlockCount > 1 ? 'were' : 'was'} unlocked by this pass phrase`);
252+
await this.closeDialogPageOpenedExternally();
240253
}
241254
if (atLeastOneMatched) {
242255
await this.bruteForceProtection.passphraseCheckSucceed();
243256
this.closeDialog(true, this.initiatorFrameId);
257+
await this.closeDialogPageOpenedExternally();
244258
} else {
245259
await this.bruteForceProtection.passphraseCheckFailed();
246260
this.renderFailedEntryPpPrompt();

extension/css/cryptup.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1428,7 +1428,7 @@ td {
14281428
.backup_neutral {
14291429
border: none;
14301430
border-left: 4px solid #989898;
1431-
padding-left: 10px;
1431+
padding: 6px 10px;
14321432
}
14331433

14341434
.pgp_neutral .error_container {

extension/js/common/browser/browser-msg.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export namespace Bm {
9696
export type ReRenderRecipient = { email: string };
9797
export type PgpBlockRetry = { frameId: string; messageSender: Dest };
9898
export type PgpBlockReady = { frameId: string; messageSender: Dest };
99+
export type ThunderbirdOpenPassphraseDialog = { acctEmail: string; longids: string };
99100

100101
export namespace Res {
101102
export type GetActiveTabInfo = {
@@ -112,6 +113,9 @@ export namespace Bm {
112113
export type ExpirationCacheGet<V> = Promise<V | undefined>;
113114
export type ExpirationCacheSet = Promise<void>;
114115
export type ExpirationCacheDeleteExpired = Promise<void>;
116+
export type ThunderbirdGetCurrentUser = string | undefined;
117+
export type ThunderbirdMsgGet = messenger.messages.MessagePart | undefined;
118+
export type ThunderbirdOpenPassphraseDialog = Promise<void>;
115119
// eslint-disable-next-line @typescript-eslint/no-explicit-any
116120
export type Db = any; // not included in Any below
117121
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -128,7 +132,8 @@ export namespace Bm {
128132
| ExpirationCacheSet
129133
| ExpirationCacheDeleteExpired
130134
| AjaxGmailAttachmentGetChunk
131-
| ConfirmationResult;
135+
| ConfirmationResult
136+
| ThunderbirdMsgGet;
132137
}
133138

134139
export type AnyRequest =
@@ -169,6 +174,7 @@ export namespace Bm {
169174
| PgpBlockReady
170175
| PgpBlockRetry
171176
| ConfirmationResult
177+
| ThunderbirdOpenPassphraseDialog
172178
| Ajax;
173179

174180
export type AsyncRespondingHandler = (req: AnyRequest) => Promise<Res.Any>;
@@ -232,6 +238,11 @@ export class BrowserMsg {
232238
BrowserMsg.sendAwait(undefined, 'expirationCacheSet', bm, true) as Promise<Bm.Res.ExpirationCacheSet>,
233239
expirationCacheDeleteExpired: (bm: Bm.ExpirationCacheDeleteExpired) =>
234240
BrowserMsg.sendAwait(undefined, 'expirationCacheDeleteExpired', bm, true) as Promise<Bm.Res.ExpirationCacheDeleteExpired>,
241+
thunderbirdGetCurrentUser: () =>
242+
BrowserMsg.sendAwait(undefined, 'thunderbirdGetCurrentUser', undefined, true) as Promise<Bm.Res.ThunderbirdGetCurrentUser>,
243+
thunderbirdMsgGet: () => BrowserMsg.sendAwait(undefined, 'thunderbirdMsgGet', undefined, true) as Promise<Bm.Res.ThunderbirdMsgGet>,
244+
thunderbirdOpenPassphraseDiaglog: (bm: Bm.ThunderbirdOpenPassphraseDialog) =>
245+
BrowserMsg.sendAwait(undefined, 'thunderbirdOpenPassphraseDialog', bm, true) as Promise<Bm.Res.ThunderbirdOpenPassphraseDialog>,
235246
},
236247
},
237248
passphraseEntry: (bm: Bm.PassphraseEntry) => {

extension/js/common/browser/env.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export class Env {
6161
}
6262

6363
public static async webmails(): Promise<WebMailName[]> {
64-
return ['gmail']; // async because storage may be involved in the future
64+
return ['gmail', 'thunderbird']; // async because storage may be involved in the future
6565
}
6666

6767
public static getBaseUrl() {

extension/js/common/inject.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,11 @@ export class Injector {
5757
}
5858

5959
public meta = () => {
60-
this.S.cached('body')
61-
.addClass(`cryptup_${this.webmailName} cryptup_${this.webmailName}_${this.webmailVariant} ${Catch.browser().name}`)
62-
.append(this.factory.metaStylesheet('webmail') + this.factory.metaNotificationContainer()); // xss-safe-factory
60+
if (this.webmailName === 'gmail') {
61+
this.S.cached('body')
62+
.addClass(`cryptup_${this.webmailName} cryptup_${this.webmailName}_${this.webmailVariant} ${Catch.browser().name}`)
63+
.append(this.factory.metaStylesheet('webmail') + this.factory.metaNotificationContainer()); // xss-safe-factory
64+
}
6365
};
6466

6567
public openComposeWin = (draftId?: string, openInFullScreen?: boolean, thunderbirdMsgId?: number, replyOption?: string, replyMsgId?: string): boolean => {

extension/js/content_scripts/webmail/generic/setup-webmail-content-script.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export type WebmailVariantObject = {
3737
type WebmailSpecificInfo = {
3838
name: WebMailName;
3939
variant: WebmailVariantString;
40-
getUserAccountEmail: () => string | undefined;
40+
getUserAccountEmail: () => Promise<string> | string | undefined;
4141
getUserFullName: () => string | undefined;
4242
getReplacer: () => WebmailElementReplacer;
4343
start: (
@@ -74,7 +74,7 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi
7474
let acctEmailInterval = 1000;
7575
const webmails = await Env.webmails();
7676
while (true) {
77-
const acctEmail = webmailSpecific.getUserAccountEmail();
77+
const acctEmail = await webmailSpecific.getUserAccountEmail();
7878
if (typeof acctEmail !== 'undefined') {
7979
win.account_email_global = acctEmail;
8080
if (webmails.includes(webmailSpecific.name)) {
@@ -444,20 +444,22 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi
444444
}
445445
const acctEmail = await waitForAcctEmail();
446446
const { tabId, notifications, factory, inject } = await initInternalVars(acctEmail);
447-
await showNotificationsAndWaitTilAcctSetUp(acctEmail, notifications);
448-
Catch.setHandledTimeout(() => updateClientConfiguration(acctEmail), 0);
449447
const ppEvent: { entered?: boolean } = {};
450448
const relayManager = new RelayManager();
451-
browserMsgListen(acctEmail, tabId, inject, factory, notifications, relayManager, ppEvent);
452449
const clientConfiguration = await ClientConfiguration.newInstance(acctEmail);
453-
await startPullingKeysFromEkm(
454-
acctEmail,
455-
clientConfiguration,
456-
factory,
457-
ppEvent,
458-
notifications,
459-
Catch.try(() => notifyExpiringKeys(acctEmail, clientConfiguration, notifications))
460-
);
450+
if (webmailSpecific.name === 'gmail') {
451+
Catch.setHandledTimeout(() => updateClientConfiguration(acctEmail), 0);
452+
await showNotificationsAndWaitTilAcctSetUp(acctEmail, notifications);
453+
browserMsgListen(acctEmail, tabId, inject, factory, notifications, relayManager, ppEvent);
454+
await startPullingKeysFromEkm(
455+
acctEmail,
456+
clientConfiguration,
457+
factory,
458+
ppEvent,
459+
notifications,
460+
Catch.try(() => notifyExpiringKeys(acctEmail, clientConfiguration, notifications))
461+
);
462+
}
461463
await webmailSpecific.start(acctEmail, clientConfiguration, inject, notifications, factory, relayManager);
462464
} catch (e) {
463465
if (e instanceof TabIdRequiredError) {
@@ -509,7 +511,11 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi
509511
};
510512

511513
win.vacant = () => {
512-
return !$('.' + win.destroyable_class).length;
514+
if (Catch.isThunderbirdMail()) {
515+
return true;
516+
} else {
517+
return !$('.' + win.destroyable_class).length;
518+
}
513519
};
514520

515521
win.TrySetDestroyableInterval = (code, ms) => {

extension/js/content_scripts/webmail/thunderbird/thunderbird-element-replacer.ts

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,144 @@
22

33
'use strict';
44

5-
import { IntervalFunction, WebmailElementReplacer } from '../generic/webmail-element-replacer';
5+
import { BrowserMsg } from '../../../common/browser/browser-msg.js';
6+
import { KeyUtil } from '../../../common/core/crypto/key.js';
7+
import { DecryptError, DecryptErrTypes, MsgUtil } from '../../../common/core/crypto/pgp/msg-util.js';
8+
import { OpenPGPKey } from '../../../common/core/crypto/pgp/openpgp-key.js';
9+
import { PgpArmor } from '../../../common/core/crypto/pgp/pgp-armor';
10+
import { Catch } from '../../../common/platform/catch';
11+
import { ContactStore } from '../../../common/platform/store/contact-store.js';
12+
import { KeyStore } from '../../../common/platform/store/key-store.js';
13+
import { Xss } from '../../../common/platform/xss.js';
14+
import { IntervalFunction, WebmailElementReplacer } from '../generic/webmail-element-replacer.js';
15+
import * as openpgp from 'openpgp';
616

717
export class ThunderbirdElementReplacer extends WebmailElementReplacer {
8-
public getIntervalFunctions: () => IntervalFunction[];
918
public setReplyBoxEditable: () => Promise<void>;
1019
public reinsertReplyBox: (replyMsgId: string) => void;
1120
public scrollToReplyBox: (replyMsgId: string) => void;
1221
public scrollToCursorInReplyBox: (replyMsgId: string, cursorOffsetTop: number) => void;
22+
private emailBodyFromThunderbirdMail: string;
23+
24+
public getIntervalFunctions = (): IntervalFunction[] => {
25+
return [{ interval: 2000, handler: () => this.replaceThunderbirdMsgPane() }];
26+
};
27+
28+
public replaceThunderbirdMsgPane = async () => {
29+
if (Catch.isThunderbirdMail()) {
30+
const fullMsg = await BrowserMsg.send.bg.await.thunderbirdMsgGet();
31+
if (!fullMsg) {
32+
return;
33+
} else {
34+
const acctEmail = await BrowserMsg.send.bg.await.thunderbirdGetCurrentUser();
35+
const parsedPubs = (await ContactStore.getOneWithAllPubkeys(undefined, String(acctEmail)))?.sortedPubkeys ?? [];
36+
const signerKeys = parsedPubs.map(key => KeyUtil.armor(key.pubkey));
37+
if (this.isPublicKeyEncryptedMsg(fullMsg)) {
38+
const result = await MsgUtil.decryptMessage({
39+
kisWithPp: await KeyStore.getAllWithOptionalPassPhrase(String(acctEmail)),
40+
encryptedData: this.emailBodyFromThunderbirdMail,
41+
verificationPubs: signerKeys,
42+
});
43+
if (result.success && result.content) {
44+
const decryptedMsg = result.content.toUtfStr();
45+
const encryptionStatus = result.isEncrypted ? 'encrypted' : 'not encrypted';
46+
let verificationStatus = '';
47+
if (result?.signature) {
48+
if (result.signature.match) {
49+
verificationStatus = 'signed';
50+
} else if (result.signature.error) {
51+
verificationStatus = `could not verify signature: ${result.signature.error}`;
52+
} else {
53+
verificationStatus = 'not signed';
54+
}
55+
}
56+
const pgpBlock = this.generatePgpBlockTemplate(encryptionStatus, verificationStatus, decryptedMsg);
57+
$('body').html(pgpBlock); // xss-sanitized
58+
} else {
59+
const decryptErr = result as DecryptError;
60+
let decryptionErrorMsg = '';
61+
if (decryptErr.error && decryptErr.error.type === DecryptErrTypes.needPassphrase) {
62+
const acctEmail = String(await BrowserMsg.send.bg.await.thunderbirdGetCurrentUser());
63+
const longids = decryptErr.longids.needPassphrase.join(',');
64+
decryptionErrorMsg = `decrypt error: private key needs to be unlocked by your passphrase.`;
65+
await BrowserMsg.send.bg.await.thunderbirdOpenPassphraseDiaglog({ acctEmail, longids });
66+
} else {
67+
decryptionErrorMsg = `decrypt error: ${(result as DecryptError).error.message}`;
68+
}
69+
const pgpBlock = this.generatePgpBlockTemplate(decryptionErrorMsg, 'not signed', this.emailBodyFromThunderbirdMail);
70+
$('body').html(pgpBlock); // xss-sanitized
71+
}
72+
} else if (this.isCleartextMsg(fullMsg)) {
73+
const message = await openpgp.readCleartextMessage({ cleartextMessage: this.emailBodyFromThunderbirdMail });
74+
const result = await OpenPGPKey.verify(message, await ContactStore.getPubkeyInfos(undefined, signerKeys));
75+
let verificationStatus = '';
76+
let signedMessage = '';
77+
if (result.match && result.content) {
78+
verificationStatus = 'signed';
79+
signedMessage = result.content.toUtfStr();
80+
} else if (result.error) {
81+
verificationStatus = `could not verify signature: ${result.error}`;
82+
}
83+
const pgpBlock = this.generatePgpBlockTemplate('not encrypted', verificationStatus, signedMessage);
84+
$('body').html(pgpBlock); // xss-sanitized
85+
}
86+
// todo: detached signed message via https://github.com/FlowCrypt/flowcrypt-browser/issues/5668
87+
}
88+
}
89+
};
90+
91+
private generatePgpBlockTemplate = (encryptionStatus: string, verificationStatus: string, messageToRender: string): string => {
92+
return `
93+
<div ${encryptionStatus === 'encrypted' ? 'class="pgp_secure"' : 'class="pgp_neutral"'}>
94+
<div>
95+
<div id="pgp_encryption" class="pgp_badge short ${encryptionStatus === 'encrypted' ? 'green_label' : 'red_label'}">${encryptionStatus}</div>
96+
<div id="pgp_signature" class="pgp_badge short ${verificationStatus === 'signed' ? 'green_label' : 'red_label'}">${verificationStatus}</div>
97+
</div>
98+
<div class="pgp_block">
99+
<pre>${Xss.escape(messageToRender)}</pre>
100+
</div>
101+
</div>`;
102+
};
103+
104+
private isCleartextMsg = (fullMsg: messenger.messages.MessagePart): boolean => {
105+
return (
106+
(fullMsg.headers &&
107+
'openpgp' in fullMsg.headers &&
108+
fullMsg.parts &&
109+
fullMsg.parts[0]?.parts?.length === 1 &&
110+
fullMsg.parts[0].parts[0].contentType === 'text/plain' &&
111+
this.resemblesCleartextMsg(fullMsg.parts[0].parts[0].body?.trim() || '')) ||
112+
false
113+
);
114+
};
115+
116+
private resemblesCleartextMsg = (body: string) => {
117+
this.emailBodyFromThunderbirdMail = body;
118+
return (
119+
body.startsWith(PgpArmor.ARMOR_HEADER_DICT.signedMsg.begin) &&
120+
body.includes(String(PgpArmor.ARMOR_HEADER_DICT.signedMsg.middle)) &&
121+
body.endsWith(String(PgpArmor.ARMOR_HEADER_DICT.signedMsg.end))
122+
);
123+
};
124+
125+
private isPublicKeyEncryptedMsg = (fullMsg: messenger.messages.MessagePart): boolean => {
126+
if (fullMsg.headers && 'openpgp' in fullMsg.headers && fullMsg.parts) {
127+
return (
128+
(fullMsg.parts[0]?.parts?.length === 2 &&
129+
fullMsg.parts[0]?.parts[1].contentType === 'application/pgp-encrypted' &&
130+
this.resemblesAsciiArmoredMsg(fullMsg.parts[0]?.parts[0].body?.trim() || '')) ||
131+
(fullMsg.parts[0]?.parts?.length === 1 &&
132+
fullMsg.parts[0]?.contentType === 'multipart/mixed' &&
133+
this.resemblesAsciiArmoredMsg(fullMsg.parts[0]?.parts[0].body?.trim() || '')) ||
134+
(fullMsg.parts.length === 1 && fullMsg.parts[0]?.contentType === 'text/plain' && this.resemblesAsciiArmoredMsg(fullMsg.parts[0]?.body?.trim() || '')) ||
135+
false
136+
);
137+
}
138+
return false;
139+
};
140+
141+
private resemblesAsciiArmoredMsg = (body: string): boolean => {
142+
this.emailBodyFromThunderbirdMail = body;
143+
return body.startsWith(PgpArmor.ARMOR_HEADER_DICT.encryptedMsg.begin) && body.endsWith(PgpArmor.ARMOR_HEADER_DICT.encryptedMsg.end as string);
144+
};
13145
}
Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,29 @@
11
/* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact [email protected] */
2-
import { ClientConfiguration } from '../../../common/client-configuration';
3-
import { Injector } from '../../../common/inject';
4-
import { Notifications } from '../../../common/notifications';
2+
import { BrowserMsg } from '../../../common/browser/browser-msg';
53
import { contentScriptSetupIfVacant } from '../generic/setup-webmail-content-script';
6-
import { GmailElementReplacer } from '../gmail/gmail-element-replacer';
74
import { ThunderbirdElementReplacer } from './thunderbird-element-replacer';
85

96
export class ThunderbirdWebmailStartup {
10-
private replacer: GmailElementReplacer;
7+
private replacer: ThunderbirdElementReplacer;
118

129
public asyncConstructor = async () => {
1310
await contentScriptSetupIfVacant({
1411
name: 'thunderbird',
1512
variant: undefined,
16-
getUserAccountEmail: () => undefined, // todo, but can start with undefined
13+
getUserAccountEmail: async () => String(await BrowserMsg.send.bg.await.thunderbirdGetCurrentUser()),
1714
getUserFullName: () => undefined, // todo, but can start with undefined
18-
getReplacer: () => new ThunderbirdElementReplacer(), // todo - add this class empty, methods do nothing
15+
getReplacer: () => this.replacer,
1916
start: this.start,
2017
});
2118
};
2219

23-
private start = async (
24-
acctEmail: string,
25-
clientConfiguration: ClientConfiguration,
26-
injector: Injector,
27-
notifications: Notifications
28-
// factory: XssSafeFactory, // todo in another issue
29-
// relayManager: RelayManager // todo in another issue
30-
) => {
31-
// injector.btns(); // todo in another issue - add compose button
20+
private start = async () => {
21+
this.replacer = new ThunderbirdElementReplacer();
3222
this.replacer.runIntervalFunctionsPeriodically();
33-
await notifications.showInitial(acctEmail);
34-
notifications.show(
35-
'FlowCrypt Thunderbird support is still in early development, and not expected to function properly yet. Support will be gradually added in upcoming versions.'
36-
);
23+
// todo: show notification using Thunderbird Notification as contentscript notification or such does not work.
24+
// await notifications.showInitial(acctEmail);
25+
// notifications.show(
26+
// 'FlowCrypt Thunderbird support is still in early development, and not expected to function properly yet. Support will be gradually added in upcoming versions.'
27+
// );
3728
};
3829
}

extension/js/content_scripts/webmail/webmail.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ declare global {
1717

1818
Catch.try(async () => {
1919
// when we support more webmails, there will be if/else here to figure out which one to run
20-
const browserName = Catch.browser().name;
21-
if (browserName === 'thunderbird') {
20+
if (Catch.isThunderbirdMail()) {
2221
await new ThunderbirdWebmailStartup().asyncConstructor();
2322
} else {
2423
await new GmailWebmailStartup().asyncConstructor();

extension/js/service_worker/background.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,11 @@ console.info('background.js service worker starting');
7070
await BgHandlers.updateUninstallUrl({});
7171
injectFcIntoWebmail();
7272

73-
// Thunderbird event handlers
7473
if (Catch.isThunderbirdMail()) {
75-
BrowserMsg.thunderbirdSecureComposeHandler();
74+
BgHandlers.thunderbirdSecureComposeHandler();
75+
await BgHandlers.thunderbirdContentScriptRegistration();
76+
BrowserMsg.bgAddListener('thunderbirdGetCurrentUser', BgHandlers.thunderbirdGetCurrentUserHandler);
77+
BrowserMsg.bgAddListener('thunderbirdMsgGet', BgHandlers.thunderbirdMsgGetHandler);
78+
BrowserMsg.bgAddListener('thunderbirdOpenPassphraseDialog', BgHandlers.thunderbirdOpenPassphraseDialog);
7679
}
7780
})().catch(Catch.reportErr);

0 commit comments

Comments
 (0)