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
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"
}
},
"@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",

View File

@ -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",

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;
copyToClipboard: (text: string, options?: any) => void;
readFromClipboard: (options?: any) => Promise<string>;
supportsBiometric: () => Promise<boolean>;
authenticateBiometric: () => Promise<boolean>;
}

View File

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

View File

@ -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<any>;
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<boolean>(ConstantsService.disableFaviconKey);
await this.stateService.save(ConstantsService.disableFaviconKey, !!disableFavicon);
this.messagingService.send('unlocked');

View File

@ -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;
}

View File

@ -129,4 +129,12 @@ export class CliPlatformUtilsService implements PlatformUtilsService {
readFromClipboard(options?: any): Promise<string> {
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 enableAlwaysOnTopKey: string = 'enableAlwaysOnTopKey';
static readonly minimizeOnCopyToClipboardKey: string = 'minimizeOnCopyToClipboardKey';
static readonly enableBiometric: string = 'enabledBiometric';
}

View File

@ -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<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 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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -42,7 +42,8 @@ export class CryptoService implements CryptoServiceAbstraction {
this.key = key;
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
return;
}
@ -291,7 +292,8 @@ export class CryptoService implements CryptoServiceAbstraction {
async toggleKey(): Promise<any> {
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;

View File

@ -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<boolean> {
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<boolean> {
return await this.storageService.get<boolean>(ConstantsService.biometricUnlockKey);
}
clear(): Promise<any> {
this.pinProtectedKey = null;
return this.storageService.remove(ConstantsService.protectedPin);

View File

@ -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,