diff --git a/app/renderer/js/clipboard-decrypter.ts b/app/renderer/js/clipboard-decrypter.ts new file mode 100644 index 00000000..adb2db4b --- /dev/null +++ b/app/renderer/js/clipboard-decrypter.ts @@ -0,0 +1,82 @@ +import {clipboard} from 'electron'; +import crypto from 'crypto'; + +// This helper is exposed via electron_bridge for use in the social +// login flow. +// +// It consists of a key and a promised token. The in-app page sends +// the key to the server, and opens the user’s browser to a page where +// they can log in and get a token encrypted to that key. When the +// user copies the encrypted token from their browser to the +// clipboard, we decrypt it and resolve the promise. The in-app page +// then uses the decrypted token to log the user in within the app. +// +// The encryption is authenticated (AES-GCM) to guarantee that we +// don’t leak anything from the user’s clipboard other than the token +// intended for us. + +export class ClipboardDecrypter { + version: number; + key: Uint8Array; + pasted: Promise; + + constructor(_: number) { + // At this time, the only version is 1. + this.version = 1; + this.key = crypto.randomBytes(32); + this.pasted = new Promise(resolve => { + let interval: NodeJS.Timeout | null = null; + const startPolling = () => { + if (interval === null) { + interval = setInterval(poll, 1000); + } + + poll(); + }; + + const stopPolling = () => { + if (interval !== null) { + clearInterval(interval); + interval = null; + } + }; + + const poll = () => { + let plaintext; + + try { + const data = Buffer.from(clipboard.readText(), 'hex'); + const iv = data.slice(0, 12); + const ciphertext = data.slice(12, -16); + const authTag = data.slice(-16); + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + this.key, + iv, + {authTagLength: 16} + ); + decipher.setAuthTag(authTag); + plaintext = + decipher.update(ciphertext, undefined, 'utf8') + + decipher.final('utf8'); + } catch (_) { + // If the parsing or decryption failed in any way, + // the correct token hasn’t been copied yet; try + // again next time. + return; + } + + window.removeEventListener('focus', startPolling); + window.removeEventListener('blur', stopPolling); + stopPolling(); + resolve(plaintext); + }; + + window.addEventListener('focus', startPolling); + window.addEventListener('blur', stopPolling); + if (document.hasFocus()) { + startPolling(); + } + }); + } +} diff --git a/app/renderer/js/electron-bridge.ts b/app/renderer/js/electron-bridge.ts index cf0cce38..5b05d7a6 100644 --- a/app/renderer/js/electron-bridge.ts +++ b/app/renderer/js/electron-bridge.ts @@ -2,6 +2,7 @@ import {ipcRenderer} from 'electron'; import {EventEmitter} from 'events'; +import {ClipboardDecrypter} from './clipboard-decrypter'; import {NotificationData, newNotification} from './notification'; type ListenerType = ((...args: any[]) => void); @@ -46,6 +47,9 @@ class ElectronBridge extends EventEmitter { set_send_notification_reply_message_supported = (value: boolean): void => { this.send_notification_reply_message_supported = value; }; + + decrypt_clipboard = (version: number): ClipboardDecrypter => + new ClipboardDecrypter(version); } const electron_bridge = new ElectronBridge();