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
This commit is contained in:
Oscar Hinton 2020-07-23 19:32:20 +02:00 committed by GitHub
parent 94d363bfca
commit c62f5287cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 300 additions and 6 deletions

View File

@ -3,3 +3,14 @@
# Bitwarden JavaScript Library # Bitwarden JavaScript Library
Common code referenced across Bitwarden JavaScript projects. 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.

15
package-lock.json generated
View File

@ -254,6 +254,21 @@
"msgpack5": "^4.0.2" "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": { "@types/commander": {
"version": "2.12.2", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@types/commander/-/commander-2.12.2.tgz", "resolved": "https://registry.npmjs.org/@types/commander/-/commander-2.12.2.tgz",

View File

@ -74,6 +74,7 @@
"@angular/upgrade": "7.2.1", "@angular/upgrade": "7.2.1",
"@microsoft/signalr": "3.1.0", "@microsoft/signalr": "3.1.0",
"@microsoft/signalr-protocol-msgpack": "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", "big-integer": "1.6.36",
"chalk": "2.4.1", "chalk": "2.4.1",
"commander": "2.18.0", "commander": "2.18.0",

View File

@ -0,0 +1,5 @@
export abstract class BiometricMain {
init: () => Promise<void>;
supportsBiometric: () => Promise<boolean>;
requestCreate: () => Promise<boolean>;
}

View File

@ -32,4 +32,6 @@ export abstract class PlatformUtilsService {
isSelfHost: () => boolean; isSelfHost: () => boolean;
copyToClipboard: (text: string, options?: any) => void; copyToClipboard: (text: string, options?: any) => void;
readFromClipboard: (options?: any) => Promise<string>; readFromClipboard: (options?: any) => Promise<string>;
supportsBiometric: () => Promise<boolean>;
authenticateBiometric: () => Promise<boolean>;
} }

View File

@ -1,6 +1,7 @@
import { CipherString } from '../models/domain/cipherString'; import { CipherString } from '../models/domain/cipherString';
export abstract class VaultTimeoutService { export abstract class VaultTimeoutService {
biometricLocked: boolean;
pinProtectedKey: CipherString; pinProtectedKey: CipherString;
isLocked: () => Promise<boolean>; isLocked: () => Promise<boolean>;
checkVaultTimeout: () => Promise<void>; checkVaultTimeout: () => Promise<void>;
@ -8,5 +9,6 @@ export abstract class VaultTimeoutService {
logOut: () => Promise<void>; logOut: () => Promise<void>;
setVaultTimeoutOptions: (vaultTimeout: number, vaultTimeoutAction: string) => Promise<void>; setVaultTimeoutOptions: (vaultTimeout: number, vaultTimeoutAction: string) => Promise<void>;
isPinLockSet: () => Promise<[boolean, boolean]>; isPinLockSet: () => Promise<[boolean, boolean]>;
isBiometricLockSet: () => Promise<boolean>;
clear: () => Promise<any>; clear: () => Promise<any>;
} }

View File

@ -1,5 +1,6 @@
import { OnInit } from '@angular/core'; import { OnInit } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { first } from 'rxjs/operators';
import { ApiService } from '../../abstractions/api.service'; import { ApiService } from '../../abstractions/api.service';
import { CryptoService } from '../../abstractions/crypto.service'; import { CryptoService } from '../../abstractions/crypto.service';
@ -29,6 +30,9 @@ export class LockComponent implements OnInit {
pinLock: boolean = false; pinLock: boolean = false;
webVaultHostname: string = ''; webVaultHostname: string = '';
formPromise: Promise<any>; formPromise: Promise<any>;
supportsBiometric: boolean;
biometricLock: boolean;
biometricText: string;
protected successRoute: string = 'vault'; protected successRoute: string = 'vault';
protected onSuccessfulSubmit: () => void; protected onSuccessfulSubmit: () => void;
@ -46,12 +50,20 @@ export class LockComponent implements OnInit {
async ngOnInit() { async ngOnInit() {
this.pinSet = await this.vaultTimeoutService.isPinLockSet(); this.pinSet = await this.vaultTimeoutService.isPinLockSet();
this.pinLock = (this.pinSet[0] && this.vaultTimeoutService.pinProtectedKey != null) || this.pinSet[1]; 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(); this.email = await this.userService.getEmail();
let vaultUrl = this.environmentService.getWebVaultUrl(); let vaultUrl = this.environmentService.getWebVaultUrl();
if (vaultUrl == null) { if (vaultUrl == null) {
vaultUrl = 'https://bitwarden.com'; vaultUrl = 'https://bitwarden.com';
} }
this.webVaultHostname = Utils.getHostname(vaultUrl); this.webVaultHostname = Utils.getHostname(vaultUrl);
this.router.routerState.root.queryParams.pipe(first()).subscribe((params) => {
if (this.supportsBiometric && params.promptBiometric) {
this.unlockBiometric();
}
});
} }
async submit() { 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() { togglePassword() {
this.platformUtilsService.eventTrack('Toggled Master Password on Unlock'); this.platformUtilsService.eventTrack('Toggled Master Password on Unlock');
this.showPassword = !this.showPassword; this.showPassword = !this.showPassword;
@ -158,6 +182,7 @@ export class LockComponent implements OnInit {
} }
private async doContinue() { private async doContinue() {
this.vaultTimeoutService.biometricLocked = false;
const disableFavicon = await this.storageService.get<boolean>(ConstantsService.disableFaviconKey); const disableFavicon = await this.storageService.get<boolean>(ConstantsService.disableFaviconKey);
await this.stateService.save(ConstantsService.disableFaviconKey, !!disableFavicon); await this.stateService.save(ConstantsService.disableFaviconKey, !!disableFavicon);
this.messagingService.send('unlocked'); this.messagingService.send('unlocked');

View File

@ -27,7 +27,7 @@ export class AuthGuardService implements CanActivate {
if (routerState != null) { if (routerState != null) {
this.messagingService.send('lockedUrl', { url: routerState.url }); this.messagingService.send('lockedUrl', { url: routerState.url });
} }
this.router.navigate(['lock']); this.router.navigate(['lock'], { queryParams: { promptBiometric: true }});
return false; return false;
} }

View File

@ -129,4 +129,12 @@ export class CliPlatformUtilsService implements PlatformUtilsService {
readFromClipboard(options?: any): Promise<string> { readFromClipboard(options?: any): Promise<string> {
throw new Error('Not implemented.'); throw new Error('Not implemented.');
} }
supportsBiometric(): Promise<boolean> {
return Promise.resolve(false);
}
authenticateBiometric(): Promise<boolean> {
return Promise.resolve(false);
}
} }

View File

@ -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<boolean> {
return Promise.resolve(systemPreferences.canPromptTouchID());
}
async requestCreate(): Promise<boolean> {
try {
await systemPreferences.promptTouchID(this.i18nservice.t('touchIdConsentMessage'));
return true;
} catch {
return false;
}
}
}

View File

@ -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<boolean> {
const availability = await checkAvailability();
return AllowedAvailabilities.includes(availability);
}
async requestCreate(): Promise<boolean> {
const verification = await requestVerification(this.i18nservice.t('windowsHelloConsentMessage'));
return verification === UserConsentVerificationResult.verified;
}
}

View File

@ -5,4 +5,5 @@ export class ElectronConstants {
static readonly enableStartToTrayKey: string = 'enableStartToTrayKey'; static readonly enableStartToTrayKey: string = 'enableStartToTrayKey';
static readonly enableAlwaysOnTopKey: string = 'enableAlwaysOnTopKey'; static readonly enableAlwaysOnTopKey: string = 'enableAlwaysOnTopKey';
static readonly minimizeOnCopyToClipboardKey: string = 'minimizeOnCopyToClipboardKey'; static readonly minimizeOnCopyToClipboardKey: string = 'minimizeOnCopyToClipboardKey';
static readonly enableBiometric: string = 'enabledBiometric';
} }

View File

@ -1,5 +1,6 @@
import { import {
clipboard, clipboard,
ipcRenderer,
remote, remote,
shell, shell,
} from 'electron'; } from 'electron';
@ -15,8 +16,10 @@ import { DeviceType } from '../../enums/deviceType';
import { I18nService } from '../../abstractions/i18n.service'; import { I18nService } from '../../abstractions/i18n.service';
import { MessagingService } from '../../abstractions/messaging.service'; import { MessagingService } from '../../abstractions/messaging.service';
import { PlatformUtilsService } from '../../abstractions/platformUtils.service'; import { PlatformUtilsService } from '../../abstractions/platformUtils.service';
import { StorageService } from '../../abstractions/storage.service';
import { AnalyticsIds } from '../../misc/analytics'; import { AnalyticsIds } from '../../misc/analytics';
import { ElectronConstants } from '../electronConstants';
export class ElectronPlatformUtilsService implements PlatformUtilsService { export class ElectronPlatformUtilsService implements PlatformUtilsService {
identityClientId: string; identityClientId: string;
@ -25,7 +28,7 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService {
private analyticsIdCache: string = null; private analyticsIdCache: string = null;
constructor(private i18nService: I18nService, private messagingService: MessagingService, constructor(private i18nService: I18nService, private messagingService: MessagingService,
private isDesktopApp: boolean) { private isDesktopApp: boolean, private storageService: StorageService) {
this.identityClientId = isDesktopApp ? 'desktop' : 'connector'; this.identityClientId = isDesktopApp ? 'desktop' : 'connector';
} }
@ -203,4 +206,17 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService {
const type = options ? options.type : null; const type = options ? options.type : null;
return Promise.resolve(clipboard.readText(type)); return Promise.resolve(clipboard.readText(type));
} }
supportsBiometric(): Promise<boolean> {
return this.storageService.get(ElectronConstants.enableBiometric);
}
authenticateBiometric(): Promise<boolean> {
return new Promise((resolve) => {
const val = ipcRenderer.sendSync('biometric', {
action: 'authenticate',
});
resolve(val);
});
}
} }

101
src/globals.d.ts vendored
View File

@ -1,3 +1,104 @@
declare function escape(s: string): string; declare function escape(s: string): string;
declare function unescape(s: string): string; declare function unescape(s: string): string;
declare module 'duo_web_sdk'; 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;
}
}

View File

@ -22,6 +22,7 @@ import { MessagingService } from '../abstractions/messaging.service';
import { PlatformUtilsService } from '../abstractions/platformUtils.service'; import { PlatformUtilsService } from '../abstractions/platformUtils.service';
import { TokenService } from '../abstractions/token.service'; import { TokenService } from '../abstractions/token.service';
import { UserService } from '../abstractions/user.service'; import { UserService } from '../abstractions/user.service';
import { VaultTimeoutService } from '../abstractions/vaultTimeout.service';
export const TwoFactorProviders = { export const TwoFactorProviders = {
[TwoFactorProviderType.Authenticator]: { [TwoFactorProviderType.Authenticator]: {
@ -89,7 +90,7 @@ export class AuthService implements AuthServiceAbstraction {
private userService: UserService, private tokenService: TokenService, private userService: UserService, private tokenService: TokenService,
private appIdService: AppIdService, private i18nService: I18nService, private appIdService: AppIdService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private messagingService: MessagingService, private platformUtilsService: PlatformUtilsService, private messagingService: MessagingService,
private setCryptoKeys = true) { } private vaultTimeoutService: VaultTimeoutService, private setCryptoKeys = true) { }
init() { init() {
TwoFactorProviders[TwoFactorProviderType.Email].name = this.i18nService.t('emailTitle'); TwoFactorProviders[TwoFactorProviderType.Email].name = this.i18nService.t('emailTitle');
@ -317,6 +318,7 @@ export class AuthService implements AuthServiceAbstraction {
await this.cryptoService.setEncPrivateKey(tokenResponse.privateKey); await this.cryptoService.setEncPrivateKey(tokenResponse.privateKey);
} }
this.vaultTimeoutService.biometricLocked = false;
this.messagingService.send('loggedIn'); this.messagingService.send('loggedIn');
return result; return result;
} }

View File

@ -25,6 +25,8 @@ export class ConstantsService {
static readonly eventCollectionKey: string = 'eventCollection'; static readonly eventCollectionKey: string = 'eventCollection';
static readonly ssoCodeVerifierKey: string = 'ssoCodeVerifier'; static readonly ssoCodeVerifierKey: string = 'ssoCodeVerifier';
static readonly ssoStateKey: string = 'ssoState'; static readonly ssoStateKey: string = 'ssoState';
static readonly biometricUnlockKey: string = 'biometric';
static readonly biometricText: string = 'biometricText';
readonly environmentUrlsKey: string = ConstantsService.environmentUrlsKey; readonly environmentUrlsKey: string = ConstantsService.environmentUrlsKey;
readonly disableGaKey: string = ConstantsService.disableGaKey; readonly disableGaKey: string = ConstantsService.disableGaKey;
@ -51,4 +53,6 @@ export class ConstantsService {
readonly eventCollectionKey: string = ConstantsService.eventCollectionKey; readonly eventCollectionKey: string = ConstantsService.eventCollectionKey;
readonly ssoCodeVerifierKey: string = ConstantsService.ssoCodeVerifierKey; readonly ssoCodeVerifierKey: string = ConstantsService.ssoCodeVerifierKey;
readonly ssoStateKey: string = ConstantsService.ssoStateKey; readonly ssoStateKey: string = ConstantsService.ssoStateKey;
readonly biometricUnlockKey: string = ConstantsService.biometricUnlockKey;
readonly biometricText: string = ConstantsService.biometricText;
} }

View File

@ -42,7 +42,8 @@ export class CryptoService implements CryptoServiceAbstraction {
this.key = key; this.key = key;
const option = await this.storageService.get<number>(ConstantsService.vaultTimeoutKey); const option = await this.storageService.get<number>(ConstantsService.vaultTimeoutKey);
if (option != null) { const biometric = await this.storageService.get<boolean>(ConstantsService.biometricUnlockKey);
if (option != null && !biometric) {
// if we have a lock option set, we do not store the key // if we have a lock option set, we do not store the key
return; return;
} }
@ -291,7 +292,8 @@ export class CryptoService implements CryptoServiceAbstraction {
async toggleKey(): Promise<any> { async toggleKey(): Promise<any> {
const key = await this.getKey(); const key = await this.getKey();
const option = await this.storageService.get(ConstantsService.vaultTimeoutKey); 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 // if we have a lock option set, clear the key
await this.clearKey(); await this.clearKey();
this.key = key; this.key = key;

View File

@ -16,6 +16,7 @@ import { CipherString } from '../models/domain/cipherString';
export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
pinProtectedKey: CipherString = null; pinProtectedKey: CipherString = null;
biometricLocked: boolean = true;
private inited = false; 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. // Keys aren't stored for a device that is locked or logged out.
async isLocked(): Promise<boolean> { async isLocked(): Promise<boolean> {
const hasKey = await this.cryptoService.hasKey(); const hasKey = await this.cryptoService.hasKey();
if (hasKey) {
if (await this.isBiometricLockSet() && this.biometricLocked) {
return true;
}
}
return !hasKey; return !hasKey;
} }
@ -91,6 +97,17 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
return; 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([ await Promise.all([
this.cryptoService.clearKey(), this.cryptoService.clearKey(),
this.cryptoService.clearOrgKeys(true), this.cryptoService.clearOrgKeys(true),
@ -127,6 +144,10 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
return [protectedPin != null, pinProtectedKey != null]; return [protectedPin != null, pinProtectedKey != null];
} }
async isBiometricLockSet(): Promise<boolean> {
return await this.storageService.get<boolean>(ConstantsService.biometricUnlockKey);
}
clear(): Promise<any> { clear(): Promise<any> {
this.pinProtectedKey = null; this.pinProtectedKey = null;
return this.storageService.remove(ConstantsService.protectedPin); return this.storageService.remove(ConstantsService.protectedPin);

View File

@ -5,7 +5,7 @@
"noImplicitAny": true, "noImplicitAny": true,
"target": "ES6", "target": "ES6",
"module": "commonjs", "module": "commonjs",
"lib": ["es5", "es6", "dom"], "lib": ["es5", "es6", "es7", "dom"],
"sourceMap": true, "sourceMap": true,
"declaration": true, "declaration": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,