From c62f5287cd259c6385c6e79193e0e5e1746c7a3c Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Thu, 23 Jul 2020 19:32:20 +0200 Subject: [PATCH] Desktop biometrics support (#119) * Initial work on windows hello support * Switch to use windows.security.credentials.ui UserConsentVerifier * Fix linting warnings * Remove unessesary supportsBiometric from lock screen * Rename biometric.main to windows.biometric.main. Add abstraction for biometric. * Add support for dynamic biometric text. * Add untested darwin implementation * Rename fingerprintUnlock to biometric * Add new functions to cliPlatformUtils.service.ts. * Hide login if biometric is not supported * Export default for biometric.*.main.ts * Remove @nodert-win10-rs4/windows.security.credentials * Add build requirements to readme * Auto prompt biometric when starting the application. * Ensure we support biometric before trying to auto prompt. * Fix review comments and linting errors --- README.md | 11 ++ package-lock.json | 15 +++ package.json | 1 + src/abstractions/biometric.main.ts | 5 + src/abstractions/platformUtils.service.ts | 2 + src/abstractions/vaultTimeout.service.ts | 2 + src/angular/components/lock.component.ts | 25 +++++ src/angular/services/auth-guard.service.ts | 2 +- src/cli/services/cliPlatformUtils.service.ts | 8 ++ src/electron/biometric.darwin.main.ts | 32 ++++++ src/electron/biometric.windows.main.ts | 46 ++++++++ src/electron/electronConstants.ts | 1 + .../services/electronPlatformUtils.service.ts | 18 +++- src/globals.d.ts | 101 ++++++++++++++++++ src/services/auth.service.ts | 4 +- src/services/constants.service.ts | 4 + src/services/crypto.service.ts | 6 +- src/services/vaultTimeout.service.ts | 21 ++++ tsconfig.json | 2 +- 19 files changed, 300 insertions(+), 6 deletions(-) create mode 100644 src/abstractions/biometric.main.ts create mode 100644 src/electron/biometric.darwin.main.ts create mode 100644 src/electron/biometric.windows.main.ts diff --git a/README.md b/README.md index d376564923..5096a355f4 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,14 @@ # Bitwarden JavaScript Library Common code referenced across Bitwarden JavaScript projects. + +## Requirements + +* Git +* node-gyp + +### Windows + +* *Microsoft Build Tools 2015* in Visual Studio Installer +* [Windows 10 SDK 17134](https://developer.microsoft.com/en-us/windows/downloads/sdk-archive/) +either by downloading it seperately or through the Visual Studio Installer. diff --git a/package-lock.json b/package-lock.json index 95111a5b5a..b36e8bd6fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -254,6 +254,21 @@ "msgpack5": "^4.0.2" } }, + "@nodert-win10-rs4/windows.security.credentials.ui": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@nodert-win10-rs4/windows.security.credentials.ui/-/windows.security.credentials.ui-0.4.4.tgz", + "integrity": "sha512-P+EsJw5MCQXTxp7mwXfNDvIzIYsB6ple+HNg01QjPWg/PJfAodPuxL6XM7l0sPtYHsDYnfnvoefZMdZRa2Z1ig==", + "requires": { + "nan": "^2.14.1" + }, + "dependencies": { + "nan": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz", + "integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==" + } + } + }, "@types/commander": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/@types/commander/-/commander-2.12.2.tgz", diff --git a/package.json b/package.json index 5fec1f5667..71bf68e843 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@angular/upgrade": "7.2.1", "@microsoft/signalr": "3.1.0", "@microsoft/signalr-protocol-msgpack": "3.1.0", + "@nodert-win10-rs4/windows.security.credentials.ui": "^0.4.4", "big-integer": "1.6.36", "chalk": "2.4.1", "commander": "2.18.0", diff --git a/src/abstractions/biometric.main.ts b/src/abstractions/biometric.main.ts new file mode 100644 index 0000000000..0f85cc1f55 --- /dev/null +++ b/src/abstractions/biometric.main.ts @@ -0,0 +1,5 @@ +export abstract class BiometricMain { + init: () => Promise; + supportsBiometric: () => Promise; + requestCreate: () => Promise; +} diff --git a/src/abstractions/platformUtils.service.ts b/src/abstractions/platformUtils.service.ts index 070abf0027..f40e07504f 100644 --- a/src/abstractions/platformUtils.service.ts +++ b/src/abstractions/platformUtils.service.ts @@ -32,4 +32,6 @@ export abstract class PlatformUtilsService { isSelfHost: () => boolean; copyToClipboard: (text: string, options?: any) => void; readFromClipboard: (options?: any) => Promise; + supportsBiometric: () => Promise; + authenticateBiometric: () => Promise; } diff --git a/src/abstractions/vaultTimeout.service.ts b/src/abstractions/vaultTimeout.service.ts index 4c0ad3d0d2..bd8ff2f743 100644 --- a/src/abstractions/vaultTimeout.service.ts +++ b/src/abstractions/vaultTimeout.service.ts @@ -1,6 +1,7 @@ import { CipherString } from '../models/domain/cipherString'; export abstract class VaultTimeoutService { + biometricLocked: boolean; pinProtectedKey: CipherString; isLocked: () => Promise; checkVaultTimeout: () => Promise; @@ -8,5 +9,6 @@ export abstract class VaultTimeoutService { logOut: () => Promise; setVaultTimeoutOptions: (vaultTimeout: number, vaultTimeoutAction: string) => Promise; isPinLockSet: () => Promise<[boolean, boolean]>; + isBiometricLockSet: () => Promise; clear: () => Promise; } diff --git a/src/angular/components/lock.component.ts b/src/angular/components/lock.component.ts index 0cf22362c3..85b0b2b167 100644 --- a/src/angular/components/lock.component.ts +++ b/src/angular/components/lock.component.ts @@ -1,5 +1,6 @@ import { OnInit } from '@angular/core'; import { Router } from '@angular/router'; +import { first } from 'rxjs/operators'; import { ApiService } from '../../abstractions/api.service'; import { CryptoService } from '../../abstractions/crypto.service'; @@ -29,6 +30,9 @@ export class LockComponent implements OnInit { pinLock: boolean = false; webVaultHostname: string = ''; formPromise: Promise; + supportsBiometric: boolean; + biometricLock: boolean; + biometricText: string; protected successRoute: string = 'vault'; protected onSuccessfulSubmit: () => void; @@ -46,12 +50,20 @@ export class LockComponent implements OnInit { async ngOnInit() { this.pinSet = await this.vaultTimeoutService.isPinLockSet(); this.pinLock = (this.pinSet[0] && this.vaultTimeoutService.pinProtectedKey != null) || this.pinSet[1]; + this.supportsBiometric = await this.platformUtilsService.supportsBiometric(); + this.biometricLock = await this.vaultTimeoutService.isBiometricLockSet(); + this.biometricText = await this.storageService.get(ConstantsService.biometricText); this.email = await this.userService.getEmail(); let vaultUrl = this.environmentService.getWebVaultUrl(); if (vaultUrl == null) { vaultUrl = 'https://bitwarden.com'; } this.webVaultHostname = Utils.getHostname(vaultUrl); + this.router.routerState.root.queryParams.pipe(first()).subscribe((params) => { + if (this.supportsBiometric && params.promptBiometric) { + this.unlockBiometric(); + } + }); } async submit() { @@ -146,6 +158,18 @@ export class LockComponent implements OnInit { } } + async unlockBiometric() { + if (!this.biometricLock) { + return; + } + const success = await this.platformUtilsService.authenticateBiometric(); + + this.vaultTimeoutService.biometricLocked = !success; + if (success) { + await this.doContinue(); + } + } + togglePassword() { this.platformUtilsService.eventTrack('Toggled Master Password on Unlock'); this.showPassword = !this.showPassword; @@ -158,6 +182,7 @@ export class LockComponent implements OnInit { } private async doContinue() { + this.vaultTimeoutService.biometricLocked = false; const disableFavicon = await this.storageService.get(ConstantsService.disableFaviconKey); await this.stateService.save(ConstantsService.disableFaviconKey, !!disableFavicon); this.messagingService.send('unlocked'); diff --git a/src/angular/services/auth-guard.service.ts b/src/angular/services/auth-guard.service.ts index 0401c37875..b459d62053 100644 --- a/src/angular/services/auth-guard.service.ts +++ b/src/angular/services/auth-guard.service.ts @@ -27,7 +27,7 @@ export class AuthGuardService implements CanActivate { if (routerState != null) { this.messagingService.send('lockedUrl', { url: routerState.url }); } - this.router.navigate(['lock']); + this.router.navigate(['lock'], { queryParams: { promptBiometric: true }}); return false; } diff --git a/src/cli/services/cliPlatformUtils.service.ts b/src/cli/services/cliPlatformUtils.service.ts index d7564c9b84..23a0aa01d0 100644 --- a/src/cli/services/cliPlatformUtils.service.ts +++ b/src/cli/services/cliPlatformUtils.service.ts @@ -129,4 +129,12 @@ export class CliPlatformUtilsService implements PlatformUtilsService { readFromClipboard(options?: any): Promise { throw new Error('Not implemented.'); } + + supportsBiometric(): Promise { + return Promise.resolve(false); + } + + authenticateBiometric(): Promise { + return Promise.resolve(false); + } } diff --git a/src/electron/biometric.darwin.main.ts b/src/electron/biometric.darwin.main.ts new file mode 100644 index 0000000000..ab449654ef --- /dev/null +++ b/src/electron/biometric.darwin.main.ts @@ -0,0 +1,32 @@ +import { I18nService, StorageService } from '../abstractions'; + +import { ipcMain, systemPreferences } from 'electron'; +import { BiometricMain } from '../abstractions/biometric.main'; +import { ConstantsService } from '../services'; +import { ElectronConstants } from './electronConstants'; + +export default class BiometricDarwinMain implements BiometricMain { + constructor(private storageService: StorageService, private i18nservice: I18nService) {} + + async init() { + this.storageService.save(ElectronConstants.enableBiometric, await this.supportsBiometric()); + this.storageService.save(ConstantsService.biometricText, 'unlockWithTouchId'); + + ipcMain.on('biometric', async (event: any, message: any) => { + event.returnValue = await this.requestCreate(); + }); + } + + supportsBiometric(): Promise { + return Promise.resolve(systemPreferences.canPromptTouchID()); + } + + async requestCreate(): Promise { + try { + await systemPreferences.promptTouchID(this.i18nservice.t('touchIdConsentMessage')); + return true; + } catch { + return false; + } + } +} diff --git a/src/electron/biometric.windows.main.ts b/src/electron/biometric.windows.main.ts new file mode 100644 index 0000000000..5a0d089965 --- /dev/null +++ b/src/electron/biometric.windows.main.ts @@ -0,0 +1,46 @@ +import * as util from 'util'; + +import { + UserConsentVerificationResult, + UserConsentVerifier, + UserConsentVerifierAvailability, +} from '@nodert-win10-rs4/windows.security.credentials.ui'; +import { I18nService, StorageService } from '../abstractions'; + +import { ipcMain } from 'electron'; +import { BiometricMain } from '../abstractions/biometric.main'; +import { ConstantsService } from '../services'; +import { ElectronConstants } from './electronConstants'; + +const requestVerification: any = util.promisify(UserConsentVerifier.requestVerificationAsync); +const checkAvailability: any = util.promisify(UserConsentVerifier.checkAvailabilityAsync); + +const AllowedAvailabilities = [ + UserConsentVerifierAvailability.available, + UserConsentVerifierAvailability.deviceBusy, +]; + +export default class BiometricWindowsMain implements BiometricMain { + constructor(private storageService: StorageService, private i18nservice: I18nService) {} + + async init() { + this.storageService.save(ElectronConstants.enableBiometric, await this.supportsBiometric()); + this.storageService.save(ConstantsService.biometricText, 'unlockWithWindowsHello'); + + ipcMain.on('biometric', async (event: any, message: any) => { + event.returnValue = await this.requestCreate(); + }); + } + + async supportsBiometric(): Promise { + const availability = await checkAvailability(); + + return AllowedAvailabilities.includes(availability); + } + + async requestCreate(): Promise { + const verification = await requestVerification(this.i18nservice.t('windowsHelloConsentMessage')); + + return verification === UserConsentVerificationResult.verified; + } +} diff --git a/src/electron/electronConstants.ts b/src/electron/electronConstants.ts index f444b0a1d8..8d88ef6b52 100644 --- a/src/electron/electronConstants.ts +++ b/src/electron/electronConstants.ts @@ -5,4 +5,5 @@ export class ElectronConstants { static readonly enableStartToTrayKey: string = 'enableStartToTrayKey'; static readonly enableAlwaysOnTopKey: string = 'enableAlwaysOnTopKey'; static readonly minimizeOnCopyToClipboardKey: string = 'minimizeOnCopyToClipboardKey'; + static readonly enableBiometric: string = 'enabledBiometric'; } diff --git a/src/electron/services/electronPlatformUtils.service.ts b/src/electron/services/electronPlatformUtils.service.ts index ad08b170e5..dcefdc8883 100644 --- a/src/electron/services/electronPlatformUtils.service.ts +++ b/src/electron/services/electronPlatformUtils.service.ts @@ -1,5 +1,6 @@ import { clipboard, + ipcRenderer, remote, shell, } from 'electron'; @@ -15,8 +16,10 @@ import { DeviceType } from '../../enums/deviceType'; import { I18nService } from '../../abstractions/i18n.service'; import { MessagingService } from '../../abstractions/messaging.service'; import { PlatformUtilsService } from '../../abstractions/platformUtils.service'; +import { StorageService } from '../../abstractions/storage.service'; import { AnalyticsIds } from '../../misc/analytics'; +import { ElectronConstants } from '../electronConstants'; export class ElectronPlatformUtilsService implements PlatformUtilsService { identityClientId: string; @@ -25,7 +28,7 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { private analyticsIdCache: string = null; constructor(private i18nService: I18nService, private messagingService: MessagingService, - private isDesktopApp: boolean) { + private isDesktopApp: boolean, private storageService: StorageService) { this.identityClientId = isDesktopApp ? 'desktop' : 'connector'; } @@ -203,4 +206,17 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { const type = options ? options.type : null; return Promise.resolve(clipboard.readText(type)); } + + supportsBiometric(): Promise { + return this.storageService.get(ElectronConstants.enableBiometric); + } + + authenticateBiometric(): Promise { + return new Promise((resolve) => { + const val = ipcRenderer.sendSync('biometric', { + action: 'authenticate', + }); + resolve(val); + }); + } } diff --git a/src/globals.d.ts b/src/globals.d.ts index 8116ab173f..4c123c15a8 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -1,3 +1,104 @@ declare function escape(s: string): string; declare function unescape(s: string): string; declare module 'duo_web_sdk'; + +/* tslint:disable */ +declare module '@nodert-win10-rs4/windows.security.credentials.ui' { + export enum AuthenticationProtocol { + basic, + digest, + ntlm, + kerberos, + negotiate, + credSsp, + custom, + } + + export enum CredentialSaveOption { + unselected, + selected, + hidden, + } + + export enum UserConsentVerifierAvailability { + available, + deviceNotPresent, + notConfiguredForUser, + disabledByPolicy, + deviceBusy, + } + + export enum UserConsentVerificationResult { + verified, + deviceNotPresent, + notConfiguredForUser, + disabledByPolicy, + deviceBusy, + retriesExhausted, + canceled, + } + + export class CredentialPickerOptions { + targetName: String; + previousCredential: Object; + message: String; + errorCode: Number; + customAuthenticationProtocol: String; + credentialSaveOption: CredentialSaveOption; + caption: String; + callerSavesCredential: Boolean; + authenticationProtocol: AuthenticationProtocol; + alwaysDisplayDialog: Boolean; + constructor(); + } + + export class CredentialPickerResults { + credential: Object; + credentialDomainName: String; + credentialPassword: String; + credentialSaveOption: CredentialSaveOption; + credentialSaved: Boolean; + credentialUserName: String; + errorCode: Number; + constructor(); + } + + export class CredentialPicker { + constructor(); + + static pickAsync( + options: CredentialPickerOptions, + callback: (error: Error, result: CredentialPickerResults) => void + ): void; + static pickAsync( + targetName: String, + message: String, + callback: (error: Error, result: CredentialPickerResults) => void + ): void; + static pickAsync( + targetName: String, + message: String, + caption: String, + callback: (error: Error, result: CredentialPickerResults) => void + ): void; + } + + export class UserConsentVerifier { + constructor(); + + static checkAvailabilityAsync( + callback: ( + error: Error, + result: UserConsentVerifierAvailability + ) => void + ): void; + + static requestVerificationAsync( + message: String, + callback: ( + error: Error, + result: UserConsentVerificationResult + ) => void + ): void; + } +} diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 9a8d0ec3e0..16025dc088 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -22,6 +22,7 @@ import { MessagingService } from '../abstractions/messaging.service'; import { PlatformUtilsService } from '../abstractions/platformUtils.service'; import { TokenService } from '../abstractions/token.service'; import { UserService } from '../abstractions/user.service'; +import { VaultTimeoutService } from '../abstractions/vaultTimeout.service'; export const TwoFactorProviders = { [TwoFactorProviderType.Authenticator]: { @@ -89,7 +90,7 @@ export class AuthService implements AuthServiceAbstraction { private userService: UserService, private tokenService: TokenService, private appIdService: AppIdService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private messagingService: MessagingService, - private setCryptoKeys = true) { } + private vaultTimeoutService: VaultTimeoutService, private setCryptoKeys = true) { } init() { TwoFactorProviders[TwoFactorProviderType.Email].name = this.i18nService.t('emailTitle'); @@ -317,6 +318,7 @@ export class AuthService implements AuthServiceAbstraction { await this.cryptoService.setEncPrivateKey(tokenResponse.privateKey); } + this.vaultTimeoutService.biometricLocked = false; this.messagingService.send('loggedIn'); return result; } diff --git a/src/services/constants.service.ts b/src/services/constants.service.ts index 752648fd79..7e077933d4 100644 --- a/src/services/constants.service.ts +++ b/src/services/constants.service.ts @@ -25,6 +25,8 @@ export class ConstantsService { static readonly eventCollectionKey: string = 'eventCollection'; static readonly ssoCodeVerifierKey: string = 'ssoCodeVerifier'; static readonly ssoStateKey: string = 'ssoState'; + static readonly biometricUnlockKey: string = 'biometric'; + static readonly biometricText: string = 'biometricText'; readonly environmentUrlsKey: string = ConstantsService.environmentUrlsKey; readonly disableGaKey: string = ConstantsService.disableGaKey; @@ -51,4 +53,6 @@ export class ConstantsService { readonly eventCollectionKey: string = ConstantsService.eventCollectionKey; readonly ssoCodeVerifierKey: string = ConstantsService.ssoCodeVerifierKey; readonly ssoStateKey: string = ConstantsService.ssoStateKey; + readonly biometricUnlockKey: string = ConstantsService.biometricUnlockKey; + readonly biometricText: string = ConstantsService.biometricText; } diff --git a/src/services/crypto.service.ts b/src/services/crypto.service.ts index 5fa86444e3..2037a57d85 100644 --- a/src/services/crypto.service.ts +++ b/src/services/crypto.service.ts @@ -42,7 +42,8 @@ export class CryptoService implements CryptoServiceAbstraction { this.key = key; const option = await this.storageService.get(ConstantsService.vaultTimeoutKey); - if (option != null) { + const biometric = await this.storageService.get(ConstantsService.biometricUnlockKey); + if (option != null && !biometric) { // if we have a lock option set, we do not store the key return; } @@ -291,7 +292,8 @@ export class CryptoService implements CryptoServiceAbstraction { async toggleKey(): Promise { const key = await this.getKey(); const option = await this.storageService.get(ConstantsService.vaultTimeoutKey); - if (option != null || option === 0) { + const biometric = await this.storageService.get(ConstantsService.biometricUnlockKey); + if (!biometric && (option != null || option === 0)) { // if we have a lock option set, clear the key await this.clearKey(); this.key = key; diff --git a/src/services/vaultTimeout.service.ts b/src/services/vaultTimeout.service.ts index 2601dc5454..f801b554b8 100644 --- a/src/services/vaultTimeout.service.ts +++ b/src/services/vaultTimeout.service.ts @@ -16,6 +16,7 @@ import { CipherString } from '../models/domain/cipherString'; export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { pinProtectedKey: CipherString = null; + biometricLocked: boolean = true; private inited = false; @@ -42,6 +43,11 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { // Keys aren't stored for a device that is locked or logged out. async isLocked(): Promise { const hasKey = await this.cryptoService.hasKey(); + if (hasKey) { + if (await this.isBiometricLockSet() && this.biometricLocked) { + return true; + } + } return !hasKey; } @@ -91,6 +97,17 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { return; } + if (allowSoftLock) { + const biometricLocked = await this.isBiometricLockSet(); + if (biometricLocked) { + this.messagingService.send('locked'); + if (this.lockedCallback != null) { + await this.lockedCallback(); + } + return; + } + } + await Promise.all([ this.cryptoService.clearKey(), this.cryptoService.clearOrgKeys(true), @@ -127,6 +144,10 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { return [protectedPin != null, pinProtectedKey != null]; } + async isBiometricLockSet(): Promise { + return await this.storageService.get(ConstantsService.biometricUnlockKey); + } + clear(): Promise { this.pinProtectedKey = null; return this.storageService.remove(ConstantsService.protectedPin); diff --git a/tsconfig.json b/tsconfig.json index 7ed555a337..960333221f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "noImplicitAny": true, "target": "ES6", "module": "commonjs", - "lib": ["es5", "es6", "dom"], + "lib": ["es5", "es6", "es7", "dom"], "sourceMap": true, "declaration": true, "allowSyntheticDefaultImports": true,