Skip to content

Commit

Permalink
Re-add biometric protections
Browse files Browse the repository at this point in the history
  • Loading branch information
perry-mitchell committed Nov 26, 2023
1 parent 7d9b5b7 commit 1a8c329
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 75 deletions.
3 changes: 2 additions & 1 deletion source/main/services/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { applyCurrentTheme } from "./theme";
import { updateTrayIcon } from "../actions/tray";
import { updateAppMenu } from "../actions/appMenu";
import { getConfigValue, initialise as initialiseConfig } from "./config";
import { getConfigPath, getVaultStoragePath } from "./storage";
import { getConfigPath, getVaultSettingsPath, getVaultStoragePath } from "./storage";
import { getOSLocale } from "./locale";
import { startFileHost } from "./fileHost";
import { isPortable } from "../library/portability";
Expand All @@ -28,6 +28,7 @@ export async function initialise() {
logInfo(`Logs location: ${getLogPath()}`);
logInfo(`Config location: ${getConfigPath()}`);
logInfo(`Vault config storage location: ${getVaultStoragePath()}`);
logInfo(`Vault-specific settings path: ${getVaultSettingsPath("<ID>")}`);
await initialiseConfig();
const preferences = await getConfigValue("preferences");
const locale = await getOSLocale();
Expand Down
14 changes: 9 additions & 5 deletions source/renderer/actions/password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,26 @@ import { getPasswordEmitter } from "../services/password";
import { sourceHasBiometricAvailability } from "../services/biometrics";
import { PASSWORD_STATE } from "../state/password";

export async function getPrimaryPassword(sourceID?: VaultSourceID): Promise<string | null> {
export async function getPrimaryPassword(
sourceID?: VaultSourceID
): Promise<[password: string | null, biometricsEnabled: boolean, usedBiometrics: boolean]> {
let biometricsEnabled: boolean = false;
if (sourceID) {
const supportsBiometrics = await sourceHasBiometricAvailability(sourceID);
if (supportsBiometrics) {
PASSWORD_STATE.passwordViaBiometricSource = sourceID;
biometricsEnabled = true;
}
}
PASSWORD_STATE.showPrompt = true;
const emitter = getPasswordEmitter();
const password = await new Promise<string | null>((resolve) => {
const callback = (password: string | null) => {
resolve(password);
const [password, usedBiometrics] = await new Promise<[string | null, boolean]>((resolve) => {
const callback = (password: string | null, usedBiometrics: boolean) => {
resolve([password, usedBiometrics]);
emitter.removeListener("password", callback);
};
emitter.once("password", callback);
});
PASSWORD_STATE.passwordViaBiometricSource = null;
return password;
return [password, biometricsEnabled, usedBiometrics];
}
44 changes: 43 additions & 1 deletion source/renderer/actions/unlockVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { getPrimaryPassword } from "./password";
import { setBusy } from "../state/app";
import { showError } from "../services/notifications";
import { logInfo } from "../library/log";
import { getVaultSettings, saveVaultSettings } from "../services/vaultSettings";
import { t } from "../../shared/i18n/trans";

export async function unlockVaultSource(sourceID: VaultSourceID): Promise<boolean> {
const password = await getPrimaryPassword(sourceID);
const [password, biometricsEnabled, usedBiometrics] = await getPrimaryPassword(sourceID);
if (!password) return false;
setBusy(true);
logInfo(`Unlocking source: ${sourceID}`);
Expand All @@ -23,6 +24,47 @@ export async function unlockVaultSource(sourceID: VaultSourceID): Promise<boolea
return await unlockVaultSource(sourceID);
}
setBusy(false);
// Update config
if (biometricsEnabled) {
const vaultSettings = await getVaultSettings(sourceID);
const { biometricForcePasswordMaxInterval, biometricForcePasswordCount } = vaultSettings;
const maxPasswordCount = parseInt(biometricForcePasswordCount, 10);
const maxInterval = parseInt(biometricForcePasswordMaxInterval, 10);
if (!isNaN(maxPasswordCount) && maxPasswordCount > 0 && usedBiometrics) {
// Max password count enabled, increment count
vaultSettings.biometricUnlockCount += 1;
logInfo(`biometric unlock count increased: ${vaultSettings.biometricUnlockCount}`);
} else {
// Not enabled, ensure 0
vaultSettings.biometricUnlockCount = 0;
}
if (!isNaN(maxInterval) && maxInterval > 0 && usedBiometrics) {
// Interval enabled, set to now
if (
typeof vaultSettings.biometricLastManualUnlock === "number" &&
vaultSettings.biometricLastManualUnlock > 0
) {
logInfo(
`biometric unlock date ignored as already set: ${vaultSettings.biometricLastManualUnlock}`
);
} else {
vaultSettings.biometricLastManualUnlock = Date.now();
logInfo(`biometric unlock date set: ${vaultSettings.biometricLastManualUnlock}`);
}
} else if (
typeof vaultSettings.biometricLastManualUnlock === "number" &&
vaultSettings.biometricLastManualUnlock > 0
) {
// Exceeded: new date
vaultSettings.biometricLastManualUnlock = Date.now();
logInfo(`biometric unlock date reset: ${vaultSettings.biometricLastManualUnlock}`);
} else {
// Not enabled: back to null
vaultSettings.biometricLastManualUnlock = null;
}
await saveVaultSettings(sourceID, vaultSettings);
}
// Return result
logInfo(`Unlocked source: ${sourceID}`);
return true;
}
145 changes: 92 additions & 53 deletions source/renderer/components/PasswordPrompt.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSingleState } from "react-obstate";
import { Button, Classes, Dialog, FormGroup, InputGroup, Intent, NonIdealState } from "@blueprintjs/core";
import { Button, Classes, Colors, Dialog, FormGroup, InputGroup, Intent, NonIdealState } from "@blueprintjs/core";
import { Layerr } from "layerr";
import ms from "ms";
import styled from "styled-components";
import { PASSWORD_STATE } from "../state/password";
import { VAULTS_STATE } from "../state/vaults";
import { getPasswordEmitter } from "../services/password";
Expand All @@ -19,6 +21,15 @@ enum PromptType {
Password = "password"
}

const DAY_MS = ms("1d");

const FallbackText = styled.i`
color: ${Colors.RED3};
font-weight: 500;
display: block;
margin-bottom: 12px;
`;

export function PasswordPrompt() {
const emitter = useMemo(getPasswordEmitter, []);
const [biometricSourceID] = useSingleState(PASSWORD_STATE, "passwordViaBiometricSource");
Expand All @@ -27,42 +38,57 @@ export function PasswordPrompt() {
const [showPassword, setShowPassword] = useState(false);
const [sourceID] = useSingleState(VAULTS_STATE, "currentVault");
const [settings, setSettings] = useState<VaultSettingsLocal | null>(null);
// const [promptType, setPromptType] = useState<PromptType>(PromptType.Password);
const [promptedBiometrics, setPromptedBiometrics] = useState<boolean>(false);
// Callbacks
const closePrompt = useCallback(() => {
setCurrentPassword(""); // clear
// setPromptType(PromptType.None);
setShowPrompt(false);
setPromptedBiometrics(false);
emitter.emit("password", null);
}, [emitter, setShowPrompt]);
const submitPasswordPrompt = useCallback((password: string) => {
emitter.emit("password", password);
const submitPasswordPrompt = useCallback((password: string, usedBiometrics: boolean) => {
emitter.emit("password", password, usedBiometrics);
setShowPrompt(false);
setCurrentPassword("");
// setPromptType(PromptType.None);
setPromptedBiometrics(false);
}, [emitter, setShowPrompt]);
const handleKeyPress = useCallback(
(event) => {
if (event.key === "Enter") {
submitPasswordPrompt(currentPassword);
submitPasswordPrompt(currentPassword, false);
}
},
[currentPassword, submitPasswordPrompt]
);
// Living data
const promptType = useMemo(() => {
let type: PromptType = PromptType.None;
const [promptType, fallbackReason] = useMemo<[PromptType, string | null]>(() => {
if (!showPrompt) return [PromptType.None, null];
const currentSettings = settings || { ...VAULT_SETTINGS_DEFAULT };
const {
biometricForcePasswordCount,
biometricForcePasswordMaxInterval,
biometricLastManualUnlock,
biometricUnlockCount
} = currentSettings;
const bioPassCount = parseInt(biometricForcePasswordCount, 10);
const bioInterval = parseInt(biometricForcePasswordMaxInterval, 10);
const bioPassCountExceeded = !isNaN(bioPassCount) && bioPassCount > 0 && biometricUnlockCount >= bioPassCount;
const bioIntervalPassed = !isNaN(bioInterval) &&
bioInterval > 0 &&
typeof biometricLastManualUnlock === "number" &&
biometricLastManualUnlock < (Date.now() - (bioInterval * DAY_MS));
if (biometricSourceID && biometricSourceID === sourceID) {
return PromptType.Biometric;
if (bioPassCountExceeded) {
return [PromptType.Password, t("dialog.password-prompt.biometric-fallback.unlock-count-exceeded")];
} else if (bioIntervalPassed) {
return [PromptType.Password, t("dialog.password-prompt.biometric-fallback.unlock-period-exceeded")];
}
return [PromptType.Biometric, null];
} else if (sourceID) {
return PromptType.Password;
return [PromptType.Password, null];
}
logInfo(`detect prompt for vault unlock: ${type} (${sourceID})`);
return PromptType.None;
}, [biometricSourceID, sourceID, settings]);
return [PromptType.None, null];
}, [biometricSourceID, sourceID, settings, showPrompt]);
// Helpers
const updateVaultSettings = useCallback(async (): Promise<VaultSettingsLocal> => {
if (!sourceID) {
Expand Down Expand Up @@ -93,57 +119,70 @@ export function PasswordPrompt() {
if (!showPrompt) return;
updateVaultSettings();
}, [showPrompt, updateVaultSettings]);
const promptBiometrics = useCallback(async () => {
if (!biometricSourceID) {
throw new Error("Environment not ready for biometric prompt");
}
const sourcePassword = await getBiometricSourcePassword(biometricSourceID);
if (!sourcePassword) return;
submitPasswordPrompt(sourcePassword, true);
}, [biometricSourceID, submitPasswordPrompt]);
useEffect(() => {
if (!showPrompt || promptType !== PromptType.Biometric || promptedBiometrics || !biometricSourceID) return;
setPromptedBiometrics(true);
getBiometricSourcePassword(biometricSourceID)
.then((sourcePassword) => {
if (!sourcePassword) return;
submitPasswordPrompt(sourcePassword);
})
.catch((err) => {
const timeout = setTimeout(() => {
setPromptedBiometrics(true);
promptBiometrics().catch((err) => {
logErr(`Failed getting biometrics password for source: ${sourceID}`, err);
const errInfo = Layerr.info(err);
const message = (errInfo?.i18n && t(errInfo.i18n)) || err.message;
showError(message);
});
}, [biometricSourceID, promptedBiometrics, promptType, showPrompt, submitPasswordPrompt]);
}, 250);
return () => {
clearTimeout(timeout);
};
}, [biometricSourceID, promptBiometrics, promptType, showPrompt]);
// Render
return (
<Dialog isOpen={showPrompt} onClose={closePrompt}>
<div className={Classes.DIALOG_HEADER}>{t("dialog.password-prompt.title")}</div>
<div className={Classes.DIALOG_BODY}>
{promptType === PromptType.Password && (
<FormGroup
label={t("dialog.password-prompt.label")}
labelFor="password"
labelInfo={t("input-required")}
>
<InputGroup
id="password"
placeholder={t("dialog.password-prompt.placeholder")}
type={showPassword ? "text" : "password"}
rightElement={
<Button
icon={showPassword ? "unlock" : "lock"}
intent={Intent.NONE}
minimal
onMouseEnter={() => {
setShowPassword(true);
}}
onMouseLeave={() => {
setShowPassword(false);
}}
active={showPassword}
style={{ outline: "none", userSelect: "none" }}
/>
}
value={currentPassword}
onChange={(evt) => setCurrentPassword(evt.target.value)}
onKeyDown={handleKeyPress}
autoFocus
/>
</FormGroup>
<>
{fallbackReason && (
<FallbackText>{fallbackReason}</FallbackText>
)}
<FormGroup
label={t("dialog.password-prompt.label")}
labelFor="password"
labelInfo={t("input-required")}
>
<InputGroup
id="password"
placeholder={t("dialog.password-prompt.placeholder")}
type={showPassword ? "text" : "password"}
rightElement={
<Button
icon={showPassword ? "unlock" : "lock"}
intent={Intent.NONE}
minimal
onMouseEnter={() => {
setShowPassword(true);
}}
onMouseLeave={() => {
setShowPassword(false);
}}
active={showPassword}
style={{ outline: "none", userSelect: "none" }}
/>
}
value={currentPassword}
onChange={(evt) => setCurrentPassword(evt.target.value)}
onKeyDown={handleKeyPress}
autoFocus
/>
</FormGroup>
</>
)}
{promptType === PromptType.Biometric && (
<NonIdealState
Expand All @@ -158,7 +197,7 @@ export function PasswordPrompt() {
<Button
disabled={promptType !== PromptType.Password}
intent={Intent.PRIMARY}
onClick={() => submitPasswordPrompt(currentPassword)}
onClick={() => submitPasswordPrompt(currentPassword, false)}
title={t("dialog.password-prompt.button-unlock-title")}
>
{t("dialog.password-prompt.button-unlock")}
Expand Down
32 changes: 18 additions & 14 deletions source/shared/i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@
"title": "Add Vault File"
},
"password-prompt": {
"biometric-fallback": {
"unlock-count-exceeded": "Maximum biometric unlock count exceeded: password required",
"unlock-period-exceeded": "Biometric unlock time period expired: password required"
},
"button-cancel": "Cancel",
"button-cancel-title": "Cancel unlock",
"button-unlock": "Unlock",
Expand Down Expand Up @@ -331,6 +335,20 @@
"switch": "Enable local backups",
"title": "Backup"
},
"biometric": {
"description": "Configure password requirement when using biometrics",
"enable-password-prompt-count": {
"label": "Maximum number of biometric unlocks before prompting to type in password",
"placeholder": "Number of biometric unlock",
"helper": "Leave blank or set it to '0' to disable"
},
"enable-password-prompt-timeout": {
"label": "Maximum number of days before prompting to type in password",
"placeholder": "Number of days",
"helper": "Leave blank or set it to '0' to disable"
},
"title": "Biometrics"
},
"format": {
"a-description": "Format A is the original Buttercup vault format that uses deltas to store vault structure.",
"b-description": "Format B is the new Buttercup vault format that uses a JSON structure to manage vault structure and history.",
Expand All @@ -340,20 +358,6 @@
"title": "Format",
"upgrade-button": "Upgrade"
},
"biometric": {
"title": "Biometric",
"description": "Configure when requiring to type in password",
"enable-password-prompt-timeout": {
"label": "Maximum number of days before prompting to type in password",
"placeholder": "Number of days",
"helper": "Leave blank or set it to '0' to disable"
},
"enable-password-prompt-count": {
"label": "Maximum number of biometric unlocks before prompting to type in password",
"placeholder": "Number of biometric unlock",
"helper": "Leave blank or set it to '0' to disable"
}
},
"not-unlocked": "Vault must be unlocked to access this area.",
"title": "Vault Settings: {{title}}"
},
Expand Down
2 changes: 1 addition & 1 deletion source/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export interface UpdateProgressInfo {
export interface VaultSettingsLocal {
biometricForcePasswordCount: string;
biometricForcePasswordMaxInterval: string;
biometricLastManualUnlock: number;
biometricLastManualUnlock: number | null;
biometricUnlockCount: number;
localBackup: boolean;
localBackupLocation: null | string;
Expand Down

0 comments on commit 1a8c329

Please sign in to comment.