import { AppIdService } from 'jslib-common/abstractions/appId.service'; import { CryptoService } from 'jslib-common/abstractions/crypto.service'; import { CryptoFunctionService } from 'jslib-common/abstractions/cryptoFunction.service'; import { I18nService } from 'jslib-common/abstractions/i18n.service'; import { MessagingService } from 'jslib-common/abstractions/messaging.service'; import { PlatformUtilsService } from 'jslib-common/abstractions/platformUtils.service'; import { StorageService } from 'jslib-common/abstractions/storage.service'; import { UserService } from 'jslib-common/abstractions/user.service'; import { VaultTimeoutService } from 'jslib-common/abstractions/vaultTimeout.service'; import { ConstantsService } from 'jslib-common/services/constants.service'; import { Utils } from 'jslib-common/misc/utils'; import { SymmetricCryptoKey } from 'jslib-common/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 publicKey: ArrayBuffer = null; private secureSetupResolve: any = null; private sharedSecret: SymmetricCryptoKey; private appId: string; private validatingFingerprint: boolean; 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, private appIdService: AppIdService, private platformUtilsService: PlatformUtilsService) { this.storageService.save(ConstantsService.biometricFingerprintValidated, false); if (chrome?.permissions?.onAdded) { // Reload extension to activate nativeMessaging chrome.permissions.onAdded.addListener(permissions => { BrowserApi.reloadExtension(null); }); } } async connect() { this.appId = await this.appIdService.getAppId(); this.storageService.save(ConstantsService.biometricFingerprintValidated, false); return new Promise((resolve, reject) => { this.port = BrowserApi.connectNative('com.8bit.bitwarden'); this.connecting = true; const connectedCallback = () => { this.connected = true; this.connecting = false; resolve(); }; // Safari has a bundled native component which is always available, no need to // check if the desktop app is running. if (this.platformUtilsService.isSafari()) { connectedCallback(); } this.port.onMessage.addListener(async (message: any) => { switch (message.command) { case 'connected': connectedCallback(); 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': // Ignore since it belongs to another device if (message.appId !== this.appId) { return; } const encrypted = Utils.fromB64ToArray(message.sharedSecret); const decrypted = await this.cryptoFunctionService.rsaDecrypt(encrypted.buffer, this.privateKey, EncryptionAlgorithm); if (this.validatingFingerprint) { this.validatingFingerprint = false; this.storageService.save(ConstantsService.biometricFingerprintValidated, true); } this.sharedSecret = new SymmetricCryptoKey(decrypted); this.secureSetupResolve(); break; case 'invalidateEncryption': // Ignore since it belongs to another device if (message.appId !== this.appId) { return; } 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', }); break; case 'verifyFingerprint': { if (this.sharedSecret == null) { this.validatingFingerprint = true; this.showFingerprintDialog(); } break; } case 'wrongUserId': this.showWrongUserDialog(); default: // Ignore since it belongs to another device if (!this.platformUtilsService.isSafari() && message.appId !== this.appId) { return; } this.onMessage(message.message); } }); this.port.onDisconnect.addListener((p: any) => { let error; if (BrowserApi.isWebExtensionsApi) { error = p.error.message; } else { error = chrome.runtime.lastError.message; } if (error != null) { 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(); }); }); } showWrongUserDialog() { this.messagingService.send('showDialog', { text: this.i18nService.t('nativeMessagingWrongUserDesc'), title: this.i18nService.t('nativeMessagingWrongUserTitle'), confirmText: this.i18nService.t('ok'), type: 'error', }); } async send(message: any) { if (!this.connected) { await this.connect(); } if (this.platformUtilsService.isSafari()) { this.postMessage(message); } else { this.postMessage({appId: this.appId, message: await this.encryptMessage(message)}); } } async encryptMessage(message: any) { if (this.sharedSecret == null) { await this.secureCommunication(); } message.timestamp = Date.now(); return await this.cryptoService.encrypt(JSON.stringify(message), this.sharedSecret); } getResponse(): Promise { return new Promise((resolve, reject) => { this.resolver = resolve; }); } private postMessage(message: any) { // Wrap in try-catch to when the port disconnected without triggering `onDisconnect`. try { this.port.postMessage(message); } catch (e) { // tslint:disable-next-line console.error("NativeMessaging port disconnected, disconnecting."); 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', }); } } private async onMessage(rawMessage: any) { let message = rawMessage; if (!this.platformUtilsService.isSafari()) { 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); if (message.response === 'not enabled') { this.messagingService.send('showDialog', { text: this.i18nService.t('biometricsNotEnabledDesc'), title: this.i18nService.t('biometricsNotEnabledTitle'), confirmText: this.i18nService.t('ok'), type: 'error', }); break; } else if (message.response === 'not supported') { this.messagingService.send('showDialog', { text: this.i18nService.t('biometricsNotSupportedDesc'), title: this.i18nService.t('biometricsNotSupportedTitle'), confirmText: this.i18nService.t('ok'), type: 'error', }); break; } 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') { await this.cryptoService.setKey(new SymmetricCryptoKey(Utils.fromB64ToArray(message.keyB64).buffer)); // Verify key is correct by attempting to decrypt a secret try { await this.cryptoService.getFingerprint(await this.userService.getUserId()); } catch (e) { // tslint:disable-next-line console.error('Unable to verify key:', e); await this.cryptoService.clearKey(); this.showWrongUserDialog(); message = false; break; } 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.publicKey = publicKey; this.privateKey = privateKey; this.sendUnencrypted({ command: 'setupEncryption', publicKey: Utils.fromBufferToB64(publicKey), userId: await this.userService.getUserId(), }); return new Promise((resolve, reject) => this.secureSetupResolve = resolve); } private async sendUnencrypted(message: any) { if (!this.connected) { await this.connect(); } message.timestamp = Date.now(); this.postMessage({appId: this.appId, message: message}); } private async showFingerprintDialog() { const fingerprint = (await this.cryptoService.getFingerprint(await this.userService.getUserId(), this.publicKey)).join(' '); this.messagingService.send('showDialog', { html: `${this.i18nService.t('desktopIntegrationVerificationText')}

${fingerprint}`, title: this.i18nService.t('desktopSyncVerificationTitle'), confirmText: this.i18nService.t('ok'), type: 'warning', }); } }