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:
parent
94d363bfca
commit
c62f5287cd
11
README.md
11
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.
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
export abstract class BiometricMain {
|
||||
init: () => Promise<void>;
|
||||
supportsBiometric: () => Promise<boolean>;
|
||||
requestCreate: () => Promise<boolean>;
|
||||
}
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue