diff --git a/package-lock.json b/package-lock.json index 6a8db88c156..fd6c462d7d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -777,6 +777,12 @@ "integrity": "sha1-wFTor02d11205jq8dviFFocU1LM=", "dev": true }, + "@types/firefox-webext-browser": { + "version": "78.0.1", + "resolved": "https://registry.npmjs.org/@types/firefox-webext-browser/-/firefox-webext-browser-78.0.1.tgz", + "integrity": "sha512-0d7oiI9K6Y4efP4Crl3JB88zYl7vaRdLtumqz8v6axMF8RCnK0NaGUjL4DnyQ7GLPo98b+s0BSRalaxAXgvPAQ==", + "dev": true + }, "@types/jasmine": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-3.3.12.tgz", diff --git a/package.json b/package.json index 1a6e07884f0..113bccba15a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@angular/compiler-cli": "^9.1.12", "@ngtools/webpack": "^9.1.12", "@types/chrome": "^0.0.73", + "@types/firefox-webext-browser": "^78.0.1", "@types/jasmine": "^3.3.12", "@types/lunr": "^2.3.3", "@types/mousetrap": "^1.6.0", @@ -48,7 +49,6 @@ "cross-env": "^5.2.0", "css-loader": "^1.0.0", "del": "^3.0.0", - "mini-css-extract-plugin": "^0.9.0", "file-loader": "^2.0.0", "gulp": "^4.0.0", "gulp-filter": "^5.1.0", @@ -68,6 +68,7 @@ "karma-jasmine": "^2.0.1", "karma-jasmine-html-reporter": "^1.4.0", "karma-typescript": "^4.0.0", + "mini-css-extract-plugin": "^0.9.0", "node-sass": "^4.13.1", "sass-loader": "^7.1.0", "style-loader": "^0.23.0", diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index bc7485908c3..65c3ed7464d 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -1250,6 +1250,15 @@ "yourVaultIsLockedPinCode": { "message": "Your vault is locked. Verify your PIN code to continue." }, + "unlockWithBiometrics": { + "message": "Unlock with biometrics" + }, + "awaitDesktop": { + "message": "Awaiting confirmation from desktop" + }, + "awaitDesktopDesc": { + "message": "Please confirm using biometrics in the Bitwarden Desktop application to enable biometrics for browser." + }, "lockWithMasterPassOnRestart": { "message": "Lock with master password on browser restart" }, @@ -1368,5 +1377,38 @@ }, "privacyPolicy": { "message": "Privacy Policy" + }, + "ok": { + "message": "Ok" + }, + "desktopSyncVerificationTitle": { + "message": "Desktop sync verification" + }, + "desktopIntegrationVerificationText": { + "message": "Please verify that the desktop application shows this fingerprint: " + }, + "desktopIntegrationDisabledTitle": { + "message": "Browser integration is not enabled" + }, + "desktopIntegrationDisabledDesc": { + "message": "Browser integration is not enabled in the Bitwarden Desktop application. Please enable it in the settings within the desktop application." + }, + "startDesktopTitle": { + "message": "Start the Bitwarden Desktop application" + }, + "startDesktopDesc": { + "message": "The Bitwarden Desktop application needs to be started before this function can be used." + }, + "errorEnableBiometricTitle": { + "message": "Unable to enable biometrics" + }, + "errorEnableBiometricDesc": { + "message": "Action was canceled by the desktop application" + }, + "nativeMessagingInvalidEncryptionDesc": { + "message": "Desktop application invalidated the secure communication channel. Please retry this operation" + }, + "nativeMessagingInvalidEncryptionTitle": { + "message": "Desktop communication interupted" } } diff --git a/src/background/contextMenus.background.ts b/src/background/contextMenus.background.ts index fb335c879f4..7a34acd659e 100644 --- a/src/background/contextMenus.background.ts +++ b/src/background/contextMenus.background.ts @@ -55,8 +55,8 @@ export default class ContextMenusBackground { private async cipherAction(info: any) { const id = info.menuItemId.split('_')[1]; if (id === 'noop') { - if (chrome.browserAction && chrome.browserAction.openPopup) { - chrome.browserAction.openPopup(); + if (chrome.browserAction && (chrome.browserAction as any).openPopup) { + (chrome.browserAction as any).openPopup(); } return; } diff --git a/src/background/main.background.ts b/src/background/main.background.ts index 006a4ccebfc..6de29fa43a2 100644 --- a/src/background/main.background.ts +++ b/src/background/main.background.ts @@ -71,6 +71,7 @@ import { SafariApp } from '../browser/safariApp'; import CommandsBackground from './commands.background'; import ContextMenusBackground from './contextMenus.background'; import IdleBackground from './idle.background'; +import { NativeMessagingBackground } from './nativeMessaging.background'; import RuntimeBackground from './runtime.background'; import TabsBackground from './tabs.background'; import WebRequestBackground from './webRequest.background'; @@ -140,6 +141,7 @@ export default class MainBackground { private menuOptionsLoaded: any[] = []; private syncTimeout: any; private isSafari: boolean; + private nativeMessagingBackground: NativeMessagingBackground; constructor() { // Services @@ -149,6 +151,19 @@ export default class MainBackground { if (this.systemService != null) { this.systemService.clearClipboard(clipboardValue, clearMs); } + }, + async () => { + if (this.nativeMessagingBackground != null) { + const promise = this.nativeMessagingBackground.getResponse(); + + try { + await this.nativeMessagingBackground.send({command: 'biometricUnlock'}); + } catch (e) { + return Promise.reject(e); + } + + return promise.then((result) => result.response === 'unlocked'); + } }); this.storageService = new BrowserStorageService(this.platformUtilsService); this.secureStorageService = new BrowserStorageService(this.platformUtilsService); @@ -229,6 +244,8 @@ export default class MainBackground { this.platformUtilsService as BrowserPlatformUtilsService, this.storageService, this.i18nService, this.analytics, this.notificationsService, this.systemService, this.vaultTimeoutService, this.environmentService); + this.nativeMessagingBackground = new NativeMessagingBackground(this.storageService, this.cryptoService, this.cryptoFunctionService, + this.vaultTimeoutService, this.runtimeBackground, this.i18nService, this.userService, this.messagingService); this.commandsBackground = new CommandsBackground(this, this.passwordGenerationService, this.platformUtilsService, this.analytics, this.vaultTimeoutService); diff --git a/src/background/nativeMessaging.background.ts b/src/background/nativeMessaging.background.ts new file mode 100644 index 00000000000..88397c05389 --- /dev/null +++ b/src/background/nativeMessaging.background.ts @@ -0,0 +1,198 @@ +import { ConstantsService } from 'jslib/services/constants.service'; +import { CryptoFunctionService } from 'jslib/abstractions/cryptoFunction.service'; +import { CryptoService } from 'jslib/abstractions/crypto.service'; +import { I18nService } from 'jslib/abstractions/i18n.service'; +import { MessagingService } from 'jslib/abstractions/messaging.service'; +import { StorageService } from 'jslib/abstractions/storage.service'; +import { UserService } from 'jslib/abstractions/user.service'; +import { VaultTimeoutService } from 'jslib/abstractions/vaultTimeout.service'; + +import { Utils } from 'jslib/misc/utils'; +import { SymmetricCryptoKey } from 'jslib/models/domain'; + +import { BrowserApi } from '../browser/browserApi'; +import RuntimeBackground from './runtime.background'; + +const MessageValidTimeout = 10 * 1000; +const EncryptionAlgorithm = 'sha1'; + +export class NativeMessagingBackground { + private connected = false; + private connecting: boolean; + private port: browser.runtime.Port | chrome.runtime.Port; + + private resolver: any = null; + private privateKey: ArrayBuffer = null; + private secureSetupResolve: any = null; + private sharedSecret: SymmetricCryptoKey; + + constructor(private storageService: StorageService, private cryptoService: CryptoService, + private cryptoFunctionService: CryptoFunctionService, private vaultTimeoutService: VaultTimeoutService, + private runtimeBackground: RuntimeBackground, private i18nService: I18nService, private userService: UserService, + private messagingService: MessagingService) {} + + async connect() { + return new Promise((resolve, reject) => { + this.port = BrowserApi.connectNative('com.8bit.bitwarden'); + + this.connecting = true; + + this.port.onMessage.addListener(async (message: any) => { + switch (message.command) { + case 'connected': + this.connected = true; + this.connecting = false; + resolve(); + break; + case 'disconnected': + if (this.connecting) { + this.messagingService.send('showDialog', { + text: this.i18nService.t('startDesktopDesc'), + title: this.i18nService.t('startDesktopTitle'), + confirmText: this.i18nService.t('ok'), + type: 'error', + }); + reject(); + } + this.connected = false; + this.port.disconnect(); + break; + case 'setupEncryption': + const encrypted = Utils.fromB64ToArray(message.sharedSecret); + const decrypted = await this.cryptoFunctionService.rsaDecrypt(encrypted.buffer, this.privateKey, EncryptionAlgorithm); + + this.sharedSecret = new SymmetricCryptoKey(decrypted); + this.secureSetupResolve(); + break; + case 'invalidateEncryption': + this.sharedSecret = null; + this.privateKey = null; + this.connected = false; + + this.messagingService.send('showDialog', { + text: this.i18nService.t('nativeMessagingInvalidEncryptionDesc'), + title: this.i18nService.t('nativeMessagingInvalidEncryptionTitle'), + confirmText: this.i18nService.t('ok'), + type: 'error', + }); + default: + this.onMessage(message); + } + }); + + this.port.onDisconnect.addListener((p: any) => { + let error; + if (BrowserApi.isWebExtensionsApi) { + error = p.error.message; + } else { + error = chrome.runtime.lastError.message; + } + + if (error === 'Specified native messaging host not found.' || + error === 'Access to the specified native messaging host is forbidden.' || + error === 'An unexpected error occurred') { + this.messagingService.send('showDialog', { + text: this.i18nService.t('desktopIntegrationDisabledDesc'), + title: this.i18nService.t('desktopIntegrationDisabledTitle'), + confirmText: this.i18nService.t('ok'), + type: 'error', + }); + } + this.sharedSecret = null; + this.privateKey = null; + this.connected = false; + reject(); + }); + }); + } + + async send(message: any) { + if (!this.connected) { + await this.connect(); + } + + if (this.sharedSecret == null) { + await this.secureCommunication(); + } + + message.timestamp = Date.now(); + + const encrypted = await this.cryptoService.encrypt(JSON.stringify(message), this.sharedSecret); + this.port.postMessage(encrypted); + } + + getResponse(): Promise { + return new Promise((resolve, reject) => { + this.resolver = resolve; + }); + } + + private async onMessage(rawMessage: any) { + const message = JSON.parse(await this.cryptoService.decryptToUtf8(rawMessage, this.sharedSecret)); + + if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) { + // tslint:disable-next-line + console.error('NativeMessage is to old, ignoring.'); + return; + } + + switch (message.command) { + case 'biometricUnlock': + await this.storageService.remove(ConstantsService.biometricAwaitingAcceptance); + + const enabled = await this.storageService.get(ConstantsService.biometricUnlockKey); + if (enabled === null || enabled === false) { + if (message.response === 'unlocked') { + await this.storageService.save(ConstantsService.biometricUnlockKey, true); + } + break; + } + + // Ignore unlock if already unlockeded + if (!this.vaultTimeoutService.biometricLocked) { + break; + } + + if (message.response === 'unlocked') { + this.cryptoService.setKey(new SymmetricCryptoKey(Utils.fromB64ToArray(message.keyB64).buffer)); + this.vaultTimeoutService.biometricLocked = false; + this.runtimeBackground.processMessage({command: 'unlocked'}, null, null); + } + break; + default: + // tslint:disable-next-line + console.error('NativeMessage, got unknown command: ', message.command); + } + + if (this.resolver) { + this.resolver(message); + } + } + + private async secureCommunication() { + const [publicKey, privateKey] = await this.cryptoFunctionService.rsaGenerateKeyPair(2048); + this.privateKey = privateKey; + + this.sendUnencrypted({command: 'setupEncryption', publicKey: Utils.fromBufferToB64(publicKey)}); + const fingerprint = (await this.cryptoService.getFingerprint(await this.userService.getUserId(), publicKey)).join(' '); + + this.messagingService.send('showDialog', { + html: `${this.i18nService.t('desktopIntegrationVerificationText')}

${fingerprint}`, + title: this.i18nService.t('desktopSyncVerificationTitle'), + confirmText: this.i18nService.t('ok'), + type: 'warning', + }); + + return new Promise((resolve, reject) => this.secureSetupResolve = resolve); + } + + private async sendUnencrypted(message: any) { + if (!this.connected) { + await this.connect(); + } + + message.timestamp = Date.now(); + + this.port.postMessage(message); + } +} diff --git a/src/background/runtime.background.ts b/src/background/runtime.background.ts index 09b946036ad..685b4a5dff4 100644 --- a/src/background/runtime.background.ts +++ b/src/background/runtime.background.ts @@ -22,6 +22,7 @@ import { VaultTimeoutService } from 'jslib/abstractions/vaultTimeout.service'; import { BrowserApi } from '../browser/browserApi'; import MainBackground from './main.background'; +import { NativeMessagingBackground } from './nativeMessaging.background'; import { Analytics } from 'jslib/misc'; import { Utils } from 'jslib/misc/utils'; diff --git a/src/browser/browserApi.ts b/src/browser/browserApi.ts index 2e4a7f2d039..1ee54a45b18 100644 --- a/src/browser/browserApi.ts +++ b/src/browser/browserApi.ts @@ -221,4 +221,12 @@ export class BrowserApi { }); } } + + static connectNative(application: string): browser.runtime.Port | chrome.runtime.Port { + if (BrowserApi.isWebExtensionsApi) { + return browser.runtime.connectNative(application); + } else if (BrowserApi.isChromeApi) { + return chrome.runtime.connectNative(application); + } + } } diff --git a/src/globals.d.ts b/src/globals.d.ts index 2868ff5ae7a..120d1194340 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -1,6 +1,4 @@ declare function escape(s: string): string; declare function unescape(s: string): string; declare var opr: any; -declare var chrome: any; -declare var browser: any; declare var safari: any; diff --git a/src/manifest.json b/src/manifest.json index 4ea0809bce4..d05030f072c 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -89,7 +89,8 @@ "http://*/*", "https://*/*", "webRequest", - "webRequestBlocking" + "webRequestBlocking", + "nativeMessaging" ], "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", "commands": { diff --git a/src/popup/accounts/lock.component.html b/src/popup/accounts/lock.component.html index e909d91e219..1a40769500b 100644 --- a/src/popup/accounts/lock.component.html +++ b/src/popup/accounts/lock.component.html @@ -36,6 +36,11 @@ {{'loggedInAsOn' | i18n : email : webVaultHostname}} +
+ +

{{'logOut' | i18n}}

diff --git a/src/popup/accounts/lock.component.ts b/src/popup/accounts/lock.component.ts index ed7bc001089..3920e6bcf22 100644 --- a/src/popup/accounts/lock.component.ts +++ b/src/popup/accounts/lock.component.ts @@ -13,6 +13,7 @@ import { UserService } from 'jslib/abstractions/user.service'; import { VaultTimeoutService } from 'jslib/abstractions/vaultTimeout.service'; import { LockComponent as BaseLockComponent } from 'jslib/angular/components/lock.component'; +import Swal from 'sweetalert2'; @Component({ selector: 'app-lock', @@ -36,4 +37,26 @@ export class LockComponent extends BaseLockComponent { document.getElementById(this.pinLock ? 'pin' : 'masterPassword').focus(); }, 100); } + + async unlockBiometric() { + if (!this.biometricLock) { + return; + } + + const div = document.createElement('div'); + div.innerHTML = `
${this.i18nService.t('awaitDesktop')}
`; + + Swal.fire({ + heightAuto: false, + buttonsStyling: false, + html: div, + showCancelButton: true, + cancelButtonText: this.i18nService.t('cancel'), + showConfirmButton: false, + }); + + await super.unlockBiometric(); + + Swal.close(); + } } diff --git a/src/popup/app.component.ts b/src/popup/app.component.ts index 1daee7f854c..5e10d08ecb3 100644 --- a/src/popup/app.component.ts +++ b/src/popup/app.component.ts @@ -241,6 +241,7 @@ export class AppComponent implements OnInit { icon: type as SweetAlertIcon, // required to be any of the SweetAlertIcons to output the iconHtml. iconHtml: iconClasses != null ? `` : undefined, text: msg.text, + html: msg.html, title: msg.title, showCancelButton: (cancelText != null), cancelButtonText: cancelText, diff --git a/src/popup/settings/settings.component.html b/src/popup/settings/settings.component.html index 61e8d7088dd..fc1eeedfe79 100644 --- a/src/popup/settings/settings.component.html +++ b/src/popup/settings/settings.component.html @@ -42,6 +42,10 @@ +
+ + +
{{'lockNow' | i18n}}
diff --git a/src/popup/settings/settings.component.ts b/src/popup/settings/settings.component.ts index a831a994bfa..0cf3972dcec 100644 --- a/src/popup/settings/settings.component.ts +++ b/src/popup/settings/settings.component.ts @@ -51,6 +51,7 @@ export class SettingsComponent implements OnInit { vaultTimeoutActions: any[]; vaultTimeoutAction: string; pin: boolean = null; + biometric: boolean = false; previousVaultTimeout: number = null; constructor(private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, @@ -100,6 +101,7 @@ export class SettingsComponent implements OnInit { const pinSet = await this.vaultTimeoutService.isPinLockSet(); this.pin = pinSet[0] || pinSet[1]; + this.biometric = await this.vaultTimeoutService.isBiometricLockSet(); } async saveVaultTimeout(newValue: number) { @@ -204,6 +206,49 @@ export class SettingsComponent implements OnInit { } } + async updateBiometric() { + if (this.biometric) { + const submitted = Swal.fire({ + heightAuto: false, + buttonsStyling: false, + title: this.i18nService.t('awaitDesktop'), + text: this.i18nService.t('awaitDesktopDesc'), + icon: 'info', + iconHtml: '', + showCancelButton: true, + cancelButtonText: this.i18nService.t('cancel'), + showConfirmButton: false, + allowOutsideClick: false, + }); + + await this.storageService.save(ConstantsService.biometricAwaitingAcceptance, true); + await this.cryptoService.toggleKey(); + + await Promise.race([ + submitted.then((result) => { + if (result.dismiss === Swal.DismissReason.cancel) { + this.biometric = false; + this.storageService.remove(ConstantsService.biometricAwaitingAcceptance); + } + }), + this.platformUtilsService.authenticateBiometric().then((result) => { + this.biometric = result; + + Swal.close(); + if (this.biometric === false) { + this.platformUtilsService.showToast('error', this.i18nService.t('errorEnableBiometricTitle'), this.i18nService.t('errorEnableBiometricDesc')); + } + }).catch((e) => { + // Handle connection errors + this.biometric = false; + }) + ]); + } else { + await this.storageService.remove(ConstantsService.biometricUnlockKey); + this.vaultTimeoutService.biometricLocked = false; + } + } + async lock() { this.analytics.eventTrack.next({ action: 'Lock Now' }); await this.vaultTimeoutService.lock(true); diff --git a/src/services/browserPlatformUtils.service.spec.ts b/src/services/browserPlatformUtils.service.spec.ts index 74881853a60..6b3711ef477 100644 --- a/src/services/browserPlatformUtils.service.spec.ts +++ b/src/services/browserPlatformUtils.service.spec.ts @@ -27,7 +27,7 @@ describe('Browser Utils Service', () => { value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36', }); - const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null); + const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null, null); expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.ChromeExtension); }); @@ -37,7 +37,7 @@ describe('Browser Utils Service', () => { value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:58.0) Gecko/20100101 Firefox/58.0', }); - const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null); + const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null, null); expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.FirefoxExtension); }); @@ -52,7 +52,7 @@ describe('Browser Utils Service', () => { value: {}, }); - const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null); + const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null, null); expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.OperaExtension); }); @@ -62,7 +62,7 @@ describe('Browser Utils Service', () => { value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.74 Safari/537.36 Edg/79.0.309.43', }); - const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null); + const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null, null); expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.EdgeExtension); }); @@ -77,7 +77,7 @@ describe('Browser Utils Service', () => { value: true, }); - const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null); + const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null, null); expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.SafariExtension); Object.defineProperty(window, 'safariAppExtension', { @@ -92,7 +92,7 @@ describe('Browser Utils Service', () => { value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.97 Safari/537.36 Vivaldi/1.94.1008.40', }); - const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null); + const browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null, null); expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.VivaldiExtension); }); }); diff --git a/src/services/browserPlatformUtils.service.ts b/src/services/browserPlatformUtils.service.ts index 1955a1d691c..b9946937f43 100644 --- a/src/services/browserPlatformUtils.service.ts +++ b/src/services/browserPlatformUtils.service.ts @@ -18,7 +18,8 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService private analyticsIdCache: string = null; constructor(private messagingService: MessagingService, - private clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void) { } + private clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void, + private biometricCallback: () => Promise) { } getDevice(): DeviceType { if (this.deviceCache) { @@ -288,11 +289,11 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService } supportsBiometric() { - return Promise.resolve(false); + return Promise.resolve(true); } authenticateBiometric() { - return Promise.resolve(false); + return this.biometricCallback(); } sidebarViewName(): string { diff --git a/tsconfig.json b/tsconfig.json index e1b5cca4130..a1991acf1a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,9 @@ "sourceMap": true, "types": [ "jasmine", - "sweetalert2" + "sweetalert2", + "@types/chrome", + "@types/firefox-webext-browser" ], "baseUrl": ".", "paths": {